expediate 1.0.5 → 1.0.6

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 (73) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/CONTRIBUTING.md +150 -0
  3. package/README.md +278 -779
  4. package/dist/apis.d.ts +372 -12
  5. package/dist/apis.d.ts.map +1 -1
  6. package/dist/apis.js +483 -65
  7. package/dist/apis.js.map +1 -1
  8. package/dist/cjs/index.js +2290 -807
  9. package/dist/git.d.ts +1 -1
  10. package/dist/git.d.ts.map +1 -1
  11. package/dist/git.js +5 -5
  12. package/dist/git.js.map +1 -1
  13. package/dist/http-objects.d.ts +26 -0
  14. package/dist/http-objects.d.ts.map +1 -0
  15. package/dist/http-objects.js +588 -0
  16. package/dist/http-objects.js.map +1 -0
  17. package/dist/index.d.ts +6 -5
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +2 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/jwt-auth.d.ts +11 -0
  22. package/dist/jwt-auth.d.ts.map +1 -1
  23. package/dist/jwt-auth.js +9 -9
  24. package/dist/jwt-auth.js.map +1 -1
  25. package/dist/middleware.js +2 -2
  26. package/dist/middleware.js.map +1 -1
  27. package/dist/mimetypes.json +882 -1
  28. package/dist/misc.d.ts +161 -25
  29. package/dist/misc.d.ts.map +1 -1
  30. package/dist/misc.js +228 -80
  31. package/dist/misc.js.map +1 -1
  32. package/dist/openapi.d.ts +156 -13
  33. package/dist/openapi.d.ts.map +1 -1
  34. package/dist/openapi.js +214 -71
  35. package/dist/openapi.js.map +1 -1
  36. package/dist/router-types.d.ts +760 -0
  37. package/dist/router-types.d.ts.map +1 -0
  38. package/dist/router-types.js +23 -0
  39. package/dist/router-types.js.map +1 -0
  40. package/dist/router.d.ts +7 -530
  41. package/dist/router.d.ts.map +1 -1
  42. package/dist/router.js +128 -375
  43. package/dist/router.js.map +1 -1
  44. package/dist/static.d.ts +2 -2
  45. package/dist/static.d.ts.map +1 -1
  46. package/dist/static.js +77 -22
  47. package/dist/static.js.map +1 -1
  48. package/docs/THREAT_MODEL.md +52 -0
  49. package/docs/api-builder-v2-design.md +644 -0
  50. package/docs/api-builder-v3-design.md +397 -0
  51. package/docs/api-builder.md +454 -0
  52. package/docs/benchmark.md +27 -0
  53. package/docs/body-parsing.md +223 -0
  54. package/docs/errors.md +359 -0
  55. package/docs/expediate.png +0 -0
  56. package/docs/git.md +139 -0
  57. package/docs/jwt-auth.md +251 -0
  58. package/docs/logo.svg +12 -0
  59. package/docs/middleware.md +264 -0
  60. package/docs/openapi.md +180 -0
  61. package/docs/router.md +356 -0
  62. package/docs/static.md +128 -0
  63. package/docs/wiki.json +123 -0
  64. package/package.json +30 -8
  65. package/dist/cjs/apis.js +0 -327
  66. package/dist/cjs/git.js +0 -293
  67. package/dist/cjs/jwt-auth.js +0 -532
  68. package/dist/cjs/middleware.js +0 -511
  69. package/dist/cjs/mimetypes.json +0 -1
  70. package/dist/cjs/misc.js +0 -787
  71. package/dist/cjs/openapi.js +0 -485
  72. package/dist/cjs/router.js +0 -898
  73. package/dist/cjs/static.js +0 -669
