@nextrush/router 3.0.7 → 4.0.0-beta.1

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.
Files changed (51) hide show
  1. package/README.md +250 -492
  2. package/dist/index.d.ts +227 -178
  3. package/dist/index.js +1075 -657
  4. package/dist/index.js.map +1 -1
  5. package/package.json +7 -6
  6. package/src/__tests__/allowed-methods.test.ts +144 -0
  7. package/src/__tests__/audit-fixes.test.ts +59 -0
  8. package/src/__tests__/canonical-path-security.test.ts +198 -0
  9. package/src/__tests__/canonicalize-memo.test.ts +108 -0
  10. package/src/__tests__/dispatch-deasync.test.ts +292 -0
  11. package/src/__tests__/find-node-differential.test.ts +220 -0
  12. package/src/__tests__/fixtures/match-golden.json +556 -0
  13. package/src/__tests__/head-auto-registration.test.ts +197 -0
  14. package/src/__tests__/helpers/differential-corpus.ts +314 -0
  15. package/src/__tests__/match-differential.test.ts +46 -0
  16. package/src/__tests__/match-hotpath-guard.test.ts +71 -0
  17. package/src/__tests__/match-node-indexed-unpooled.test.ts +83 -0
  18. package/src/__tests__/match-normalize-fastpath.test.ts +64 -0
  19. package/src/__tests__/match-prenormalized.test.ts +95 -0
  20. package/src/__tests__/match-safety.test.ts +223 -0
  21. package/src/__tests__/match-single-alloc.test.ts +82 -0
  22. package/src/__tests__/match-walk-pool-safety.test.ts +103 -0
  23. package/src/__tests__/middleware-pipeline.test.ts +306 -0
  24. package/src/__tests__/param-decoding.test.ts +78 -0
  25. package/src/__tests__/public-surface.test.ts +72 -0
  26. package/src/__tests__/registration-max-depth.test.ts +56 -0
  27. package/src/__tests__/route-metadata.test.ts +172 -0
  28. package/src/__tests__/router-audit.test.ts +315 -0
  29. package/src/__tests__/router-edge-cases.test.ts +2 -7
  30. package/src/__tests__/router.test.ts +148 -7
  31. package/src/__tests__/static-map-reset.test.ts +48 -0
  32. package/src/__tests__/walk-pool-sizing.test.ts +72 -0
  33. package/src/__tests__/walk-pool-undersized-guard.test.ts +59 -0
  34. package/src/canonicalize.ts +137 -0
  35. package/src/composition.ts +79 -0
  36. package/src/constants.ts +43 -0
  37. package/src/dispatch.ts +145 -0
  38. package/src/find-node.ts +125 -0
  39. package/src/group-router.ts +208 -0
  40. package/src/index.ts +14 -5
  41. package/src/match-route.ts +241 -0
  42. package/src/matching.ts +246 -0
  43. package/src/middleware-adapter.ts +59 -0
  44. package/src/redirect.ts +97 -0
  45. package/src/registration.ts +343 -0
  46. package/src/route-metadata.ts +68 -0
  47. package/src/router.ts +219 -872
  48. package/src/segment-trie.ts +227 -0
  49. package/src/state.ts +53 -0
  50. package/src/walk-pool.ts +208 -0
  51. package/src/radix-tree.ts +0 -184
@@ -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,72 @@
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
+ // `canonicalizePath`/`hasDotSegment` are RFC-029's canonical-path surface:
34
+ // the single normalization owner every path-based consumer (mount-prefix
35
+ // matching, CSRF exclude paths, a hand-written policy guard) should call
36
+ // instead of re-deriving its own fold/collapse/strip.
37
+ const expectedRuntime = [
38
+ 'createRouter',
39
+ 'endpoint',
40
+ 'Router',
41
+ 'createNode',
42
+ 'NodeType',
43
+ 'parseSegments',
44
+ 'canonicalizePath',
45
+ 'hasDotSegment',
46
+ ].sort();
47
+
48
+ expect(actualExports).toEqual(expectedRuntime);
49
+ expect(typeof NodeType).toBe('object');
50
+ });
51
+ });
52
+
53
+ describe('Public API surface (type-only exports)', () => {
54
+ it('the type-only surface stays importable from the barrel', () => {
55
+ // Compile-time only: removing/renaming any of these in src/index.ts fails
56
+ // this file to type-check.
57
+ type Surface = [
58
+ RouteGroup,
59
+ HandlerEntry,
60
+ ParsedSegment,
61
+ TrieNode,
62
+ HttpMethod,
63
+ Middleware,
64
+ Route,
65
+ RouteHandler,
66
+ RouteMatch,
67
+ RouterInterface,
68
+ RouterOptions,
69
+ ];
70
+ expectTypeOf<Surface>().not.toBeNever();
71
+ });
72
+ });
@@ -0,0 +1,56 @@
1
+ /**
2
+ * @nextrush/router - registration-time max-depth tracking
3
+ *
4
+ * `addRoute` inserts each route's `:param`/static segments as a chain of trie
5
+ * nodes. The reused walk-frame pool (`reduce-router-match-allocations`) needs
6
+ * this chain's worst-case length known at registration time, not guessed —
7
+ * an attacker's request path can never make the walk descend deeper than the
8
+ * trie's own real depth (a mismatched segment backtracks, it never pushes),
9
+ * so sizing the pool from registered-route depth preserves the walk's
10
+ * existing recursion-depth DoS guard rather than reintroducing it.
11
+ */
12
+
13
+ import { describe, expect, it } from 'vitest';
14
+ import { addRoute, type RegistrationState } from '../registration';
15
+ import { createNode } from '../segment-trie';
16
+
17
+ function createState(): RegistrationState & { maxDepth: number } {
18
+ return {
19
+ root: createNode(''),
20
+ caseSensitive: false,
21
+ staticRoutes: new Map(),
22
+ routeDefinitions: [],
23
+ maxDepth: 0,
24
+ };
25
+ }
26
+
27
+ const noop = async (): Promise<void> => {};
28
+
29
+ describe('registration tracks the deepest registered route', () => {
30
+ it('a single-segment route has depth 1', () => {
31
+ const state = createState();
32
+ addRoute('GET', '/a', [noop], [], state);
33
+ expect(state.maxDepth).toBe(1);
34
+ });
35
+
36
+ it('tracks the deepest of several routes, not the last registered', () => {
37
+ const state = createState();
38
+ addRoute('GET', '/a', [noop], [], state);
39
+ addRoute('GET', '/a/b/c/:id', [noop], [], state);
40
+ addRoute('GET', '/x', [noop], [], state);
41
+ expect(state.maxDepth).toBe(4);
42
+ });
43
+
44
+ it('a wildcard segment counts toward depth but never pushes beyond it', () => {
45
+ const state = createState();
46
+ addRoute('GET', '/files/*', [noop], [], state);
47
+ expect(state.maxDepth).toBe(2);
48
+ });
49
+
50
+ it('registering a shallower route after a deeper one does not shrink maxDepth', () => {
51
+ const state = createState();
52
+ addRoute('GET', '/a/b/c', [noop], [], state);
53
+ addRoute('GET', '/x', [noop], [], state);
54
+ expect(state.maxDepth).toBe(3);
55
+ });
56
+ });
@@ -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
+ });