Skip to content

Commit 4baa045

Browse files
committed
feat: implement group context isolation for async route registration in Router
1 parent 921fc67 commit 4baa045

4 files changed

Lines changed: 103 additions & 23 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "clear-router",
3-
"version": "2.1.8",
3+
"version": "2.1.9",
44
"description": "Laravel-style routing system for Express.js and H3, with CommonJS, ESM, and TypeScript support",
55
"keywords": [
66
"h3",

src/express/router.ts

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { ApiResourceMiddleware, ControllerAction, HttpMethod } from 'types/basic'
22
import { Handler, HttpContext, Middleware, RouteHandler } from 'types/express'
33

4+
import { AsyncLocalStorage } from 'node:async_hooks'
45
import { ClearRequest } from 'src/ClearRequest'
56
import { Controller } from 'src/Controller'
67
import { Router as ExpressRouter } from 'express'
@@ -14,6 +15,11 @@ import { Route } from 'src/Route'
1415
* @repository https://github.com/toneflix/clear-router
1516
*/
1617
export class Router {
18+
private static readonly groupContext = new AsyncLocalStorage<{
19+
prefix: string
20+
groupMiddlewares: Middleware[]
21+
}>()
22+
1723
/**
1824
* All registered routes
1925
*/
@@ -69,18 +75,22 @@ export class Router {
6975
handler: Handler,
7076
middlewares?: Middleware[] | Middleware
7177
): void {
78+
const context = this.groupContext.getStore()
79+
const activePrefix = context?.prefix ?? this.prefix
80+
const activeGroupMiddlewares = context?.groupMiddlewares ?? this.groupMiddlewares
81+
7282
methods = Array.isArray(methods) ? methods : [methods]
7383
middlewares = middlewares
7484
? (Array.isArray(middlewares) ? middlewares : [middlewares])
7585
: undefined
7686

77-
const fullPath = this.normalizePath(`${this.prefix}/${path}`)
87+
const fullPath = this.normalizePath(`${activePrefix}/${path}`)
7888

7989
const route = new Route<HttpContext, Middleware>(
8090
methods.includes('options') ? methods : methods.concat('options'),
8191
fullPath,
8292
handler as never,
83-
[...this.globalMiddlewares, ...this.groupMiddlewares, ...(middlewares || [])]
93+
[...this.globalMiddlewares, ...activeGroupMiddlewares, ...(middlewares || [])]
8494
)
8595

8696
if (
@@ -232,22 +242,22 @@ export class Router {
232242
callback: () => void | Promise<void>,
233243
middlewares?: Middleware[]
234244
): Promise<void> {
235-
const previousPrefix = this.prefix
236-
const previousMiddlewares = this.groupMiddlewares
245+
const context = this.groupContext.getStore()
246+
const previousPrefix = context?.prefix ?? this.prefix
247+
const previousMiddlewares = context?.groupMiddlewares ?? this.groupMiddlewares
237248

238249
const fullPrefix = [previousPrefix, prefix]
239250
.filter(Boolean)
240251
.join('/')
241252

242-
this.prefix = this.normalizePath(fullPrefix)
243-
this.groupMiddlewares = [...previousMiddlewares, ...(middlewares || [])]
253+
const nextContext = {
254+
prefix: this.normalizePath(fullPrefix),
255+
groupMiddlewares: [...previousMiddlewares, ...(middlewares || [])],
256+
}
244257

245-
try {
258+
await this.groupContext.run(nextContext, async () => {
246259
await Promise.resolve(callback())
247-
} finally {
248-
this.prefix = previousPrefix
249-
this.groupMiddlewares = previousMiddlewares
250-
}
260+
})
251261
}
252262

253263
/**

src/h3/router.ts

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { ClearRequest } from 'src/ClearRequest'
22
import { ApiResourceMiddleware, ControllerAction, HttpMethod } from 'types/basic'
33
import { H3App, Handler, HttpContext, Middleware, RouteHandler } from 'types/h3'
4+
import { AsyncLocalStorage } from 'node:async_hooks'
45

56
import { getQuery, getRouterParams, readBody, type H3 } from 'h3'
67
import { Controller } from 'src/Controller'
@@ -13,6 +14,11 @@ import { Route } from 'src/Route'
1314
* @repository https://github.com/toneflix/clear-router
1415
*/
1516
export class Router {
17+
private static readonly groupContext = new AsyncLocalStorage<{
18+
prefix: string
19+
groupMiddlewares: Middleware[]
20+
}>()
21+
1622
/**
1723
* All registered routes
1824
*/
@@ -68,19 +74,23 @@ export class Router {
6874
handler: Handler,
6975
middlewares?: Middleware[] | Middleware
7076
): void {
77+
const context = this.groupContext.getStore()
78+
const activePrefix = context?.prefix ?? this.prefix
79+
const activeGroupMiddlewares = context?.groupMiddlewares ?? this.groupMiddlewares
80+
7181
methods = Array.isArray(methods) ? methods : [methods]
7282
middlewares = middlewares
7383
? (Array.isArray(middlewares) ? middlewares : [middlewares])
7484
: undefined
7585

7686

77-
const fullPath = this.normalizePath(`${this.prefix}/${path}`)
87+
const fullPath = this.normalizePath(`${activePrefix}/${path}`)
7888

7989
const route = new Route<HttpContext, Middleware>(
8090
methods.includes('options') ? methods : methods.concat('options'),
8191
fullPath,
8292
handler,
83-
[...this.globalMiddlewares, ...this.groupMiddlewares, ...(middlewares || [])]
93+
[...this.globalMiddlewares, ...activeGroupMiddlewares, ...(middlewares || [])]
8494
)
8595

8696
if (
@@ -231,22 +241,22 @@ export class Router {
231241
callback: () => void | Promise<void>,
232242
middlewares?: Middleware[]
233243
): Promise<void> {
234-
const previousPrefix = this.prefix
235-
const previousMiddlewares = this.groupMiddlewares
244+
const context = this.groupContext.getStore()
245+
const previousPrefix = context?.prefix ?? this.prefix
246+
const previousMiddlewares = context?.groupMiddlewares ?? this.groupMiddlewares
236247

237248
const fullPrefix = [previousPrefix, prefix]
238249
.filter(Boolean)
239250
.join('/')
240251

241-
this.prefix = this.normalizePath(fullPrefix)
242-
this.groupMiddlewares = [...previousMiddlewares, ...(middlewares || [])]
252+
const nextContext = {
253+
prefix: this.normalizePath(fullPrefix),
254+
groupMiddlewares: [...previousMiddlewares, ...(middlewares || [])],
255+
}
243256

244-
try {
257+
await this.groupContext.run(nextContext, async () => {
245258
await Promise.resolve(callback())
246-
} finally {
247-
this.prefix = previousPrefix
248-
this.groupMiddlewares = previousMiddlewares
249-
}
259+
})
250260
}
251261

252262
/**

tests/typescript.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,35 @@ describe('Express Routing - TypeScript', () => {
261261
expect(response.body.grouped).toBe(true)
262262
})
263263

264+
test('should keep group context isolated across async registration tasks', async () => {
265+
await Router.group('/api', async () => {
266+
void (async () => {
267+
await new Promise<void>(resolve => setTimeout(resolve, 20))
268+
Router.get('/late', ({ res }: HttpContext) => {
269+
res.json({ late: true })
270+
})
271+
})()
272+
})
273+
274+
await Router.group('/', async () => {
275+
await new Promise<void>(resolve => setTimeout(resolve, 5))
276+
Router.get('/home', ({ res }: HttpContext) => {
277+
res.json({ home: true })
278+
})
279+
})
280+
281+
await new Promise<void>(resolve => setTimeout(resolve, 35))
282+
await setupApp()
283+
284+
const home = await request(app).get('/home')
285+
expect(home.status).toBe(200)
286+
expect(home.body.home).toBe(true)
287+
288+
const late = await request(app).get('/api/late')
289+
expect(late.status).toBe(200)
290+
expect(late.body.late).toBe(true)
291+
})
292+
264293
test('should handle typed error in middleware', async () => {
265294
const errorMiddleware = (
266295
req: Request,
@@ -553,6 +582,37 @@ describe('H3 Routing - TypeScript', () => {
553582
expect(response.grouped).toBe(true)
554583
})
555584

585+
test('should keep h3 group context isolated across async registration tasks', async () => {
586+
await H3Router.group('/api', async () => {
587+
void (async () => {
588+
await new Promise<void>(resolve => setTimeout(resolve, 20))
589+
H3Router.get('/late', () => {
590+
return { late: true }
591+
})
592+
})()
593+
})
594+
595+
await H3Router.group('/', async () => {
596+
await new Promise<void>(resolve => setTimeout(resolve, 5))
597+
H3Router.get('/home', () => {
598+
return { home: true }
599+
})
600+
})
601+
602+
await new Promise<void>(resolve => setTimeout(resolve, 35))
603+
setupApp()
604+
605+
const home = await router
606+
.fetch(new global.Request(new URL('http://localhost/home'), { method: 'GET' }))
607+
.then(res => res.json())
608+
expect(home.home).toBe(true)
609+
610+
const late = await router
611+
.fetch(new global.Request(new URL('http://localhost/api/late'), { method: 'GET' }))
612+
.then(res => res.json())
613+
expect(late.late).toBe(true)
614+
})
615+
556616
test('should handle typed error in middleware', async () => {
557617
const errorMiddleware = (): void => {
558618
throw new HTTPError('Middleware error', { status: 500 })

0 commit comments

Comments
 (0)