@@ -0,0 +1,454 @@
1
+ # API Builder and OpenAPI
2
+
3
+ `apiBuilder` lets you define REST endpoints as a controller-style service object. It handles instance scoping, lifecycle, method binding, route specificity sorting, and error translation automatically. Since v2 it also supports **multi-file composition** (controllers), **guards**, a declarative **auth binding**, and **runtime request validation** from the JSON Schemas you already declare for the spec. The companion `describe()` helper annotates handlers with OpenAPI metadata so a full spec can be generated without duplication.
4
+
5
+ ---
6
+
7
+ ## `apiBuilder(service)`
8
+
9
+ ```ts
10
+ import { createRouter, json, apiBuilder } from 'expediate';
11
+ import type { ServiceDefinition, ApiContext } from 'expediate';
12
+
13
+ interface State {
14
+ items: Record<string, { title: string; done: boolean }>;
15
+ nextId: number;
16
+ }
17
+
18
+ const todoService: ServiceDefinition<State> = {
19
+ data: () => ({ items: {}, nextId: 1 }),
20
+
21
+ methods: {
22
+ findOrThrow(this: State, id: string) {
23
+ const item = this.items[id];
24
+ if (!item) throw { status: 404, message: 'Not found' };
25
+ return item;
26
+ },
27
+ },
28
+
29
+ GET: {
30
+ '/todos': function (this: State) {
31
+ return Object.entries(this.items).map(([id, v]) => ({ id, ...v }));
32
+ },
33
+ '/todos/:id': function (this: State, ctx: ApiContext) {
34
+ return this.findOrThrow(ctx.params.id);
35
+ },
36
+ },
37
+
38
+ POST: {
39
+ '/todos': function (this: State, _ctx: ApiContext, body: any) {
40
+ const id = String(this.nextId++);
41
+ this.items[id] = { title: body.title, done: false };
42
+ return { id, ...this.items[id] };
43
+ },
44
+ },
45
+
46
+ DELETE: {
47
+ '/todos/:id': function (this: State, ctx: ApiContext) {
48
+ this.findOrThrow(ctx.params.id);
49
+ delete this.items[ctx.params.id];
50
+ }, // no return → 201 No Content
51
+ },
52
+ };
53
+
54
+ const app = createRouter();
55
+ app.use('/', json());
56
+ app.use('/api', apiBuilder(todoService));
57
+ app.listen(3000);
58
+ ```
59
+
60
+ ### Handler signature and `ApiContext`
61
+
62
+ Handlers are called with `this` bound to the service instance and receive `(ctx, body)`:
63
+
64
+ ```ts
65
+ interface ApiContext<TUser = unknown, TState = Record<string, unknown>> {
66
+ query: {
67
+ route: Record<string, string>; // named :params from the path
68
+ url: Record<string, string | string[]>; // ?query=string parameters
69
+ };
70
+ params: Record<string, string>; // shorthand alias for query.route
71
+ path: string; // request path (after parent use() stripping)
72
+ user?: TUser; // set by an authenticate middleware
73
+ state: TState; // values produced by guards
74
+ }
75
+ ```
76
+
77
+ `ctx.params` is the dominant access pattern; the namespaced `ctx.query.route` / `ctx.query.url` forms remain for collision cases. Type `user` and `state` explicitly when you need strict access:
78
+
79
+ ```ts
80
+ import type { TokenPayload } from 'expediate';
81
+
82
+ '/me': (ctx: ApiContext<TokenPayload>) => ({ name: ctx.user?.sub }),
83
+ ```
84
+
85
+ ### Handler conventions
86
+
87
+ | Return value | HTTP response |
88
+ |---|---|
89
+ | Truthy value (`object`, `string`, `number`, `true`) | `200 OK` with JSON body |
90
+ | `undefined`, `null`, `false`, `0`, `''` | `201 No Content` |
91
+ | Throw `{ status, message }` | `<status>` with plain-text body |
92
+ | Throw `{ status, data }` | `<status>` with JSON body |
93
+ | Throw anything else | `500 Internal Server Error` |
94
+
95
+ ### Route specificity sorting
96
+
97
+ All routes — root-level and controller-level together — are sorted **globally** before registration so that more-specific paths are registered first and cannot be shadowed:
98
+
99
+ ```
100
+ score = (segment_count × 100) − (param_count × 10)
101
+ ```
102
+
103
+ `/items/special` (score 200) is registered before `/items/:id` (score 190), so you do not need to care about declaration order, even across controllers.
104
+
105
+ ### ServiceDefinition structure
106
+
107
+ ```ts
108
+ interface ServiceDefinition<TInstance> {
109
+ // Instance lifecycle (API-wide, shared by all controllers)
110
+ scope?: (req: RouterRequest) => string | null;
111
+ data?: (key: string | null) => Partial<TInstance>;
112
+ setup?: (this: TInstance) => void | Promise<void>;
113
+ methods?: ServiceMethods<TInstance>;
114
+
115
+ // v2 — composition, guards, auth, validation
116
+ controllers?: ControllerDefinition<TInstance>[];
117
+ guards?: Guard[];
118
+ auth?: AuthBinding;
119
+ validate?: boolean | ApiBuilderOptions;
120
+ schemas?: Record<string, JsonSchema>;
121
+
122
+ // Root route maps (form an implicit controller with no prefix)
123
+ GET?: RouteMap<TInstance>;
124
+ POST?: RouteMap<TInstance>;
125
+ PUT?: RouteMap<TInstance>;
126
+ DELETE?: RouteMap<TInstance>;
127
+ PATCH?: RouteMap<TInstance>;
128
+ }
129
+ ```
130
+
131
+ ---
132
+
133
+ ## Scoping
134
+
135
+ Control how many instances of the service state are created:
136
+
137
+ | `scope` field value | Behaviour |
138
+ |---|---|
139
+ | Absent (no `scope`) | **Singleton** — one global instance for all requests |
140
+ | Returns a `string` | **Keyed** — one instance per key, cached indefinitely |
141
+ | Returns `null` | **Ephemeral** — fresh instance per request, discarded afterwards |
142
+
143
+ The key is stored at `this.$key` on the instance.
144
+
145
+ ```ts
146
+ const service: ServiceDefinition<State> = {
147
+ // Per-session scope
148
+ scope: (req) => (req as any).session?.id ?? null,
149
+
150
+ // Per-request (ephemeral)
151
+ scope: () => null,
152
+
153
+ data: () => ({ /* initial state */ }),
154
+ };
155
+ ```
156
+
157
+ The instance lifecycle stays at the top level: **one lifecycle for the whole API, shared by all controllers**. Controllers organise routes; they do not isolate state.
158
+
159
+ ---
160
+
161
+ ## Async setup
162
+
163
+ `setup()` is called after `data()` and methods are bound. If it returns a `Promise`, it is awaited before the module is put into service. For singletons, requests arriving while setup is in progress receive **503 Service not ready**; for keyed/ephemeral instances, the handler awaits instance creation per request.
164
+
165
+ ```ts
166
+ const service: ServiceDefinition<State> = {
167
+ data: () => ({ db: null as any }),
168
+
169
+ setup: async function (this: State) {
170
+ this.db = await connectToDatabase();
171
+ },
172
+
173
+ GET: {
174
+ '/items': function (this: State) {
175
+ return this.db.query('SELECT * FROM items');
176
+ },
177
+ },
178
+ };
179
+ ```
180
+
181
+ ---
182
+
183
+ ## Controllers and composition
184
+
185
+ Split a large API into per-domain files. Each file exports a `ControllerDefinition`; the composition root merges them into **one router** and **one OpenAPI document**.
186
+
187
+ ```ts
188
+ interface ControllerDefinition<TInstance> {
189
+ prefix?: string; // prepended to every route (may contain :params)
190
+ tags?: string[]; // default OpenAPI tags for untagged routes
191
+ guards?: Guard[]; // run before every handler of this controller
192
+ permission?: string | string[]; // default permission (route meta overrides)
193
+
194
+ GET?: RouteMap<TInstance>; POST?: ...; PUT?: ...; DELETE?: ...; PATCH?: ...;
195
+ }
196
+ ```
197
+
198
+ `defineController()` is an identity helper providing type inference when declaring a controller in its own file:
199
+
200
+ ```ts
201
+ // wiki.controller.ts
202
+ import { defineController, describe } from 'expediate';
203
+
204
+ export const wikiController = defineController({
205
+ prefix: '/p/:proj/wiki',
206
+ tags: ['Wiki'],
207
+ permission: 'wiki.read',
208
+
209
+ GET: {
210
+ '/tree': describe(
211
+ (ctx) => wikiService.listPages(ctx.params.proj),
212
+ { summary: 'List all wiki pages' }),
213
+
214
+ '/pages/:slug': describe(
215
+ (ctx) => wikiService.readPage(ctx.params.proj, ctx.params.slug),
216
+ { summary: 'Read a wiki page' }),
217
+ },
218
+
219
+ PUT: {
220
+ '/pages/:slug': describe(
221
+ (ctx, body) => wikiService.writePage(ctx.params.proj, ctx.params.slug, body),
222
+ { summary: 'Create or update a page', permission: 'wiki.write' }),
223
+ },
224
+ });
225
+ ```
226
+
227
+ ```ts
228
+ // index.ts — composition root
229
+ const api = apiBuilder({
230
+ controllers: [wikiController, issuesController, adminController],
231
+ });
232
+ ```
233
+
234
+ ### Merge rules
235
+
236
+ - Each controller route is rewritten to `joinPath(prefix, path)` (duplicate slashes normalised; a `'/'` route + prefix `/p/:proj/wiki` → `/p/:proj/wiki`).
237
+ - Root-level route maps form an implicit controller with no prefix, so v1 single-definition services keep working unchanged.
238
+ - Routes of all controllers are concatenated and specificity-sorted **globally**.
239
+ - A duplicate `(verb, joined path)` pair **throws at build time**, naming both declaring controllers:
240
+
241
+ ```
242
+ Error: apiBuilder: duplicate route GET /p/:proj/settings
243
+ declared by controllers 'Settings' and 'Projects'
244
+ ```
245
+
246
+ - Handlers in every controller run with `this` bound to the same service instance.
247
+
248
+ ---
249
+
250
+ ## Guards and `ctx.state`
251
+
252
+ A *guard* is a pre-handler hook living in the `ctx` world:
253
+
254
+ ```ts
255
+ type Guard = (ctx: ApiContext, req: RouterRequest)
256
+ => void | Record<string, unknown> | Promise<void | Record<string, unknown>>;
257
+ ```
258
+
259
+ A guard may **throw / reject an `ApiError`** (translated to an HTTP error response), **return an object** (shallow-merged into `ctx.state`), or **return void** (pure check).
260
+
261
+ Guards attach at three levels and run outermost-first:
262
+
263
+ ```
264
+ auth.authenticate → auth.check → api guards → controller guards → route guards → handler
265
+ ```
266
+
267
+ Per-route guards ride on the `describe()` metadata:
268
+
269
+ ```ts
270
+ import type { Guard } from 'expediate';
271
+
272
+ /** Require a project feature flag; expects ctx.state.proj loaded earlier. */
273
+ const featureEnabled = (flag: string): Guard => (ctx) => {
274
+ const proj = ctx.state.proj as any;
275
+ if (!proj?.[flag]) throw { status: 404, message: 'Feature is not enabled' };
276
+ };
277
+
278
+ /** Load a resource once and share it with later guards and the handler. */
279
+ const loadProject: Guard = async (ctx) => ({
280
+ proj: await projectService.openProject(ctx.params.proj),
281
+ });
282
+
283
+ const api = apiBuilder({
284
+ guards: [/* run for every route of the API */],
285
+ controllers: [defineController({
286
+ prefix: '/p/:proj/wiki',
287
+ guards: [loadProject, featureEnabled('wikiEnabled')],
288
+ GET: {
289
+ '/pages/:slug': describe(
290
+ (ctx) => wikiService.readPage(ctx.params.proj, ctx.params.slug),
291
+ { summary: 'Read a wiki page', guards: [/* route-level guards */] }),
292
+ },
293
+ })],
294
+ });
295
+ ```
296
+
297
+ ---
298
+
299
+ ## Auth binding
300
+
301
+ Connect an authentication layer (typically the JWT plugin) to the API Builder once, then declare permissions per route or per controller:
302
+
303
+ ```ts
304
+ interface AuthBinding<TUser = unknown> {
305
+ authenticate?: Middleware; // registered on the internal router, runs first
306
+ check?: (ctx: ApiContext<TUser>, required: string[]) => void | Promise<void>;
307
+ scheme?: Record<string, unknown>; // OpenAPI security scheme (default: bearer JWT)
308
+ permissionsExtension?: string; // default: 'x-required-permissions'
309
+ }
310
+ ```
311
+
312
+ ### Out-of-the-box JWT integration
313
+
314
+ ```ts
315
+ import { createJwtPlugin, apiBuilder, describe } from 'expediate';
316
+
317
+ const jwt = createJwtPlugin({ accessTokenSecret: SECRET });
318
+
319
+ const api = apiBuilder({
320
+ auth: { authenticate: jwt.authenticate }, // default check() reads ctx.user.permissions
321
+ GET: {
322
+ '/public': () => ({ ok: true }), // no permission → public
323
+ '/private': describe(handler, { permission: 'write' }), // 401 / 403 / pass
324
+ },
325
+ });
326
+ ```
327
+
328
+ The default `check` mirrors `jwtPlugin.requirePermission` semantics in the `ctx` world: `401` when `ctx.user` is absent, `403` when `ctx.user.permissions` is missing **any** required entry. Routes without a `permission` stay public — authorization is opt-in per route.
329
+
330
+ ### Resource-scoped models
331
+
332
+ Override `check` **once** for per-resource permissions; it may load resources and share them through `ctx.state`:
333
+
334
+ ```ts
335
+ auth: {
336
+ authenticate: jwt.authenticate,
337
+ check: async (ctx, required) => {
338
+ const proj = await projectService.openProject(ctx.params.proj);
339
+ for (const p of required)
340
+ authService.requirePermission(ctx.user?.sub ?? null, proj, p);
341
+ ctx.state.proj = proj; // share the loaded project with guards/handler
342
+ },
343
+ },
344
+ ```
345
+
346
+ ---
347
+
348
+ ## Request validation
349
+
350
+ Execute the JSON Schemas you already declare in `describe()` metadata:
351
+
352
+ ```ts
353
+ const api = apiBuilder({
354
+ validate: true, // or { requests: true }
355
+ schemas: { // shared by the validator AND the spec
356
+ Item: {
357
+ type: 'object',
358
+ required: ['name'],
359
+ properties: {
360
+ name: { type: 'string', pattern: '^[a-z0-9][a-z0-9.\\-]*$' },
361
+ size: { type: 'integer', minimum: 1 },
362
+ },
363
+ },
364
+ },
365
+ POST: {
366
+ '/items': describe(createItem, {
367
+ requestBody: {
368
+ required: true,
369
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/Item' } } },
370
+ },
371
+ }),
372
+ },
373
+ });
374
+ ```
375
+
376
+ When enabled, the body is checked against the route's `requestBody` schema before the guards run. Failures produce `400`:
377
+
378
+ ```json
379
+ { "message": "Request body validation failed",
380
+ "fieldErrors": { "name": "does not match pattern ^[a-z0-9][a-z0-9.\\-]*$" } }
381
+ ```
382
+
383
+ Supported keywords: `type`, `required`, `properties`, `items`, `enum`, `pattern`, `minLength`/`maxLength`, `minimum`/`maximum`, `additionalProperties`, `allOf`/`anyOf`/`oneOf`, and `$ref` resolved against `ServiceDefinition.schemas`. Field-error paths are dotted (`name`, `address.city`, `tags.0`); errors on the body itself are keyed `'$'`.
384
+
385
+ ---
386
+
387
+ ## Builder options: `apiBuilder(service, options?)`
388
+
389
+ `apiBuilder` takes an optional second argument, `ApiBuilderOptions`, that controls validation. When you pass it, it is authoritative and overrides the `service.validate` field (which accepts the same `boolean | ApiBuilderOptions` shape):
390
+
391
+ ```ts
392
+ apiBuilder(service); // follows service.validate
393
+ apiBuilder(service, {}); // validate requests (default on), not responses
394
+ apiBuilder(service, { validateRequests: false }); // validate nothing
395
+ apiBuilder(service, { validateResponses: true }); // requests + responses (500 on mismatch)
396
+ apiBuilder(service, { validateResponses: 'warn' }); // requests + responses (log only, no 500)
397
+ ```
398
+
399
+ `ApiBuilderOptions`:
400
+
401
+ | Field | Type | Default | Effect |
402
+ |---------------------|--------------------|---------|--------|
403
+ | `validateRequests` | `boolean` | `true` | Check incoming bodies against `requestBody` schemas. `false` cancels it. Failure → `400`. |
404
+ | `validateResponses` | `boolean \| 'warn'`| `false` | Check each handler's return against the route's `responses['200']` schema. `true` → `500` on mismatch (off-spec body not sent); `'warn'` → log via `console.warn` and send anyway. |
405
+
406
+ Response validation is a **server-contract** check: a handler returning data that violates its own declared `200` schema means the *server* is at fault. With `true`, a mismatch yields `500 { message: 'Response body validation failed', fieldErrors }` instead of emitting an off-spec body; with `'warn'`, it logs server-side and sends the response unchanged (handy in development). Only truthy returns (sent as `200`) are checked — falsy returns (`201 No Content`) and routes with no declared `200` content schema are skipped. It uses the same validator and `fieldErrors` shape as request validation.
407
+
408
+ ```ts
409
+ const api = apiBuilder(todoService, { validateResponses: true });
410
+ ```
411
+
412
+ ---
413
+
414
+ ## Mounting
415
+
416
+ `apiBuilder` returns a `Router`. Mount it with `app.use()`:
417
+
418
+ ```ts
419
+ app.use('/api/v1', apiBuilder(todoService));
420
+ ```
421
+
422
+ For tests, mounting at `'/'` avoids path-stripping concerns:
423
+
424
+ ```ts
425
+ app.use('/', apiBuilder(todoService));
426
+ ```
427
+
428
+ Body-parsing middleware must be registered before the API router for `body` to be populated in handlers.
429
+
430
+ ---
431
+
432
+ ## OpenAPI spec generation
433
+
434
+ → Full reference: [docs/openapi.md](openapi.md)
435
+
436
+ The router returned by `apiBuilder` can introspect its own definition — controllers included, producing **one** document:
437
+
438
+ ```ts
439
+ import { describe } from 'expediate';
440
+
441
+ GET: {
442
+ '/todos/:id': describe(
443
+ function (this: State, ctx) { return this.findOrThrow(ctx.params.id); },
444
+ { summary: 'Get a todo by ID', tags: ['todos'] },
445
+ ),
446
+ }
447
+
448
+ const api = apiBuilder(service);
449
+ app.use('/api', api);
450
+ app.get('/openapi.json', api.specHandler({ title: 'Todo API', version: '1.0.0' }));
451
+ app.get('/openapi.yaml', api.specHandler({ title: 'Todo API', version: '1.0.0' }, 'yaml'));
452
+ ```
453
+
454
+ `openApiSpec(service, opts)` is the underlying free function when you have the definition but not the router — it also accepts an array of sources to merge in routes that have no `ServiceDefinition` at all (e.g. JWT auth endpoints). See [docs/openapi.md](openapi.md) for `describe()`, security output, `serializeSpec()`, and multi-source merging.
@@ -0,0 +1,27 @@
1
+ # Benchmark
2
+
3
+ Les chiffres sont très bons. Tu bats Express partout (+30 % environ), et sur `json-echo` tu dépasses même Fastify (73k vs 70k). L'écart ne porte que sur les scénarios à dominante *routing* (hello, route-param, chain), où Fastify mène d'~25 %. Laisse-moi regarder ton dispatch pour t'expliquer précisément d'où vient l'écart.Bonne nouvelle d'abord : ton intuition est en partie juste. Là où le travail dominant n'est pas le routing — `json-echo`, où le coût est `JSON.parse`/`stringify` que les deux paient à l'identique (on a désactivé le fast-path de sérialisation de Fastify) — tu passes **devant** Fastify (73k vs 70k). L'écart ne vit que sur le chemin de dispatch. Donc Fastify n'est pas « plus léger » que toi ; il a un chemin par-requête mieux taillé pour V8. J'ai lu ton `router.ts`, voici concrètement d'où vient l'écart.
4
+
5
+ **1. L'algorithme de routeur : scan regex linéaire vs arbre radix.** Ta boucle `next()` fait `while (idx < routes.length)` et exécute `layer.regex.exec(path)` / `.test(path)` sur chaque layer jusqu'à match (lignes 985, 1469, 1698-1702). C'est O(n) exécutions de regex par requête, et chaque `.exec` avec groupes nommés alloue un objet match + un objet groups. Fastify utilise `find-my-way`, un arbre radix (trie compressé) : O(longueur du path), **zéro regex** pour les routes statiques et paramétrées, zéro allocation de match. Avec peu de routes l'écart reste modéré, mais un parcours de trie est intrinsèquement moins cher qu'un `RegExp.exec`.
6
+
7
+ **2. Tu fais du travail eager sur *chaque* requête dans `updateHttpObjects`**, même quand le handler n'en a pas besoin :
8
+
9
+ - `new URL(\`http://${host}${req.url}\`)` (ligne 1144) — le constructeur WHATWG URL est l'un des appels les plus lourds du chemin : parsing/validation complète + allocation d'un objet URL et de son `searchParams`. Fastify fait un simple split sur `?` et ne parse la query que si on y accède.
10
+ - Tu construis plusieurs objets et fais **deux passes** sur les paramètres (`searchParams.entries()` puis `Object.entries`) même sans query string.
11
+ - Tu alloues **3 closures par requête** — `rReq.json`, `rReq.text`, `rReq.formData` (lignes 1206-1254) plus `resolvedReqOpts` — créées même si le body n'est jamais lu. Plus ~8 helpers `res.*` (send/json/status/redirect/cookie…). Fastify n'attache pas de closures par requête.
12
+ - Tu parses les cookies et poses `X-Powered-By` systématiquement.
13
+
14
+ Chacun est minuscule, mais à 90k req/s ce sont autant d'allocations qui nourrissent le GC.
15
+
16
+ **3. Hidden classes / mégamorphisme.** Tu greffes dynamiquement une quinzaine de propriétés (`ip`, `path`, `queries`, `params`, `cookies`, `json`, `text`…) sur les objets `IncomingMessage`/`ServerResponse` bruts, dont V8 a déjà figé la forme. Ces ajouts forcent des transitions de hidden-class et un accès propriété plus lent. Fastify crée **ses propres** objets Request/Reply à forme fixe et déclarée par route (factory + prototype), donc V8 les garde monomorphes.
17
+
18
+ **4. La boucle de dispatch elle-même** alloue des closures par hop (`invoke`, `next`, l'arrow strip-path ligne 1710) et enveloppe chaque appel dans try/catch + `ret instanceof Promise`. Fastify compile la chaîne hooks/handler à l'avance, une fois par route.
19
+
20
+ En clair : tu ne fais pas « trop », tu paies des coûts peu V8-friendly (dispatch regex, URL parsée eager, closures par requête, augmentation dynamique d'objets) qu'un design trie + objets à forme stable évite.
21
+
22
+ Si tu veux refermer l'écart, sans ajouter de dépendance, par ordre de rendement/risque :
23
+
24
+ 1. **Tout rendre lazy** — ne crée `json/text/formData`, ne parse cookies et query qu'au premier accès (getters définis une fois sur un prototype). Retire ~4 allocations du chemin chaud. Gros gain, risque faible.
25
+ 2. **Remplacer `new URL()`** par un split manuel sur le premier `?` ; parser la query seulement si lue. C'est l'appel le plus cher isolé.
26
+ 3. **Forme de requête stable** — initialiser tous les champs augmentés dans un ordre fixe (idéalement via une classe/prototype) pour garder une seule hidden-class.
27
+ 4. **Routeur radix** pour le fast-path statique/paramétré, en retombant sur la regex uniquement pour les globs/RegExp/contraintes `:id(\d+)`. C'est le gain structurel majeur — mais le plus de travail et le plus de risque pour ta suite de tests. À garder en phase 2.