Skip to content

Commit 302b8bc

Browse files
authored
fix: prevent double execution when not found (#339)
* fix: prevent double execution when not found * format
1 parent 043e5b9 commit 302b8bc

File tree

4 files changed

+77
-34
lines changed

4 files changed

+77
-34
lines changed

mocks/app/routes/_404.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { NotFoundHandler } from 'hono'
22

33
const handler: NotFoundHandler = (c) => {
4+
c.res.headers.append('HeaderFrom404', 'Hi')
45
return c.render(<h1>Not Found</h1>, {
56
title: 'Not Found',
67
})

mocks/app/routes/_renderer.tsx

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,28 @@
1+
import { createMiddleware } from 'hono/factory'
12
import { jsxRenderer } from 'hono/jsx-renderer'
23
import { HasIslands } from '../../../src/server'
34

4-
export default jsxRenderer(
5-
({ children, title }) => {
6-
return (
7-
<html>
8-
<head>
9-
<title>{title}</title>
10-
</head>
11-
<body>
12-
{children}
13-
<HasIslands>
14-
<script type='module' async src='/app/client.ts'></script>
15-
</HasIslands>
16-
</body>
17-
</html>
18-
)
19-
},
20-
{ stream: true }
21-
)
5+
const middleware = createMiddleware(async (c, next) => {
6+
c.res.headers.append('HeaderFromRenderer', 'Hi')
7+
const renderer = jsxRenderer(
8+
({ children, title }) => {
9+
return (
10+
<html>
11+
<head>
12+
<title>{title}</title>
13+
</head>
14+
<body>
15+
{children}
16+
<HasIslands>
17+
<script type='module' async src='/app/client.ts'></script>
18+
</HasIslands>
19+
</body>
20+
</html>
21+
)
22+
},
23+
{ stream: true }
24+
)
25+
await renderer(c, next)
26+
})
27+
28+
export default middleware

src/server/server.ts

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@ export const createApp = <E extends Env>(options: BaseServerOptions<E>): Hono<E>
6767
// Key: directory path, Value: Set of middleware file paths applied to that directory
6868
const appliedMiddlewaresByDirectory = new Map<string, Set<string>>()
6969

70+
// Track responses that have already been processed by 404 handler to prevent double execution
71+
const processedNotFoundResponses = new WeakSet<Response>()
72+
73+
// Track requests that have been processed by each renderer to prevent double execution
74+
const processedRendererRequests = new WeakMap<MiddlewareHandler, WeakSet<Request>>()
75+
7076
// Share context by AsyncLocalStorage
7177
app.use(async function ShareContext(c, next) {
7278
await contextStorage.run(c, () => next())
@@ -128,13 +134,14 @@ export const createApp = <E extends Env>(options: BaseServerOptions<E>): Hono<E>
128134
if (notFoundHandler) {
129135
subApp.use(async (c, next) => {
130136
await next()
131-
if (c.res.status === 404) {
137+
if (c.res.status === 404 && !processedNotFoundResponses.has(c.res)) {
132138
const notFoundResponse = await notFoundHandler(c)
133139
const res = new Response(notFoundResponse.body, {
134140
status: 404,
135141
headers: notFoundResponse.headers,
136142
})
137143
c.res = res
144+
processedNotFoundResponses.add(c.res)
138145
}
139146
})
140147
}
@@ -177,19 +184,34 @@ export const createApp = <E extends Env>(options: BaseServerOptions<E>): Hono<E>
177184
}
178185
const rendererDefault = renderer.default
179186
if (rendererDefault) {
180-
subApp.use('*', rendererDefault)
187+
// Get or create WeakSet for this renderer
188+
if (!processedRendererRequests.has(rendererDefault)) {
189+
processedRendererRequests.set(rendererDefault, new WeakSet<Request>())
190+
}
191+
const processedRequests = processedRendererRequests.get(rendererDefault)!
192+
193+
// Wrap renderer to check if already processed
194+
const wrappedRenderer = createMiddleware(async (c, next) => {
195+
if (!processedRequests.has(c.req.raw)) {
196+
processedRequests.add(c.req.raw)
197+
return rendererDefault(c, next)
198+
}
199+
return next()
200+
})
201+
202+
subApp.use('*', wrappedRenderer)
181203

182204
// Apply extra middleware for parent routing patterns like /:lang{en} or /:lang?
183205
const rootPath = dir.replace(rootRegExp, '')
184206
const isRootLevel = !rootPath.includes('/')
185207
const isSimpleStructure = !Object.keys(content).some((f) => f.includes('/'))
186208

187209
if (Object.keys(content).length > 0 && isRootLevel && isSimpleStructure) {
188-
subApp.use('/', rendererDefault)
210+
subApp.use('/', wrappedRenderer)
189211
Object.keys(content).forEach((filename) => {
190212
const path = filePathToPath(filename)
191213
if (path !== '/' && !path.includes('[') && !path.includes('*')) {
192-
subApp.use(path, rendererDefault)
214+
subApp.use(path, wrappedRenderer)
193215
}
194216
})
195217
}
@@ -320,7 +342,7 @@ export const createApp = <E extends Env>(options: BaseServerOptions<E>): Hono<E>
320342
const subApp = new Hono<{
321343
Variables: Variables
322344
}>()
323-
applyNotFound(subApp, dir, notFoundMap)
345+
applyNotFound(subApp, dir, notFoundMap, processedNotFoundResponses)
324346
const rootPath = getRootPath(dir)
325347
app.route(rootPath, subApp)
326348
}
@@ -344,7 +366,8 @@ function applyNotFound(
344366
Variables: Variables
345367
}>,
346368
dir: string,
347-
map: Record<string, Record<string, NotFoundFile>>
369+
map: Record<string, Record<string, NotFoundFile>>,
370+
processedNotFoundResponses: WeakSet<Response>
348371
) {
349372
for (const [mapDir, content] of Object.entries(map)) {
350373
if (dir === mapDir) {
@@ -358,9 +381,13 @@ function applyNotFound(
358381
return next()
359382
})
360383
}
361-
app.get('*', (c) => {
362-
c.status(404)
363-
return notFoundHandler(c)
384+
app.get('*', async (c, next) => {
385+
await next()
386+
if (processedNotFoundResponses.has(c.res)) {
387+
c.status(404)
388+
processedNotFoundResponses.add(c.res)
389+
c.res = await notFoundHandler(c)
390+
}
364391
})
365392
}
366393
}

test-integration/apps.test.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,15 @@ describe('Basic', () => {
1010
const NOT_FOUND = import.meta.glob('../mocks/app/routes/**/_404.(ts|tsx)', {
1111
eager: true,
1212
})
13+
const RENDERER = import.meta.glob('../mocks/app/routes/**/_renderer.(ts|tsx)', {
14+
eager: true,
15+
})
1316

1417
const app = createApp({
1518
root: '../mocks/app/routes',
1619
ROUTES: ROUTES as any,
1720
NOT_FOUND: NOT_FOUND as any,
21+
RENDERER: RENDERER as any,
1822
init: (app) => {
1923
app.use('*', poweredBy())
2024
},
@@ -106,7 +110,7 @@ describe('Basic', () => {
106110
method: 'GET',
107111
},
108112
]
109-
expect(app.routes).toHaveLength(52)
113+
expect(app.routes).toHaveLength(69)
110114
expect(app.routes).toEqual(
111115
expect.arrayContaining(
112116
routes.map(({ path, method }) => {
@@ -123,26 +127,30 @@ describe('Basic', () => {
123127
it('Should return 200 response - / with a Powered By header', async () => {
124128
const res = await app.request('/')
125129
expect(res.status).toBe(200)
126-
expect(await res.text()).toBe('<h1>Hello</h1>')
130+
expect(await res.text()).toContain('<h1>Hello</h1>')
127131
expect(res.headers.get('x-powered-by'), 'Hono')
128132
})
129133

130134
it('Should return 404 response - /foo', async () => {
131135
const res = await app.request('/foo')
136+
expect(res.headers.get('HeaderFrom404')).toBe('Hi')
137+
expect(res.headers.get('HeaderFromRenderer')).toBe('Hi')
132138
expect(res.status).toBe(404)
133139
})
134140

135141
it('Should return custom 404 response - /not-found', async () => {
136142
const res = await app.request('/not-found')
137143
expect(res.status).toBe(404)
138-
expect(await res.text()).toBe('<h1>Not Found</h1>')
144+
expect(res.headers.get('HeaderFrom404')).toBe('Hi')
145+
expect(res.headers.get('HeaderFromRenderer')).toBe('Hi')
146+
expect(await res.text()).toContain('<h1>Not Found</h1>')
139147
})
140148

141149
it('Should return 200 response - /about/me', async () => {
142150
const res = await app.request('/about/me')
143151
expect(res.status).toBe(200)
144152
// hono/jsx escape a single quote to &#39;
145-
expect(await res.text()).toBe('<p>It&#39;s me</p><b>My name is me</b>')
153+
expect(await res.text()).toContain('<p>It&#39;s me</p><b>My name is me</b>')
146154
})
147155

148156
it('Should return 200 response - POST /about/me', async () => {
@@ -155,19 +163,19 @@ describe('Basic', () => {
155163
it('Should return 200 response - GET /fc', async () => {
156164
const res = await app.request('/fc')
157165
expect(res.status).toBe(200)
158-
expect(await res.text()).toBe('<h1>Function from /fc</h1>')
166+
expect(await res.text()).toContain('<h1>Function from /fc</h1>')
159167
})
160168

161169
it('Should not determined as an island component - GET /non-interactive', async () => {
162170
const res = await app.request('/non-interactive')
163171
expect(res.status).toBe(200)
164-
expect(await res.text()).toBe('<p>Not Island</p>')
172+
expect(await res.text()).toContain('<p>Not Island</p>')
165173
})
166174

167175
it('Should render MDX content - /post', async () => {
168176
const res = await app.request('/post')
169177
expect(res.status).toBe(200)
170-
expect(await res.text()).toBe('<h1>Hello MDX</h1>')
178+
expect(await res.text()).toContain('<h1>Hello MDX</h1>')
171179
})
172180

173181
it('Should return 500 response - /throw_error', async () => {

0 commit comments

Comments
 (0)