@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.
- package/.turbo/turbo-build.log +41 -32
- package/CHANGELOG.md +15 -0
- package/LICENSE.md +16 -0
- package/dist/api/api.d.ts +1 -1
- package/dist/api/fragment-builder.d.ts +2 -2
- package/dist/api/fragment-instantiation.d.ts +2 -2
- package/dist/api/fragment-instantiation.js +3 -2
- package/dist/{api-Dcr4_-3g.d.ts → api-BX90b4-D.d.ts} +92 -6
- package/dist/api-BX90b4-D.d.ts.map +1 -0
- package/dist/client/client.d.ts +2 -2
- package/dist/client/client.js +4 -3
- package/dist/client/client.svelte.d.ts +2 -2
- package/dist/client/client.svelte.js +4 -3
- package/dist/client/client.svelte.js.map +1 -1
- package/dist/client/react.d.ts +2 -2
- package/dist/client/react.d.ts.map +1 -1
- package/dist/client/react.js +4 -3
- package/dist/client/react.js.map +1 -1
- package/dist/client/solid.d.ts +2 -2
- package/dist/client/solid.js +4 -3
- package/dist/client/solid.js.map +1 -1
- package/dist/client/vanilla.d.ts +2 -2
- package/dist/client/vanilla.js +4 -3
- package/dist/client/vanilla.js.map +1 -1
- package/dist/client/vue.d.ts +2 -2
- package/dist/client/vue.js +4 -3
- package/dist/client/vue.js.map +1 -1
- package/dist/{client-D5ORmjBP.js → client-C6LChM0Y.js} +4 -3
- package/dist/client-C6LChM0Y.js.map +1 -0
- package/dist/{fragment-builder-D6-oLYnH.d.ts → fragment-builder-BZr2JkuW.d.ts} +51 -38
- package/dist/fragment-builder-BZr2JkuW.d.ts.map +1 -0
- package/dist/fragment-builder-DOnCVBqc.js.map +1 -1
- package/dist/{fragment-instantiation-f4AhwQss.js → fragment-instantiation-DMw8OKMC.js} +137 -11
- package/dist/fragment-instantiation-DMw8OKMC.js.map +1 -0
- package/dist/integrations/react-ssr.js +1 -1
- package/dist/mod.d.ts +2 -2
- package/dist/mod.js +3 -2
- package/dist/route-CTxjMtGZ.js +10 -0
- package/dist/route-CTxjMtGZ.js.map +1 -0
- package/dist/{route-B4RbOWjd.js → route-D1MZR6JL.js} +22 -22
- package/dist/route-D1MZR6JL.js.map +1 -0
- package/dist/{ssr-CamRrMc0.js → ssr-BByDVfFD.js} +1 -1
- package/dist/{ssr-CamRrMc0.js.map → ssr-BByDVfFD.js.map} +1 -1
- package/dist/test/test.d.ts +112 -0
- package/dist/test/test.d.ts.map +1 -0
- package/dist/test/test.js +155 -0
- package/dist/test/test.js.map +1 -0
- package/package.json +18 -24
- package/src/api/fragment-builder.ts +0 -1
- package/src/api/fragment-instantiation.ts +16 -3
- package/src/api/mutable-request-state.ts +107 -0
- package/src/api/request-input-context.test.ts +51 -0
- package/src/api/request-input-context.ts +20 -13
- package/src/api/request-middleware.test.ts +88 -2
- package/src/api/request-middleware.ts +28 -6
- package/src/api/request-output-context.test.ts +6 -2
- package/src/api/request-output-context.ts +15 -9
- package/src/client/component.test.svelte +2 -0
- package/src/client/internal/ndjson-streaming.ts +6 -2
- package/src/client/react.ts +3 -1
- package/src/test/test.test.ts +449 -0
- package/src/test/test.ts +379 -0
- package/src/util/async.test.ts +6 -2
- package/tsdown.config.ts +1 -0
- package/.turbo/turbo-test.log +0 -297
- package/.turbo/turbo-types$colon$check.log +0 -1
- package/dist/api-Dcr4_-3g.d.ts.map +0 -1
- package/dist/client-D5ORmjBP.js.map +0 -1
- package/dist/fragment-builder-D6-oLYnH.d.ts.map +0 -1
- package/dist/fragment-instantiation-f4AhwQss.js.map +0 -1
- 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
|
-
|
|
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:
|
|
72
|
-
|
|
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
|
-
|
|
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.#
|
|
77
|
+
return this.#state.pathParams;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
get queryParams(): URLSearchParams {
|
|
81
|
-
return this.#
|
|
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)
|
|
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)
|
|
359
|
+
if (done) {
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
358
362
|
chunks.push(decoder.decode(value));
|
|
359
363
|
}
|
|
360
364
|
} catch {
|