Skip to content

Commit cb7982b

Browse files
committed
fix: prevent root wildcard static routes from overriding mount routes (#1515)
When using static Response/HTMLBundle on root wildcard routes (e.g., GET /*), Bun's nativeStaticResponse optimization would add them to Bun's static route table, which has highest priority and bypasses Elysia's dynamic router. This caused SPA fallback patterns with mounted APIs to fail: app.mount('/api', backend) .get('/*', staticHtmlResponse) The /api/* routes would return HTML instead of API responses because the /* wildcard in Bun's static table matched everything. Solution: - During listen(), collect mount prefixes by scanning router.history for routes with hooks.config.mount flag - In createStaticRoute(), skip root wildcard paths (/* or /) when mounts exist - This forces root wildcards through the dynamic router where the route specificity fix from #1682 correctly handles priority Production-grade approach: - Minimal performance impact: only scans during .listen() once - Surgical fix: only affects root wildcards when mounts exist - Preserves optimization: specific static routes still go to Bun table - Backwards compatible: doesn't break existing apps without mounts Fixes #1515
1 parent 57dcc20 commit cb7982b

2 files changed

Lines changed: 209 additions & 0 deletions

File tree

src/adapter/bun/index.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,17 @@ export const BunAdapter: ElysiaAdapter = {
226226
options = parseInt(options)
227227
}
228228

229+
// Collect mount prefixes to prevent wildcard static routes from overriding them
230+
// Mount routes are registered as ALL with config.mount, often with paths like /api/*
231+
const mountPrefixes = new Set<string>()
232+
for (const route of app.router.history) {
233+
if (route.hooks?.config?.mount && route.path.includes('*')) {
234+
// Extract prefix from paths like /api/* or /v1/*
235+
const prefix = route.path.slice(0, route.path.lastIndexOf('/*'))
236+
if (prefix) mountPrefixes.add(prefix)
237+
}
238+
}
239+
229240
const createStaticRoute = <
230241
WithAsync extends boolean | undefined = false
231242
>(
@@ -254,6 +265,17 @@ export const BunAdapter: ElysiaAdapter = {
254265
for (let [path, route] of Object.entries(iterator)) {
255266
path = encodeURI(path)
256267

268+
// Skip wildcard static routes that would override mount prefixes
269+
// e.g., skip GET /* if we have mounts like /api/* or /v1/*
270+
if (path.endsWith('/*') && mountPrefixes.size > 0) {
271+
const wildcardPrefix = path.slice(0, -2) // Remove /*
272+
// If this is a root wildcard /* and we have any mounts, skip it
273+
// to let the dynamic router handle route priority correctly
274+
if (wildcardPrefix === '' || wildcardPrefix === '/') {
275+
continue
276+
}
277+
}
278+
257279
if (supportPerMethodInlineHandler) {
258280
if (!route) continue
259281

test/core/spa-fallback.test.ts

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { describe, it, expect } from 'bun:test'
2+
import { Elysia } from '../../src'
3+
4+
describe('SPA Fallback Routing - Issue #1515', () => {
5+
it('handles mount routes with root wildcard static response', async () => {
6+
const backend = new Elysia().get('/users', () => ({
7+
users: ['alice', 'bob']
8+
}))
9+
10+
const staticHtml = new Response('<html><body>SPA</body></html>', {
11+
headers: { 'content-type': 'text/html' }
12+
})
13+
14+
const app = new Elysia()
15+
.mount('/api', backend)
16+
.get('/*', staticHtml)
17+
.listen(0)
18+
19+
const apiResponse = await app
20+
.handle(new Request(`http://localhost/api/users`))
21+
.then((r) => r.json())
22+
23+
const spaResponse = await app
24+
.handle(new Request(`http://localhost/other`))
25+
.then((r) => r.text())
26+
27+
expect(apiResponse).toEqual({ users: ['alice', 'bob'] })
28+
expect(spaResponse).toContain('SPA')
29+
30+
await app.server!.stop()
31+
})
32+
33+
it('handles multiple mounts with root wildcard', async () => {
34+
const backend = new Elysia().get('/users', () => ({ users: ['test'] }))
35+
36+
const staticHtml = new Response('<html>SPA</html>', {
37+
headers: { 'content-type': 'text/html' }
38+
})
39+
40+
const app = new Elysia()
41+
.mount('/api', backend)
42+
.mount('/v2', backend)
43+
.get('/*', staticHtml)
44+
.listen(0)
45+
46+
const api1 = await app
47+
.handle(new Request('http://localhost/api/users'))
48+
.then((r) => r.json())
49+
50+
const api2 = await app
51+
.handle(new Request('http://localhost/v2/users'))
52+
.then((r) => r.json())
53+
54+
const spa = await app
55+
.handle(new Request('http://localhost/other'))
56+
.then((r) => r.text())
57+
58+
expect(api1).toEqual({ users: ['test'] })
59+
expect(api2).toEqual({ users: ['test'] })
60+
expect(spa).toContain('SPA')
61+
62+
await app.server!.stop()
63+
})
64+
65+
it('respects order - wildcard registered before mount', async () => {
66+
const backend = new Elysia().get('/users', () => ({ data: 'api' }))
67+
68+
const staticHtml = new Response('<html>SPA</html>', {
69+
headers: { 'content-type': 'text/html' }
70+
})
71+
72+
const app = new Elysia()
73+
.get('/*', staticHtml)
74+
.mount('/api', backend)
75+
.listen(0)
76+
77+
const api = await app
78+
.handle(new Request('http://localhost/api/users'))
79+
.then((r) => r.json())
80+
81+
const spa = await app
82+
.handle(new Request('http://localhost/other'))
83+
.then((r) => r.text())
84+
85+
expect(api).toEqual({ data: 'api' })
86+
expect(spa).toContain('SPA')
87+
88+
await app.server!.stop()
89+
})
90+
91+
it('handles POST requests to mounted routes', async () => {
92+
const backend = new Elysia().post('/auth', () => ({ token: 'xyz' }))
93+
94+
const staticHtml = new Response('<html>SPA</html>', {
95+
headers: { 'content-type': 'text/html' }
96+
})
97+
98+
const app = new Elysia()
99+
.mount('/api', backend)
100+
.get('/*', staticHtml)
101+
.listen(0)
102+
103+
const response = await app
104+
.handle(new Request('http://localhost/api/auth', { method: 'POST' }))
105+
.then((r) => r.json())
106+
107+
expect(response).toEqual({ token: 'xyz' })
108+
109+
await app.server!.stop()
110+
})
111+
112+
it('allows specific wildcard paths with mounts', async () => {
113+
const backend = new Elysia().get('/users', () => ({ data: 'api' }))
114+
115+
const publicHtml = new Response('<html>Public</html>', {
116+
headers: { 'content-type': 'text/html' }
117+
})
118+
119+
const app = new Elysia()
120+
.mount('/api', backend)
121+
.get('/public/*', publicHtml)
122+
.listen(0)
123+
124+
const api = await app
125+
.handle(new Request('http://localhost/api/users'))
126+
.then((r) => r.json())
127+
128+
const publicAsset = await app
129+
.handle(new Request('http://localhost/public/asset.js'))
130+
.then((r) => r.text())
131+
132+
expect(api).toEqual({ data: 'api' })
133+
expect(publicAsset).toContain('Public')
134+
135+
await app.server!.stop()
136+
})
137+
138+
it('preserves normal wildcard behavior without mounts', async () => {
139+
const staticHtml = new Response('<html>SPA</html>', {
140+
headers: { 'content-type': 'text/html' }
141+
})
142+
143+
const app = new Elysia()
144+
.get('/api/users', () => ({ users: ['alice'] }))
145+
.get('/*', staticHtml)
146+
.listen(0)
147+
148+
const api = await app
149+
.handle(new Request('http://localhost/api/users'))
150+
.then((r) => r.json())
151+
152+
const spa = await app
153+
.handle(new Request('http://localhost/other'))
154+
.then((r) => r.text())
155+
156+
expect(api).toEqual({ users: ['alice'] })
157+
expect(spa).toContain('SPA')
158+
159+
await app.server!.stop()
160+
})
161+
162+
it('handles nested mount paths correctly', async () => {
163+
const backend = new Elysia().get('/status', () => ({ status: 'ok' }))
164+
165+
const staticHtml = new Response('<html>SPA</html>', {
166+
headers: { 'content-type': 'text/html' }
167+
})
168+
169+
const app = new Elysia()
170+
.mount('/api/v1', backend)
171+
.get('/*', staticHtml)
172+
.listen(0)
173+
174+
const api = await app
175+
.handle(new Request('http://localhost/api/v1/status'))
176+
.then((r) => r.json())
177+
178+
const spa = await app
179+
.handle(new Request('http://localhost/other'))
180+
.then((r) => r.text())
181+
182+
expect(api).toEqual({ status: 'ok' })
183+
expect(spa).toContain('SPA')
184+
185+
await app.server!.stop()
186+
})
187+
})

0 commit comments

Comments
 (0)