@fragno-dev/core 0.1.1 → 0.1.3

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 (71) hide show
  1. package/.turbo/turbo-build.log +41 -32
  2. package/CHANGELOG.md +15 -0
  3. package/LICENSE.md +16 -0
  4. package/dist/api/api.d.ts +1 -1
  5. package/dist/api/fragment-builder.d.ts +2 -2
  6. package/dist/api/fragment-instantiation.d.ts +2 -2
  7. package/dist/api/fragment-instantiation.js +3 -2
  8. package/dist/{api-Dcr4_-3g.d.ts → api-BX90b4-D.d.ts} +92 -6
  9. package/dist/api-BX90b4-D.d.ts.map +1 -0
  10. package/dist/client/client.d.ts +2 -2
  11. package/dist/client/client.js +4 -3
  12. package/dist/client/client.svelte.d.ts +2 -2
  13. package/dist/client/client.svelte.js +4 -3
  14. package/dist/client/client.svelte.js.map +1 -1
  15. package/dist/client/react.d.ts +2 -2
  16. package/dist/client/react.d.ts.map +1 -1
  17. package/dist/client/react.js +4 -3
  18. package/dist/client/react.js.map +1 -1
  19. package/dist/client/solid.d.ts +2 -2
  20. package/dist/client/solid.js +4 -3
  21. package/dist/client/solid.js.map +1 -1
  22. package/dist/client/vanilla.d.ts +2 -2
  23. package/dist/client/vanilla.js +4 -3
  24. package/dist/client/vanilla.js.map +1 -1
  25. package/dist/client/vue.d.ts +2 -2
  26. package/dist/client/vue.js +4 -3
  27. package/dist/client/vue.js.map +1 -1
  28. package/dist/{client-D5ORmjBP.js → client-C6LChM0Y.js} +4 -3
  29. package/dist/client-C6LChM0Y.js.map +1 -0
  30. package/dist/{fragment-builder-D6-oLYnH.d.ts → fragment-builder-BZr2JkuW.d.ts} +51 -38
  31. package/dist/fragment-builder-BZr2JkuW.d.ts.map +1 -0
  32. package/dist/fragment-builder-DOnCVBqc.js.map +1 -1
  33. package/dist/{fragment-instantiation-f4AhwQss.js → fragment-instantiation-DMw8OKMC.js} +137 -11
  34. package/dist/fragment-instantiation-DMw8OKMC.js.map +1 -0
  35. package/dist/integrations/react-ssr.js +1 -1
  36. package/dist/mod.d.ts +2 -2
  37. package/dist/mod.js +3 -2
  38. package/dist/route-CTxjMtGZ.js +10 -0
  39. package/dist/route-CTxjMtGZ.js.map +1 -0
  40. package/dist/{route-B4RbOWjd.js → route-D1MZR6JL.js} +22 -22
  41. package/dist/route-D1MZR6JL.js.map +1 -0
  42. package/dist/{ssr-CamRrMc0.js → ssr-BByDVfFD.js} +1 -1
  43. package/dist/{ssr-CamRrMc0.js.map → ssr-BByDVfFD.js.map} +1 -1
  44. package/dist/test/test.d.ts +112 -0
  45. package/dist/test/test.d.ts.map +1 -0
  46. package/dist/test/test.js +155 -0
  47. package/dist/test/test.js.map +1 -0
  48. package/package.json +18 -24
  49. package/src/api/fragment-builder.ts +0 -1
  50. package/src/api/fragment-instantiation.ts +16 -3
  51. package/src/api/mutable-request-state.ts +107 -0
  52. package/src/api/request-input-context.test.ts +51 -0
  53. package/src/api/request-input-context.ts +20 -13
  54. package/src/api/request-middleware.test.ts +88 -2
  55. package/src/api/request-middleware.ts +28 -6
  56. package/src/api/request-output-context.test.ts +6 -2
  57. package/src/api/request-output-context.ts +15 -9
  58. package/src/client/component.test.svelte +2 -0
  59. package/src/client/internal/ndjson-streaming.ts +6 -2
  60. package/src/client/react.ts +3 -1
  61. package/src/test/test.test.ts +449 -0
  62. package/src/test/test.ts +379 -0
  63. package/src/util/async.test.ts +6 -2
  64. package/tsdown.config.ts +1 -0
  65. package/.turbo/turbo-test.log +0 -297
  66. package/.turbo/turbo-types$colon$check.log +0 -1
  67. package/dist/api-Dcr4_-3g.d.ts.map +0 -1
  68. package/dist/client-D5ORmjBP.js.map +0 -1
  69. package/dist/fragment-builder-D6-oLYnH.d.ts.map +0 -1
  70. package/dist/fragment-instantiation-f4AhwQss.js.map +0 -1
  71. package/dist/route-B4RbOWjd.js.map +0 -1
