@nextrush/router 3.0.7 → 4.0.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +250 -492
- package/dist/index.d.ts +142 -185
- package/dist/index.js +816 -665
- package/dist/index.js.map +1 -1
- package/package.json +7 -6
- package/src/__tests__/allowed-methods.test.ts +144 -0
- package/src/__tests__/audit-fixes.test.ts +71 -0
- package/src/__tests__/dispatch-deasync.test.ts +312 -0
- package/src/__tests__/find-node-differential.test.ts +220 -0
- package/src/__tests__/fixtures/match-golden.json +556 -0
- package/src/__tests__/helpers/differential-corpus.ts +316 -0
- package/src/__tests__/match-differential.test.ts +46 -0
- package/src/__tests__/match-hotpath-guard.test.ts +71 -0
- package/src/__tests__/match-normalize-fastpath.test.ts +66 -0
- package/src/__tests__/match-safety.test.ts +177 -0
- package/src/__tests__/match-single-alloc.test.ts +84 -0
- package/src/__tests__/middleware-pipeline.test.ts +306 -0
- package/src/__tests__/param-decoding.test.ts +78 -0
- package/src/__tests__/public-surface.test.ts +59 -0
- package/src/__tests__/route-metadata.test.ts +172 -0
- package/src/__tests__/router-audit.test.ts +326 -0
- package/src/__tests__/router-edge-cases.test.ts +2 -2
- package/src/__tests__/router.test.ts +148 -2
- package/src/__tests__/static-map-reset.test.ts +50 -0
- package/src/composition.ts +75 -0
- package/src/constants.ts +25 -0
- package/src/dispatch.ts +117 -0
- package/src/find-node.ts +125 -0
- package/src/group-router.ts +208 -0
- package/src/index.ts +8 -5
- package/src/match-route.ts +178 -0
- package/src/matching.ts +240 -0
- package/src/middleware-adapter.ts +59 -0
- package/src/redirect.ts +97 -0
- package/src/registration.ts +291 -0
- package/src/route-metadata.ts +68 -0
- package/src/router.ts +150 -881
- package/src/segment-trie.ts +219 -0
- package/src/state.ts +52 -0
- package/src/radix-tree.ts +0 -184
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nextrush/router - HP-10 single-allocation contract
|
|
3
|
+
*
|
|
4
|
+
* Pins design.md D1: a matched request produces ONE `RouteMatch` object with
|
|
5
|
+
* `middleware` attached, rather than a `matchRoute` result later wrapped by
|
|
6
|
+
* `resolveMatch`. Asserted at the `matchRoute` boundary — `matchRoute` now
|
|
7
|
+
* returns the full `RouteMatch` shape (incl. `middleware === routerMiddleware`)
|
|
8
|
+
* for both the static-map fast path and a param walk — plus at the public
|
|
9
|
+
* `Router.match()` boundary (shape unchanged, middleware attached once).
|
|
10
|
+
*
|
|
11
|
+
* The "exactly one object" claim itself is proven by the allocation micro-bench
|
|
12
|
+
* (`bench:alloc:router`); this file pins the observable single-object contract.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Middleware, RouteHandler } from '@nextrush/types';
|
|
16
|
+
import { describe, expect, it } from 'vitest';
|
|
17
|
+
import { matchRoute } from '../match-route';
|
|
18
|
+
import { compileExecutor, createNode, NodeType, type HandlerEntry, type StaticRouteMap } from '../segment-trie';
|
|
19
|
+
import { createRouter } from '../router';
|
|
20
|
+
|
|
21
|
+
const noop: RouteHandler = async () => {
|
|
22
|
+
/* no-op */
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe('HP-10 — single RouteMatch allocation', () => {
|
|
26
|
+
it('matchRoute attaches routerMiddleware to its own returned object (param walk)', () => {
|
|
27
|
+
const root = createNode('');
|
|
28
|
+
const users = createNode('users');
|
|
29
|
+
root.children.set('users', users);
|
|
30
|
+
const idNode = createNode(':id', NodeType.PARAM);
|
|
31
|
+
idNode.paramName = 'id';
|
|
32
|
+
users.paramChild = idNode;
|
|
33
|
+
const executor = compileExecutor(noop, []);
|
|
34
|
+
idNode.handlers.set('GET', { handler: noop, middleware: [], executor });
|
|
35
|
+
|
|
36
|
+
const routerMiddleware: Middleware[] = [async (_ctx, next) => next?.()];
|
|
37
|
+
const match = matchRoute(
|
|
38
|
+
'GET',
|
|
39
|
+
'/users/42',
|
|
40
|
+
root,
|
|
41
|
+
new Map() as StaticRouteMap,
|
|
42
|
+
true,
|
|
43
|
+
false,
|
|
44
|
+
false,
|
|
45
|
+
true,
|
|
46
|
+
routerMiddleware
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
expect(match).not.toBeNull();
|
|
50
|
+
expect(match?.handler).toBe(noop);
|
|
51
|
+
expect(match?.executor).toBe(executor);
|
|
52
|
+
expect(match?.params).toEqual({ id: '42' });
|
|
53
|
+
// The single-object contract: middleware lives on matchRoute's own return.
|
|
54
|
+
expect(match?.middleware).toBe(routerMiddleware);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('matchRoute attaches routerMiddleware on the static fast path too', () => {
|
|
58
|
+
const root = createNode('');
|
|
59
|
+
const executor = compileExecutor(noop, []);
|
|
60
|
+
const staticRoutes: StaticRouteMap = new Map([
|
|
61
|
+
['GET', new Map<string, HandlerEntry>([['/s', { handler: noop, middleware: [], executor }]])],
|
|
62
|
+
]);
|
|
63
|
+
const routerMiddleware: Middleware[] = [async (_ctx, next) => next?.()];
|
|
64
|
+
|
|
65
|
+
const match = matchRoute('GET', '/s', root, staticRoutes, false, false, false, true, routerMiddleware);
|
|
66
|
+
|
|
67
|
+
expect(match?.handler).toBe(noop);
|
|
68
|
+
expect(match?.middleware).toBe(routerMiddleware);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('Router.match attaches the SAME routerMiddleware array on every match (attached once, not rebuilt)', () => {
|
|
72
|
+
const mw: Middleware = async (_ctx, next) => next?.();
|
|
73
|
+
const router = createRouter();
|
|
74
|
+
router.use(mw);
|
|
75
|
+
router.get('/x', noop);
|
|
76
|
+
router.get('/y/:id', noop);
|
|
77
|
+
|
|
78
|
+
const m1 = router.match('GET', '/x');
|
|
79
|
+
const m2 = router.match('GET', '/y/7');
|
|
80
|
+
|
|
81
|
+
expect(m1?.middleware).toBe(m2?.middleware);
|
|
82
|
+
expect(m1?.middleware).toContain(mw);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nextrush/router - Middleware pipeline edge cases
|
|
3
|
+
*
|
|
4
|
+
* Exercises per-route middleware (compiled by `compileExecutor`) across both
|
|
5
|
+
* calling styles — traditional `(ctx, next)` and modern `ctx.next()` — and every
|
|
6
|
+
* pipeline edge case: onion ordering, short-circuit, async ordering, error
|
|
7
|
+
* propagation, state sharing, chain sizes, mixed styles, sync throws, and the
|
|
8
|
+
* double-next() guard.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Context, Middleware, RouteHandler } from '@nextrush/types';
|
|
12
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
13
|
+
import { createRouter, Router } from '../router';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Context mock whose setNext/next behave like a real adapter context:
|
|
17
|
+
* setNext stores the wired next; ctx.next() invokes it.
|
|
18
|
+
*/
|
|
19
|
+
function createCtx(overrides: Partial<Context> = {}): Context {
|
|
20
|
+
let stored: () => Promise<void> = () => Promise.resolve();
|
|
21
|
+
return {
|
|
22
|
+
method: 'GET',
|
|
23
|
+
path: '/',
|
|
24
|
+
params: {},
|
|
25
|
+
query: {},
|
|
26
|
+
body: undefined,
|
|
27
|
+
headers: {},
|
|
28
|
+
status: 200,
|
|
29
|
+
state: {},
|
|
30
|
+
responded: false,
|
|
31
|
+
json: vi.fn(),
|
|
32
|
+
send: vi.fn(),
|
|
33
|
+
html: vi.fn(),
|
|
34
|
+
redirect: vi.fn(),
|
|
35
|
+
set: vi.fn(),
|
|
36
|
+
get: vi.fn(),
|
|
37
|
+
setNext: (fn: () => Promise<void>) => {
|
|
38
|
+
stored = fn;
|
|
39
|
+
},
|
|
40
|
+
next: () => stored(),
|
|
41
|
+
raw: { req: {}, res: {} },
|
|
42
|
+
...overrides,
|
|
43
|
+
} as unknown as Context;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const run = (router: Router, ctx: Context): Promise<void> => router.routes()(ctx, async () => {});
|
|
47
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
48
|
+
|
|
49
|
+
describe.each([
|
|
50
|
+
['traditional (ctx, next)', false],
|
|
51
|
+
['modern ctx.next()', true],
|
|
52
|
+
])('per-route middleware pipeline — %s', (_label, modern) => {
|
|
53
|
+
let router: Router;
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
router = createRouter();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/** Build a middleware in the style under test that runs `before`, awaits, then `after`. */
|
|
59
|
+
const layer = (before: (ctx: Context) => void, after?: (ctx: Context) => void): Middleware =>
|
|
60
|
+
modern
|
|
61
|
+
? (async (ctx: Context) => {
|
|
62
|
+
before(ctx);
|
|
63
|
+
await ctx.next();
|
|
64
|
+
after?.(ctx);
|
|
65
|
+
}) as Middleware
|
|
66
|
+
: (async (ctx: Context, next: () => Promise<void>) => {
|
|
67
|
+
before(ctx);
|
|
68
|
+
await next();
|
|
69
|
+
after?.(ctx);
|
|
70
|
+
}) as Middleware;
|
|
71
|
+
|
|
72
|
+
it('runs the onion model: before → handler → after (reverse)', async () => {
|
|
73
|
+
const order: string[] = [];
|
|
74
|
+
router.get(
|
|
75
|
+
'/',
|
|
76
|
+
layer(() => order.push('1-before'), () => order.push('1-after')),
|
|
77
|
+
layer(() => order.push('2-before'), () => order.push('2-after')),
|
|
78
|
+
layer(() => order.push('3-before'), () => order.push('3-after')),
|
|
79
|
+
(async () => {
|
|
80
|
+
order.push('handler');
|
|
81
|
+
}) as RouteHandler
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
await run(router, createCtx());
|
|
85
|
+
|
|
86
|
+
expect(order).toEqual([
|
|
87
|
+
'1-before',
|
|
88
|
+
'2-before',
|
|
89
|
+
'3-before',
|
|
90
|
+
'handler',
|
|
91
|
+
'3-after',
|
|
92
|
+
'2-after',
|
|
93
|
+
'1-after',
|
|
94
|
+
]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('short-circuits: a layer that never calls next skips the rest', async () => {
|
|
98
|
+
const order: string[] = [];
|
|
99
|
+
const handler = vi.fn();
|
|
100
|
+
const stop: Middleware = modern
|
|
101
|
+
? (async () => {
|
|
102
|
+
order.push('stop');
|
|
103
|
+
}) as Middleware
|
|
104
|
+
: (async () => {
|
|
105
|
+
order.push('stop');
|
|
106
|
+
}) as Middleware;
|
|
107
|
+
|
|
108
|
+
router.get(
|
|
109
|
+
'/',
|
|
110
|
+
layer(() => order.push('a')),
|
|
111
|
+
stop,
|
|
112
|
+
layer(() => order.push('b')),
|
|
113
|
+
handler as RouteHandler
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
await run(router, createCtx());
|
|
117
|
+
|
|
118
|
+
expect(order).toEqual(['a', 'stop']);
|
|
119
|
+
expect(handler).not.toHaveBeenCalled();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('preserves order across async delays', async () => {
|
|
123
|
+
const order: number[] = [];
|
|
124
|
+
const delayed = (n: number, ms: number): Middleware =>
|
|
125
|
+
modern
|
|
126
|
+
? (async (ctx: Context) => {
|
|
127
|
+
await sleep(ms);
|
|
128
|
+
order.push(n);
|
|
129
|
+
await ctx.next();
|
|
130
|
+
}) as Middleware
|
|
131
|
+
: (async (ctx: Context, next: () => Promise<void>) => {
|
|
132
|
+
await sleep(ms);
|
|
133
|
+
order.push(n);
|
|
134
|
+
await next();
|
|
135
|
+
}) as Middleware;
|
|
136
|
+
|
|
137
|
+
router.get('/', delayed(1, 5), delayed(2, 1), delayed(3, 3), (async () => {
|
|
138
|
+
order.push(4);
|
|
139
|
+
}) as RouteHandler);
|
|
140
|
+
|
|
141
|
+
await run(router, createCtx());
|
|
142
|
+
expect(order).toEqual([1, 2, 3, 4]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('propagates an error thrown in a middleware and skips the handler', async () => {
|
|
146
|
+
const handler = vi.fn();
|
|
147
|
+
const boom: Middleware = (async () => {
|
|
148
|
+
throw new Error('mw boom');
|
|
149
|
+
}) as Middleware;
|
|
150
|
+
|
|
151
|
+
router.get('/', layer(() => {}), boom, handler as RouteHandler);
|
|
152
|
+
|
|
153
|
+
await expect(run(router, createCtx())).rejects.toThrow('mw boom');
|
|
154
|
+
expect(handler).not.toHaveBeenCalled();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('propagates an error thrown by the handler', async () => {
|
|
158
|
+
router.get('/', layer(() => {}), (async () => {
|
|
159
|
+
throw new Error('handler boom');
|
|
160
|
+
}) as RouteHandler);
|
|
161
|
+
|
|
162
|
+
await expect(run(router, createCtx())).rejects.toThrow('handler boom');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('propagates a synchronous throw in a middleware', async () => {
|
|
166
|
+
const sync: Middleware = ((_ctx: Context) => {
|
|
167
|
+
throw new Error('sync boom');
|
|
168
|
+
}) as Middleware;
|
|
169
|
+
|
|
170
|
+
router.get('/', sync, (async () => {}) as RouteHandler);
|
|
171
|
+
|
|
172
|
+
await expect(run(router, createCtx())).rejects.toThrow('sync boom');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('shares ctx.state across all layers', async () => {
|
|
176
|
+
router.get(
|
|
177
|
+
'/',
|
|
178
|
+
layer((ctx) => {
|
|
179
|
+
ctx.state.a = 1;
|
|
180
|
+
}),
|
|
181
|
+
layer((ctx) => {
|
|
182
|
+
ctx.state.b = 2;
|
|
183
|
+
}),
|
|
184
|
+
(async (ctx: Context) => {
|
|
185
|
+
ctx.state.c = 3;
|
|
186
|
+
}) as RouteHandler
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
const ctx = createCtx();
|
|
190
|
+
await run(router, ctx);
|
|
191
|
+
expect(ctx.state).toEqual({ a: 1, b: 2, c: 3 });
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('runs a 10-layer chain in order (general dispatch path)', async () => {
|
|
195
|
+
const order: number[] = [];
|
|
196
|
+
const layers = Array.from({ length: 10 }, (_, i) => layer(() => order.push(i + 1)));
|
|
197
|
+
router.get('/', ...layers, (async () => {
|
|
198
|
+
order.push(0);
|
|
199
|
+
}) as RouteHandler);
|
|
200
|
+
|
|
201
|
+
await run(router, createCtx());
|
|
202
|
+
expect(order).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 0]);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it.each([1, 2, 3, 5])('runs correctly with %i middleware layer(s)', async (count) => {
|
|
206
|
+
const order: number[] = [];
|
|
207
|
+
const layers = Array.from({ length: count }, (_, i) => layer(() => order.push(i + 1)));
|
|
208
|
+
router.get('/', ...layers, (async () => {
|
|
209
|
+
order.push(0);
|
|
210
|
+
}) as RouteHandler);
|
|
211
|
+
|
|
212
|
+
await run(router, createCtx());
|
|
213
|
+
expect(order).toEqual([...Array.from({ length: count }, (_, i) => i + 1), 0]);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe('per-route middleware pipeline — cross-cutting', () => {
|
|
218
|
+
let router: Router;
|
|
219
|
+
beforeEach(() => {
|
|
220
|
+
router = createRouter();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('interleaves traditional and modern styles in one chain', async () => {
|
|
224
|
+
const order: string[] = [];
|
|
225
|
+
router.get(
|
|
226
|
+
'/',
|
|
227
|
+
(async (ctx: Context) => {
|
|
228
|
+
order.push('m1');
|
|
229
|
+
await ctx.next();
|
|
230
|
+
order.push('m1-after');
|
|
231
|
+
}) as Middleware,
|
|
232
|
+
(async (_ctx: Context, next: () => Promise<void>) => {
|
|
233
|
+
order.push('t2');
|
|
234
|
+
await next();
|
|
235
|
+
order.push('t2-after');
|
|
236
|
+
}) as Middleware,
|
|
237
|
+
(async (ctx: Context) => {
|
|
238
|
+
order.push('m3');
|
|
239
|
+
await ctx.next();
|
|
240
|
+
}) as Middleware,
|
|
241
|
+
(async () => {
|
|
242
|
+
order.push('handler');
|
|
243
|
+
}) as RouteHandler
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
await run(router, createCtx());
|
|
247
|
+
expect(order).toEqual(['m1', 't2', 'm3', 'handler', 't2-after', 'm1-after']);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('rejects when the next argument is called more than once', async () => {
|
|
251
|
+
const bad: Middleware = (async (_ctx: Context, next: () => Promise<void>) => {
|
|
252
|
+
await next();
|
|
253
|
+
await next();
|
|
254
|
+
}) as Middleware;
|
|
255
|
+
|
|
256
|
+
router.get('/', bad, (async () => {}) as RouteHandler);
|
|
257
|
+
|
|
258
|
+
await expect(run(router, createCtx())).rejects.toThrow('next() called multiple times');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('rejects double-next even after intervening layers', async () => {
|
|
262
|
+
const bad: Middleware = (async (_ctx: Context, next: () => Promise<void>) => {
|
|
263
|
+
await next();
|
|
264
|
+
await next();
|
|
265
|
+
}) as Middleware;
|
|
266
|
+
|
|
267
|
+
router.get(
|
|
268
|
+
'/',
|
|
269
|
+
bad,
|
|
270
|
+
(async (_ctx: Context, next: () => Promise<void>) => {
|
|
271
|
+
await next();
|
|
272
|
+
}) as Middleware,
|
|
273
|
+
(async () => {}) as RouteHandler
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
await expect(run(router, createCtx())).rejects.toThrow('next() called multiple times');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('treats ctx.next() inside the final handler as a safe no-op', async () => {
|
|
280
|
+
let reached = false;
|
|
281
|
+
router.get(
|
|
282
|
+
'/',
|
|
283
|
+
(async (ctx: Context) => {
|
|
284
|
+
await ctx.next();
|
|
285
|
+
}) as Middleware,
|
|
286
|
+
(async (ctx: Context) => {
|
|
287
|
+
reached = true;
|
|
288
|
+
await ctx.next(); // handler calling next() must not throw or re-run anything
|
|
289
|
+
}) as RouteHandler
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
await expect(run(router, createCtx())).resolves.toBeUndefined();
|
|
293
|
+
expect(reached).toBe(true);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('handler-only route: 2nd-arg next is a safe no-op', async () => {
|
|
297
|
+
let called = false;
|
|
298
|
+
router.get('/', (async (_ctx: Context, next: () => Promise<void>) => {
|
|
299
|
+
called = true;
|
|
300
|
+
await next(); // no middleware — next is NOOP
|
|
301
|
+
}) as RouteHandler);
|
|
302
|
+
|
|
303
|
+
await expect(run(router, createCtx())).resolves.toBeUndefined();
|
|
304
|
+
expect(called).toBe(true);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nextrush/router - Param decoding & the `decode` option
|
|
3
|
+
*
|
|
4
|
+
* By default the router percent-decodes param and wildcard values (like Express,
|
|
5
|
+
* Koa, Hono, find-my-way). `decode: false` opts out and preserves raw values.
|
|
6
|
+
* Malformed encoding never throws — the raw value is returned.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { RouteHandler } from '@nextrush/types';
|
|
10
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
11
|
+
import { createRouter, Router } from '../router';
|
|
12
|
+
|
|
13
|
+
const h = (): RouteHandler => vi.fn();
|
|
14
|
+
|
|
15
|
+
describe('param decoding — default (decode: true)', () => {
|
|
16
|
+
let router: Router;
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
router = createRouter();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('decodes a percent-encoded space', () => {
|
|
22
|
+
router.get('/u/:name', h());
|
|
23
|
+
expect(router.match('GET', '/u/hello%20world')?.params.name).toBe('hello world');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('decodes percent-encoded unicode', () => {
|
|
27
|
+
router.get('/u/:name', h());
|
|
28
|
+
expect(router.match('GET', '/u/jos%C3%A9')?.params.name).toBe('josé');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('leaves an unencoded value unchanged (fast path)', () => {
|
|
32
|
+
router.get('/u/:name', h());
|
|
33
|
+
expect(router.match('GET', '/u/plain-value.txt')?.params.name).toBe('plain-value.txt');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('decodes reserved characters like %2F inside a single param', () => {
|
|
37
|
+
router.get('/u/:name', h());
|
|
38
|
+
expect(router.match('GET', '/u/a%2Fb')?.params.name).toBe('a/b');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('decodes the wildcard remainder', () => {
|
|
42
|
+
router.get('/s/*', h());
|
|
43
|
+
expect(router.match('GET', '/s/a%20b/c')?.params['*']).toBe('a b/c');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('returns the raw value on malformed encoding without throwing', () => {
|
|
47
|
+
router.get('/u/:name', h());
|
|
48
|
+
expect(() => router.match('GET', '/u/%E0%A4%A')).not.toThrow();
|
|
49
|
+
expect(router.match('GET', '/u/%E0%A4%A')?.params.name).toBe('%E0%A4%A');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('decodes params in case-insensitive mode while preserving decoded case', () => {
|
|
53
|
+
router.get('/u/:name', h());
|
|
54
|
+
expect(router.match('GET', '/u/Hello%20World')?.params.name).toBe('Hello World');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('decodes each param independently in a multi-param route', () => {
|
|
58
|
+
router.get('/:a/:b', h());
|
|
59
|
+
expect(router.match('GET', '/a%20b/c%2Fd')?.params).toEqual({ a: 'a b', b: 'c/d' });
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('param decoding — opt-out (decode: false)', () => {
|
|
64
|
+
let router: Router;
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
router = createRouter({ decode: false });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('preserves the raw percent-encoded param value', () => {
|
|
70
|
+
router.get('/u/:name', h());
|
|
71
|
+
expect(router.match('GET', '/u/hello%20world')?.params.name).toBe('hello%20world');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('preserves the raw wildcard remainder', () => {
|
|
75
|
+
router.get('/s/*', h());
|
|
76
|
+
expect(router.match('GET', '/s/a%20b')?.params['*']).toBe('a%20b');
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nextrush/router - Public API surface test
|
|
3
|
+
*
|
|
4
|
+
* Locks the exported symbol set from `src/index.ts`. If this test fails, the
|
|
5
|
+
* public API has changed. Intentional changes require an explicit update to
|
|
6
|
+
* the expected list below, plus a changeset for a published package.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, expectTypeOf, it } from 'vitest';
|
|
9
|
+
import * as routerApi from '../index';
|
|
10
|
+
import { NodeType } from '../index';
|
|
11
|
+
import type {
|
|
12
|
+
HandlerEntry,
|
|
13
|
+
HttpMethod,
|
|
14
|
+
Middleware,
|
|
15
|
+
ParsedSegment,
|
|
16
|
+
Route,
|
|
17
|
+
RouteGroup,
|
|
18
|
+
RouteHandler,
|
|
19
|
+
RouteMatch,
|
|
20
|
+
RouterInterface,
|
|
21
|
+
RouterOptions,
|
|
22
|
+
TrieNode,
|
|
23
|
+
} from '../index';
|
|
24
|
+
|
|
25
|
+
describe('Public API surface (runtime exports)', () => {
|
|
26
|
+
it('exports exactly the intended runtime symbols', () => {
|
|
27
|
+
const actualExports = Object.keys(routerApi).sort();
|
|
28
|
+
|
|
29
|
+
// SEALED: intentional public runtime API surface.
|
|
30
|
+
// `createNode`/`NodeType`/`parseSegments` are internal segment-trie
|
|
31
|
+
// helpers exposed for advanced usage (see the barrel's own comment) —
|
|
32
|
+
// locked as-is here; renaming any of them is a separate breaking change.
|
|
33
|
+
const expectedRuntime = ['createRouter', 'endpoint', 'Router', 'createNode', 'NodeType', 'parseSegments'].sort();
|
|
34
|
+
|
|
35
|
+
expect(actualExports).toEqual(expectedRuntime);
|
|
36
|
+
expect(typeof NodeType).toBe('object');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('Public API surface (type-only exports)', () => {
|
|
41
|
+
it('the type-only surface stays importable from the barrel', () => {
|
|
42
|
+
// Compile-time only: removing/renaming any of these in src/index.ts fails
|
|
43
|
+
// this file to type-check.
|
|
44
|
+
type Surface = [
|
|
45
|
+
RouteGroup,
|
|
46
|
+
HandlerEntry,
|
|
47
|
+
ParsedSegment,
|
|
48
|
+
TrieNode,
|
|
49
|
+
HttpMethod,
|
|
50
|
+
Middleware,
|
|
51
|
+
Route,
|
|
52
|
+
RouteHandler,
|
|
53
|
+
RouteMatch,
|
|
54
|
+
RouterInterface,
|
|
55
|
+
RouterOptions,
|
|
56
|
+
];
|
|
57
|
+
expectTypeOf<Surface>().not.toBeNever();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nextrush/router - Route Metadata System Tests
|
|
3
|
+
*
|
|
4
|
+
* Covers endpoint() markers, the ROUTE_METADATA contribution protocol,
|
|
5
|
+
* getRoutes() introspection, contribution merge semantics, and the guarantee
|
|
6
|
+
* that pure-metadata markers never enter the executed handler chain.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Context, Middleware } from '@nextrush/types';
|
|
10
|
+
import { ROUTE_METADATA, type MetadataContribution } from '@nextrush/types';
|
|
11
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
12
|
+
import { createRouter, endpoint, Router } from '../router';
|
|
13
|
+
|
|
14
|
+
function createMockContext(overrides: Partial<Context> = {}): Context {
|
|
15
|
+
return {
|
|
16
|
+
method: 'GET',
|
|
17
|
+
path: '/',
|
|
18
|
+
params: {},
|
|
19
|
+
query: {},
|
|
20
|
+
body: undefined,
|
|
21
|
+
headers: {},
|
|
22
|
+
status: 200,
|
|
23
|
+
state: {},
|
|
24
|
+
json: vi.fn(),
|
|
25
|
+
send: vi.fn(),
|
|
26
|
+
html: vi.fn(),
|
|
27
|
+
redirect: vi.fn(),
|
|
28
|
+
set: vi.fn(),
|
|
29
|
+
get: vi.fn(),
|
|
30
|
+
next: vi.fn(),
|
|
31
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
32
|
+
raw: { req: {} as any, res: {} as any },
|
|
33
|
+
...overrides,
|
|
34
|
+
} as Context;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Simulate a metadata-contributing middleware (like validate()): a real function carrying the symbol. */
|
|
38
|
+
function contributingMiddleware(contribution: MetadataContribution): Middleware {
|
|
39
|
+
const mw: Middleware = async (ctx, next) => {
|
|
40
|
+
await next();
|
|
41
|
+
};
|
|
42
|
+
Object.defineProperty(mw, ROUTE_METADATA, { value: contribution, enumerable: false });
|
|
43
|
+
return mw;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('endpoint()', () => {
|
|
47
|
+
it('returns a marker carrying its metadata under ROUTE_METADATA', () => {
|
|
48
|
+
const marker = endpoint({ summary: 'Create user' });
|
|
49
|
+
expect(marker[ROUTE_METADATA]).toEqual({ summary: 'Create user' });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('is not a function (never executed as middleware)', () => {
|
|
53
|
+
expect(typeof endpoint({ summary: 'x' })).not.toBe('function');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('getRoutes()', () => {
|
|
58
|
+
let router: Router;
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
router = createRouter();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('lists every registered route (static and param) with method, path, key', () => {
|
|
64
|
+
router.get('/health', vi.fn());
|
|
65
|
+
router.get('/users/:id', vi.fn());
|
|
66
|
+
router.post('/users', vi.fn());
|
|
67
|
+
|
|
68
|
+
const routes = router.getRoutes();
|
|
69
|
+
const keys = routes.map((r) => r.key).sort();
|
|
70
|
+
expect(keys).toEqual(['GET /health', 'GET /users/:id', 'POST /users']);
|
|
71
|
+
|
|
72
|
+
const byId = routes.find((r) => r.key === 'GET /users/:id');
|
|
73
|
+
expect(byId?.method).toBe('GET');
|
|
74
|
+
expect(byId?.path).toBe('/users/:id');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('yields a definition with no metadata for a bare route', () => {
|
|
78
|
+
router.get('/hello', vi.fn());
|
|
79
|
+
const [route] = router.getRoutes();
|
|
80
|
+
expect(route?.key).toBe('GET /hello');
|
|
81
|
+
expect(route?.metadata).toBeUndefined();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('captures endpoint() metadata on the route', () => {
|
|
85
|
+
router.post('/users', endpoint({ summary: 'Create a user', tags: ['users'] }), vi.fn());
|
|
86
|
+
const [route] = router.getRoutes();
|
|
87
|
+
expect(route?.metadata?.summary).toBe('Create a user');
|
|
88
|
+
expect(route?.metadata?.tags).toEqual(['users']);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('captures a contributing middleware (validate-style) request schema', () => {
|
|
92
|
+
const schema = { '~standard': { version: 1, vendor: 'test', validate: () => ({ value: {} }) } };
|
|
93
|
+
router.post('/users', contributingMiddleware({ request: { body: schema } }), vi.fn());
|
|
94
|
+
const [route] = router.getRoutes();
|
|
95
|
+
expect(route?.metadata?.request?.body).toBe(schema);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('returns a readonly snapshot', () => {
|
|
99
|
+
router.get('/x', vi.fn());
|
|
100
|
+
const routes = router.getRoutes();
|
|
101
|
+
expect(Array.isArray(routes)).toBe(true);
|
|
102
|
+
expect(routes).toHaveLength(1);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('contribution merge semantics', () => {
|
|
107
|
+
let router: Router;
|
|
108
|
+
beforeEach(() => {
|
|
109
|
+
router = createRouter();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('merges across contributors: request from one, responses/summary from another', () => {
|
|
113
|
+
const bodySchema = { '~standard': { version: 1, vendor: 'test', validate: () => ({ value: {} }) } };
|
|
114
|
+
const resSchema = { '~standard': { version: 1, vendor: 'test', validate: () => ({ value: {} }) } };
|
|
115
|
+
router.post('/users',
|
|
116
|
+
contributingMiddleware({ request: { body: bodySchema } }),
|
|
117
|
+
endpoint({ summary: 'Create', responses: { 201: resSchema } }),
|
|
118
|
+
vi.fn()
|
|
119
|
+
);
|
|
120
|
+
const [route] = router.getRoutes();
|
|
121
|
+
expect(route?.metadata?.request?.body).toBe(bodySchema);
|
|
122
|
+
expect(route?.metadata?.summary).toBe('Create');
|
|
123
|
+
expect(route?.metadata?.responses?.[201]).toBe(resSchema);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('last-write-wins for scalars/arrays; per-key merge for responses map', () => {
|
|
127
|
+
const a = { '~standard': { version: 1, vendor: 't', validate: () => ({ value: {} }) } };
|
|
128
|
+
const b = { '~standard': { version: 1, vendor: 't', validate: () => ({ value: {} }) } };
|
|
129
|
+
router.get('/x',
|
|
130
|
+
endpoint({ summary: 'first', tags: ['a'], responses: { 200: a } }),
|
|
131
|
+
endpoint({ summary: 'second', tags: ['b'], responses: { 404: b } }),
|
|
132
|
+
vi.fn()
|
|
133
|
+
);
|
|
134
|
+
const [route] = router.getRoutes();
|
|
135
|
+
expect(route?.metadata?.summary).toBe('second'); // last wins
|
|
136
|
+
expect(route?.metadata?.tags).toEqual(['b']); // last wins
|
|
137
|
+
expect(route?.metadata?.responses).toEqual({ 200: a, 404: b }); // per-key merge
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('markers never enter the executed chain', () => {
|
|
142
|
+
it('dispatches correctly with an endpoint() marker present', async () => {
|
|
143
|
+
const router = createRouter();
|
|
144
|
+
const handler = vi.fn();
|
|
145
|
+
router.get('/x', endpoint({ summary: 'x' }), handler);
|
|
146
|
+
|
|
147
|
+
const ctx = createMockContext({ method: 'GET', path: '/x' });
|
|
148
|
+
await router.routes()(ctx, async () => {});
|
|
149
|
+
|
|
150
|
+
// Handler ran; the marker was filtered out (a non-function marker in the
|
|
151
|
+
// chain would throw when invoked).
|
|
152
|
+
expect(handler).toHaveBeenCalledOnce();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('still runs a contributing middleware function in the chain', async () => {
|
|
156
|
+
const router = createRouter();
|
|
157
|
+
const order: string[] = [];
|
|
158
|
+
const mw = contributingMiddleware({ summary: 'x' });
|
|
159
|
+
const wrapped: Middleware = async (ctx, next) => {
|
|
160
|
+
order.push('mw');
|
|
161
|
+
await mw(ctx, next);
|
|
162
|
+
};
|
|
163
|
+
const handler = vi.fn(() => void order.push('handler'));
|
|
164
|
+
router.get('/x', wrapped, handler);
|
|
165
|
+
|
|
166
|
+
const ctx = createMockContext({ method: 'GET', path: '/x' });
|
|
167
|
+
await router.routes()(ctx, async () => {});
|
|
168
|
+
|
|
169
|
+
expect(order).toEqual(['mw', 'handler']);
|
|
170
|
+
expect(handler).toHaveBeenCalledOnce();
|
|
171
|
+
});
|
|
172
|
+
});
|