@@ -18,6 +18,7 @@ import {
18
18
  type FragnoMiddlewareCallback,
19
19
  } from "./request-middleware";
20
20
  import type { FragmentDefinition } from "./fragment-builder";
21
+ import { MutableRequestState } from "./mutable-request-state";
21
22
 
22
23
  export interface FragnoPublicConfig {
23
24
  mountRoute?: string;
@@ -267,14 +268,25 @@ export function createFragment<
267
268
 
268
269
  const outputContext = new RequestOutputContext(outputSchema);
269
270
 
271
+ // Create mutable request state that can be modified by middleware
272
+ // Clone the request to avoid consuming the body stream
273
+ const clonedReq = req.clone();
274
+ const requestBody =
275
+ clonedReq.body instanceof ReadableStream ? await clonedReq.json() : undefined;
276
+
277
+ const requestState = new MutableRequestState({
278
+ pathParams: route.params ?? {},
279
+ searchParams: url.searchParams,
280
+ body: requestBody,
281
+ headers: new Headers(req.headers),
282
+ });
283
+
270
284
  if (middlewareHandler) {
271
285
  const middlewareInputContext = new RequestMiddlewareInputContext(routes, {
272
286
  method: req.method as HTTPMethod,
273
287
  path,
274
- pathParams: route.params,
275
- searchParams: new URL(req.url).searchParams,
276
- body: req.body,
277
288
  request: req,
289
+ state: requestState,
278
290
  });
279
291
 
280
292
  const middlewareOutputContext = new RequestMiddlewareOutputContext(dependencies, services);
@@ -310,6 +322,7 @@ export function createFragment<
310
322
  path,
311
323
  pathParams: (route.params ?? {}) as ExtractPathParams<typeof path>,
312
324
  inputSchema,
325
+ state: requestState,
313
326
  });
314
327
 
315
328
  try {
@@ -0,0 +1,107 @@
1
+ import type { RequestBodyType } from "./request-input-context";
2
+
3
+ /**
4
+ * Holds mutable request state that can be modified by middleware and consumed by handlers.
5
+ *
6
+ * This class provides a structural way for middleware to modify request data:
7
+ * - Path parameters can be modified
8
+ * - Query/search parameters can be modified
9
+ * - Request body can be overridden
10
+ * - Request headers can be modified
11
+ *
12
+ * @example
13
+ * ```typescript
14
+ * // In middleware
15
+ * const state = new MutableRequestState({
16
+ * pathParams: { id: "123" },
17
+ * searchParams: new URLSearchParams("?role=user"),
18
+ * body: { name: "John" },
19
+ * headers: new Headers()
20
+ * });
21
+ *
22
+ * // Modify query parameters
23
+ * state.searchParams.set("role", "admin");
24
+ *
25
+ * // Override body
26
+ * state.setBody({ name: "Jane" });
27
+ *
28
+ * // Modify headers
29
+ * state.headers.set("X-Custom", "value");
30
+ * ```
31
+ */
32
+ export class MutableRequestState {
33
+ readonly #pathParams: Record<string, string>;
34
+ readonly #searchParams: URLSearchParams;
35
+ readonly #headers: Headers;
36
+ // oxlint-disable-next-line no-unused-private-class-members False Positive?
37
+ readonly #initialBody: RequestBodyType;
38
+ #bodyOverride: RequestBodyType | undefined;
39
+
40
+ constructor(config: {
41
+ pathParams: Record<string, string>;
42
+ searchParams: URLSearchParams;
43
+ body: RequestBodyType;
44
+ headers: Headers;
45
+ }) {
46
+ this.#pathParams = config.pathParams;
47
+ this.#searchParams = config.searchParams;
48
+ this.#headers = config.headers;
49
+ this.#initialBody = config.body;
50
+ this.#bodyOverride = undefined;
51
+ }
52
+
53
+ /**
54
+ * Path parameters extracted from the route.
55
+ * Can be modified directly (e.g., `state.pathParams.id = "456"`).
56
+ */
57
+ get pathParams(): Record<string, string> {
58
+ return this.#pathParams;
59
+ }
60
+
61
+ /**
62
+ * URLSearchParams for query parameters.
63
+ * Can be modified using URLSearchParams API (e.g., `state.searchParams.set("key", "value")`).
64
+ */
65
+ get searchParams(): URLSearchParams {
66
+ return this.#searchParams;
67
+ }
68
+
69
+ /**
70
+ * Request headers.
71
+ * Can be modified using Headers API (e.g., `state.headers.set("X-Custom", "value")`).
72
+ */
73
+ get headers(): Headers {
74
+ return this.#headers;
75
+ }
76
+
77
+ /**
78
+ * Get the current body value.
79
+ * Returns the override if set, otherwise the initial body.
80
+ */
81
+ get body(): RequestBodyType {
82
+ return this.#bodyOverride !== undefined ? this.#bodyOverride : this.#initialBody;
83
+ }
84
+
85
+ /**
86
+ * Override the request body.
87
+ * This allows middleware to replace the body that will be seen by the handler.
88
+ *
89
+ * @param body - The new body value
90
+ *
91
+ * @example
92
+ * ```typescript
93
+ * // In middleware
94
+ * state.setBody({ modifiedField: "new value" });
95
+ * ```
96
+ */
97
+ setBody(body: RequestBodyType): void {
98
+ this.#bodyOverride = body;
99
+ }
100
+
101
+ /**
102
+ * Check if the body has been overridden by middleware.
103
+ */
104
+ get hasBodyOverride(): boolean {
105
+ return this.#bodyOverride !== undefined;
106
+ }
107
+ }
@@ -2,6 +2,7 @@ import { test, expect, describe } from "vitest";
2
2
  import { RequestInputContext } from "./request-input-context";
3
3
  import { FragnoApiValidationError } from "./api";
4
4
  import type { StandardSchemaV1 } from "@standard-schema/spec";
5
+ import { MutableRequestState } from "./mutable-request-state";
5
6
 
6
7
  // Mock schema implementations for testing
7
8
  const createMockSchema = (shouldPass: boolean, returnValue?: unknown): StandardSchemaV1 => ({
@@ -40,6 +41,7 @@ describe("RequestContext", () => {
40
41
  path: "/",
41
42
  pathParams: {},
42
43
  searchParams: new URLSearchParams(),
44
+ headers: new Headers(),
43
45
  body: undefined,
44
46
  });
45
47
 
@@ -59,6 +61,7 @@ describe("RequestContext", () => {
59
61
  path: "/api/test",
60
62
  pathParams: {},
61
63
  searchParams: new URLSearchParams(),
64
+ headers: new Headers(),
62
65
  body: jsonBody,
63
66
  });
64
67
 
@@ -73,6 +76,7 @@ describe("RequestContext", () => {
73
76
  path: "/api/form",
74
77
  pathParams: {},
75
78
  searchParams: new URLSearchParams(),
79
+ headers: new Headers(),
76
80
  body: formData,
77
81
  method: "POST",
78
82
  });
@@ -87,6 +91,7 @@ describe("RequestContext", () => {
87
91
  path: "/api/upload",
88
92
  pathParams: {},
89
93
  searchParams: new URLSearchParams(),
94
+ headers: new Headers(),
90
95
  body: blob,
91
96
  method: "POST",
92
97
  });
@@ -101,11 +106,23 @@ describe("RequestContext", () => {
101
106
  });
102
107
 
103
108
  const bodyData = { test: "data" };
109
+ const url = new URL(request.url);
110
+ const clonedReq = request.clone();
111
+ const body = clonedReq.body instanceof ReadableStream ? await clonedReq.json() : undefined;
112
+
113
+ const state = new MutableRequestState({
114
+ pathParams: {},
115
+ searchParams: url.searchParams,
116
+ body,
117
+ headers: new Headers(request.headers),
118
+ });
119
+
104
120
  const ctx = await RequestInputContext.fromRequest({
105
121
  request,
106
122
  method: "POST",
107
123
  path: "/api/test",
108
124
  pathParams: {},
125
+ state,
109
126
  });
110
127
 
111
128
  expect(ctx.path).toBe("/api/test");
@@ -131,6 +148,7 @@ describe("RequestContext", () => {
131
148
  path: "/test",
132
149
  pathParams: {},
133
150
  searchParams: new URLSearchParams(),
151
+ headers: new Headers(),
134
152
  body: { test: "data" },
135
153
  method: "POST",
136
154
  });
@@ -143,6 +161,7 @@ describe("RequestContext", () => {
143
161
  path: "/test",
144
162
  pathParams: {},
145
163
  searchParams: new URLSearchParams(),
164
+ headers: new Headers(),
146
165
  body: { test: "data" },
147
166
  inputSchema: validStringSchema,
148
167
  method: "POST",
@@ -158,6 +177,7 @@ describe("RequestContext", () => {
158
177
  path: "/test",
159
178
  pathParams: {},
160
179
  searchParams: new URLSearchParams(),
180
+ headers: new Headers(),
161
181
  body: "test string",
162
182
  inputSchema: validStringSchema,
163
183
  method: "POST",
@@ -172,6 +192,7 @@ describe("RequestContext", () => {
172
192
  path: "/test",
173
193
  pathParams: {},
174
194
  searchParams: new URLSearchParams(),
195
+ headers: new Headers(),
175
196
  body: 123, // Invalid for string schema
176
197
  inputSchema: invalidSchema,
177
198
  method: "POST",
@@ -185,6 +206,7 @@ describe("RequestContext", () => {
185
206
  path: "/test",
186
207
  pathParams: {},
187
208
  searchParams: new URLSearchParams(),
209
+ headers: new Headers(),
188
210
  body: 123,
189
211
  inputSchema: invalidSchema,
190
212
  method: "POST",
@@ -213,6 +235,7 @@ describe("RequestContext", () => {
213
235
  path: "/test",
214
236
  pathParams: {},
215
237
  searchParams: new URLSearchParams(),
238
+ headers: new Headers(),
216
239
  body: 123,
217
240
  inputSchema: invalidSchema,
218
241
  shouldValidateInput: false,
@@ -232,6 +255,7 @@ describe("RequestContext", () => {
232
255
  path: "/test",
233
256
  pathParams: {},
234
257
  searchParams: new URLSearchParams(),
258
+ headers: new Headers(),
235
259
  body: formData,
236
260
  inputSchema: validStringSchema,
237
261
  method: "POST",
@@ -249,6 +273,7 @@ describe("RequestContext", () => {
249
273
  path: "/test",
250
274
  pathParams: {},
251
275
  searchParams: new URLSearchParams(),
276
+ headers: new Headers(),
252
277
  body: blob,
253
278
  inputSchema: validStringSchema,
254
279
  method: "POST",
@@ -264,6 +289,7 @@ describe("RequestContext", () => {
264
289
  path: "/test",
265
290
  pathParams: {},
266
291
  searchParams: new URLSearchParams(),
292
+ headers: new Headers(),
267
293
  body: null,
268
294
  inputSchema: validStringSchema,
269
295
  method: "POST",
@@ -278,6 +304,7 @@ describe("RequestContext", () => {
278
304
  path: "/test",
279
305
  pathParams: {},
280
306
  searchParams: new URLSearchParams(),
307
+ headers: new Headers(),
281
308
  body: undefined,
282
309
  inputSchema: validStringSchema,
283
310
  method: "POST",
@@ -294,6 +321,7 @@ describe("RequestContext", () => {
294
321
  path: "/test",
295
322
  pathParams: {},
296
323
  searchParams: new URLSearchParams(),
324
+ headers: new Headers(),
297
325
  inputSchema: validStringSchema,
298
326
  method: "POST",
299
327
  body: undefined,
@@ -308,6 +336,7 @@ describe("RequestContext", () => {
308
336
  path: "/test",
309
337
  pathParams: {},
310
338
  searchParams: new URLSearchParams(),
339
+ headers: new Headers(),
311
340
  inputSchema: validStringSchema,
312
341
  shouldValidateInput: true,
313
342
  method: "POST",
@@ -322,6 +351,7 @@ describe("RequestContext", () => {
322
351
  path: "/test",
323
352
  pathParams: {},
324
353
  searchParams: new URLSearchParams(),
354
+ headers: new Headers(),
325
355
  inputSchema: validStringSchema,
326
356
  shouldValidateInput: false,
327
357
  method: "POST",
@@ -346,6 +376,14 @@ describe("RequestContext", () => {
346
376
 
347
377
  test("Should pass through shouldValidateInput from fromRequest", async () => {
348
378
  const request = new Request("https://example.com/api/test");
379
+ const url = new URL(request.url);
380
+ const state = new MutableRequestState({
381
+ pathParams: {},
382
+ searchParams: url.searchParams,
383
+ body: undefined,
384
+ headers: new Headers(request.headers),
385
+ });
386
+
349
387
  const ctx = await RequestInputContext.fromRequest({
350
388
  request,
351
389
  path: "/test",
@@ -353,6 +391,7 @@ describe("RequestContext", () => {
353
391
  inputSchema: validStringSchema,
354
392
  shouldValidateInput: false,
355
393
  method: "POST",
394
+ state,
356
395
  });
357
396
 
358
397
  expect(ctx.input).toBeDefined();
@@ -365,6 +404,7 @@ describe("RequestContext", () => {
365
404
  path: "/test",
366
405
  pathParams: {},
367
406
  searchParams: new URLSearchParams(),
407
+ headers: new Headers(),
368
408
  method: "POST",
369
409
  body: undefined,
370
410
  });
@@ -377,6 +417,7 @@ describe("RequestContext", () => {
377
417
  path: "/test",
378
418
  pathParams: {},
379
419
  searchParams: new URLSearchParams(),
420
+ headers: new Headers(),
380
421
  method: "POST",
381
422
  body: undefined,
382
423
  });
@@ -390,6 +431,7 @@ describe("RequestContext", () => {
390
431
  path: "/test",
391
432
  pathParams: {},
392
433
  searchParams,
434
+ headers: new Headers(),
393
435
  method: "POST",
394
436
  body: undefined,
395
437
  });
@@ -400,11 +442,20 @@ describe("RequestContext", () => {
400
442
 
401
443
  test("Should extract search params from request URL in fromRequest", async () => {
402
444
  const request = new Request("https://example.com/api/test?param=value");
445
+ const url = new URL(request.url);
446
+ const state = new MutableRequestState({
447
+ pathParams: {},
448
+ searchParams: url.searchParams,
449
+ body: undefined,
450
+ headers: new Headers(request.headers),
451
+ });
452
+
403
453
  const ctx = await RequestInputContext.fromRequest({
404
454
  request,
405
455
  path: "/test",
406
456
  pathParams: {},
407
457
  method: "POST",
458
+ state,
408
459
  });
409
460
 
410
461
  expect(ctx.query.get("param")).toBe("value");
@@ -1,6 +1,7 @@
1
1
  import type { StandardSchemaV1 } from "@standard-schema/spec";
2
2
  import type { ExtractPathParams } from "./internal/path";
3
3
  import { FragnoApiValidationError, type HTTPMethod } from "./api";
4
+ import type { MutableRequestState } from "./mutable-request-state";
4
5
 
5
6
  export type RequestBodyType =
6
7
  | unknown // JSON
@@ -17,6 +18,7 @@ export class RequestInputContext<
17
18
  readonly #method: string;
18
19
  readonly #pathParams: ExtractPathParams<TPath>;
19
20
  readonly #searchParams: URLSearchParams;
21
+ readonly #headers: Headers;
20
22
  readonly #body: RequestBodyType;
21
23
  readonly #inputSchema: TInputSchema | undefined;
22
24
  readonly #shouldValidateInput: boolean;
@@ -26,6 +28,7 @@ export class RequestInputContext<
26
28
  method: string;
27
29
  pathParams: ExtractPathParams<TPath>;
28
30
  searchParams: URLSearchParams;
31
+ headers: Headers;
29
32
  body: RequestBodyType;
30
33
 
31
34
  request?: Request;
@@ -36,6 +39,7 @@ export class RequestInputContext<
36
39
  this.#method = config.method;
37
40
  this.#pathParams = config.pathParams;
38
41
  this.#searchParams = config.searchParams;
42
+ this.#headers = config.headers;
39
43
  this.#body = config.body;
40
44
  this.#inputSchema = config.inputSchema;
41
45
  this.#shouldValidateInput = config.shouldValidateInput ?? true;
@@ -54,22 +58,16 @@ export class RequestInputContext<
54
58
  pathParams: ExtractPathParams<TPath>;
55
59
  inputSchema?: TInputSchema;
56
60
  shouldValidateInput?: boolean;
61
+ state: MutableRequestState;
57
62
  }): Promise<RequestInputContext<TPath, TInputSchema>> {
58
- const url = new URL(config.request.url);
59
-
60
- // Clone the request to avoid consuming the body stream
61
- // TODO: Probably we should just cache the result instead
62
- const request = config.request.clone();
63
-
64
- // TODO: Support other body types other than json
65
- const json = request.body instanceof ReadableStream ? await request.json() : undefined;
66
-
63
+ // Use the mutable state (potentially modified by middleware)
67
64
  return new RequestInputContext({
68
65
  method: config.method,
69
66
  path: config.path,
70
- pathParams: config.pathParams,
71
- searchParams: url.searchParams,
72
- body: json,
67
+ pathParams: config.state.pathParams as ExtractPathParams<TPath>,
68
+ searchParams: config.state.searchParams,
69
+ headers: config.state.headers,
70
+ body: config.state.body,
73
71
  inputSchema: config.inputSchema,
74
72
  shouldValidateInput: config.shouldValidateInput,
75
73
  });
@@ -88,12 +86,14 @@ export class RequestInputContext<
88
86
  path: TPath;
89
87
  pathParams: ExtractPathParams<TPath>;
90
88
  searchParams?: URLSearchParams;
89
+ headers?: Headers;
91
90
  }
92
91
  | {
93
92
  method: Exclude<HTTPMethod, "GET">;
94
93
  path: TPath;
95
94
  pathParams: ExtractPathParams<TPath>;
96
95
  searchParams?: URLSearchParams;
96
+ headers?: Headers;
97
97
  body: RequestBodyType;
98
98
  inputSchema?: TInputSchema;
99
99
  },
@@ -103,13 +103,13 @@ export class RequestInputContext<
103
103
  path: config.path,
104
104
  pathParams: config.pathParams,
105
105
  searchParams: config.searchParams ?? new URLSearchParams(),
106
+ headers: config.headers ?? new Headers(),
106
107
  body: "body" in config ? config.body : undefined,
107
108
  inputSchema: "inputSchema" in config ? config.inputSchema : undefined,
108
109
  shouldValidateInput: false, // No input validation in SSR context
109
110
  });
110
111
  }
111
112
 
112
- // TODO(Wilco): We should support reading/modifying headers here.
113
113
  /**
114
114
  * The HTTP method as string (e.g., `GET`, `POST`)
115
115
  */
@@ -137,6 +137,13 @@ export class RequestInputContext<
137
137
  get query(): URLSearchParams {
138
138
  return this.#searchParams;
139
139
  }
140
+ /**
141
+ * [Headers](https://developer.mozilla.org/en-US/docs/Web/API/Headers) object for request headers
142
+ * @remarks `Headers`
143
+ */
144
+ get headers(): Headers {
145
+ return this.#headers;
146
+ }
140
147
  // TODO: Should probably remove this
141
148
  /**
142
149
  * @internal
@@ -500,8 +500,7 @@ describe("Request Middleware", () => {
500
500
  });
501
501
  });
502
502
 
503
- // TODO: This is not currently supported
504
- test.todo("middleware can modify query parameters", async () => {
503
+ test("middleware can modify query parameters", async () => {
505
504
  const fragment = defineFragment("test-lib");
506
505
 
507
506
  const routes = [
@@ -542,4 +541,91 @@ describe("Request Middleware", () => {
542
541
  role: "some-other-role-defined-in-middleware",
543
542
  });
544
543
  });
544
+
545
+ test("middleware can modify request body", async () => {
546
+ const fragment = defineFragment("test-lib");
547
+
548
+ const routes = [
549
+ defineRoute({
550
+ method: "POST",
551
+ path: "/users",
552
+ inputSchema: z.object({ name: z.string(), role: z.string().optional() }),
553
+ outputSchema: z.object({ name: z.string(), role: z.string() }),
554
+ handler: async ({ input }, { json }) => {
555
+ const body = await input.valid();
556
+ return json({
557
+ name: body.name,
558
+ role: body.role ?? "user",
559
+ });
560
+ },
561
+ }),
562
+ ] as const;
563
+
564
+ const instance = createFragment(fragment, {}, routes, {
565
+ mountRoute: "/api",
566
+ }).withMiddleware(async ({ ifMatchesRoute, requestState }) => {
567
+ // Middleware modifies the request body
568
+ const result = await ifMatchesRoute("POST", "/users", async ({ input }) => {
569
+ const body = await input.valid();
570
+ // Modify the body by adding a role field
571
+ requestState.setBody({
572
+ ...body,
573
+ role: "admin-from-middleware",
574
+ });
575
+ });
576
+
577
+ return result;
578
+ });
579
+
580
+ const req = new Request("http://localhost/api/users", {
581
+ method: "POST",
582
+ headers: { "Content-Type": "application/json" },
583
+ body: JSON.stringify({ name: "John Doe" }),
584
+ });
585
+
586
+ const res = await instance.handler(req);
587
+ expect(res.status).toBe(200);
588
+ expect(await res.json()).toEqual({
589
+ name: "John Doe",
590
+ role: "admin-from-middleware",
591
+ });
592
+ });
593
+
594
+ test("middleware can modify request headers", async () => {
595
+ const fragment = defineFragment("test-lib");
596
+
597
+ const routes = [
598
+ defineRoute({
599
+ method: "GET",
600
+ path: "/data",
601
+ outputSchema: z.object({ auth: z.string(), custom: z.string() }),
602
+ handler: async ({ headers }, { json }) => {
603
+ return json({
604
+ auth: headers.get("Authorization") ?? "none",
605
+ custom: headers.get("X-Custom-Header") ?? "none",
606
+ });
607
+ },
608
+ }),
609
+ ] as const;
610
+
611
+ const instance = createFragment(fragment, {}, routes, {
612
+ mountRoute: "/api",
613
+ }).withMiddleware(async ({ headers }) => {
614
+ // Middleware modifies headers
615
+ headers.set("Authorization", "Bearer middleware-token");
616
+ headers.set("X-Custom-Header", "middleware-value");
617
+ return undefined;
618
+ });
619
+
620
+ const req = new Request("http://localhost/api/data", {
621
+ method: "GET",
622
+ });
623
+
624
+ const res = await instance.handler(req);
625
+ expect(res.status).toBe(200);
626
+ expect(await res.json()).toEqual({
627
+ auth: "Bearer middleware-token",
628
+ custom: "middleware-value",
629
+ });
630
+ });
545
631
  });
@@ -2,10 +2,10 @@ import type { StandardSchemaV1 } from "@standard-schema/spec";
2
2
  import type { ExtractRouteByPath, ExtractRoutePath } from "../client/client";
3
3
  import type { HTTPMethod } from "./api";
4
4
  import type { ExtractPathParams } from "./internal/path";
5
- import type { RequestBodyType } from "./request-input-context";
6
5
  import type { AnyFragnoRouteConfig } from "./route";
7
6
  import { RequestInputContext } from "./request-input-context";
8
7
  import { OutputContext, RequestOutputContext } from "./request-output-context";
8
+ import { MutableRequestState } from "./mutable-request-state";
9
9
 
10
10
  export type FragnoMiddlewareCallback<
11
11
  TRoutes extends readonly AnyFragnoRouteConfig[],
@@ -19,10 +19,8 @@ export type FragnoMiddlewareCallback<
19
19
  export interface RequestMiddlewareOptions {
20
20
  path: string;
21
21
  method: HTTPMethod;
22
- pathParams?: Record<string, string>;
23
- searchParams: URLSearchParams;
24
- body: RequestBodyType;
25
22
  request: Request;
23
+ state: MutableRequestState;
26
24
  }
27
25
 
28
26
  export class RequestMiddlewareOutputContext<
@@ -50,9 +48,11 @@ export class RequestMiddlewareOutputContext<
50
48
  export class RequestMiddlewareInputContext<const TRoutes extends readonly AnyFragnoRouteConfig[]> {
51
49
  readonly #options: RequestMiddlewareOptions;
52
50
  readonly #route: TRoutes[number];
51
+ readonly #state: MutableRequestState;
53
52
 
54
53
  constructor(routes: TRoutes, options: RequestMiddlewareOptions) {
55
54
  this.#options = options;
55
+ this.#state = options.state;
56
56
 
57
57
  const route = routes.find(
58
58
  (route) => route.path === options.path && route.method === options.method,
@@ -74,11 +74,15 @@ export class RequestMiddlewareInputContext<const TRoutes extends readonly AnyFra
74
74
  }
75
75
 
76
76
  get pathParams(): Record<string, string> {
77
- return this.#options.pathParams ?? {};
77
+ return this.#state.pathParams;
78
78
  }
79
79
 
80
80
  get queryParams(): URLSearchParams {
81
- return this.#options.searchParams;
81
+ return this.#state.searchParams;
82
+ }
83
+
84
+ get headers(): Headers {
85
+ return this.#state.headers;
82
86
  }
83
87
 
84
88
  get inputSchema(): StandardSchemaV1 | undefined {
@@ -89,6 +93,23 @@ export class RequestMiddlewareInputContext<const TRoutes extends readonly AnyFra
89
93
  return this.#route.outputSchema;
90
94
  }
91
95
 
96
+ /**
97
+ * Access to the mutable request state.
98
+ * Use this to modify query parameters, path parameters, or request body.
99
+ *
100
+ * @example
101
+ * ```typescript
102
+ * // Modify body
103
+ * requestState.setBody({ modified: true });
104
+ *
105
+ * // Query params are already accessible via queryParams getter
106
+ * // Path params are already accessible via pathParams getter
107
+ * ```
108
+ */
109
+ get requestState(): MutableRequestState {
110
+ return this.#state;
111
+ }
112
+
92
113
  // Defined as a field so that `this` reference stays in tact when destructuring
93
114
  ifMatchesRoute = async <
94
115
  const TMethod extends HTTPMethod,
@@ -116,6 +137,7 @@ export class RequestMiddlewareInputContext<const TRoutes extends readonly AnyFra
116
137
  path: path,
117
138
  pathParams: this.pathParams as ExtractPathParams<TPath>,
118
139
  inputSchema: this.#route.inputSchema,
140
+ state: this.#state,
119
141
  });
120
142
 
121
143
  const outputContext = new RequestOutputContext(this.#route.outputSchema);
@@ -327,7 +327,9 @@ describe("RequestOutputContext", () => {
327
327
  try {
328
328
  while (true) {
329
329
  const { done, value } = await reader.read();
330
- if (done) break;
330
+ if (done) {
331
+ break;
332
+ }
331
333
  chunks.push(decoder.decode(value));
332
334
  }
333
335
  } catch {
@@ -354,7 +356,9 @@ describe("RequestOutputContext", () => {
354
356
  try {
355
357
  while (true) {
356
358
  const { done, value } = await reader.read();
357
- if (done) break;
359
+ if (done) {
360
+ break;
361
+ }
358
362
  chunks.push(decoder.decode(value));
359
363
  }
360
364
  } catch {