@fragno-dev/core 0.0.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 (108) hide show
  1. package/.turbo/turbo-build.log +61 -0
  2. package/.turbo/turbo-types$colon$check.log +2 -0
  3. package/dist/api/api.d.ts +2 -0
  4. package/dist/api/api.js +3 -0
  5. package/dist/api-CBDGZiLC.d.ts +278 -0
  6. package/dist/api-CBDGZiLC.d.ts.map +1 -0
  7. package/dist/api-DgHfYjq2.js +54 -0
  8. package/dist/api-DgHfYjq2.js.map +1 -0
  9. package/dist/client/client.d.ts +3 -0
  10. package/dist/client/client.js +6 -0
  11. package/dist/client/client.svelte.d.ts +33 -0
  12. package/dist/client/client.svelte.d.ts.map +1 -0
  13. package/dist/client/client.svelte.js +123 -0
  14. package/dist/client/client.svelte.js.map +1 -0
  15. package/dist/client/react.d.ts +58 -0
  16. package/dist/client/react.d.ts.map +1 -0
  17. package/dist/client/react.js +80 -0
  18. package/dist/client/react.js.map +1 -0
  19. package/dist/client/vanilla.d.ts +61 -0
  20. package/dist/client/vanilla.d.ts.map +1 -0
  21. package/dist/client/vanilla.js +136 -0
  22. package/dist/client/vanilla.js.map +1 -0
  23. package/dist/client/vue.d.ts +39 -0
  24. package/dist/client/vue.d.ts.map +1 -0
  25. package/dist/client/vue.js +108 -0
  26. package/dist/client/vue.js.map +1 -0
  27. package/dist/client-DWjxKDnE.js +703 -0
  28. package/dist/client-DWjxKDnE.js.map +1 -0
  29. package/dist/client-XFdAy-IQ.d.ts +287 -0
  30. package/dist/client-XFdAy-IQ.d.ts.map +1 -0
  31. package/dist/integrations/astro.d.ts +18 -0
  32. package/dist/integrations/astro.d.ts.map +1 -0
  33. package/dist/integrations/astro.js +16 -0
  34. package/dist/integrations/astro.js.map +1 -0
  35. package/dist/integrations/next-js.d.ts +15 -0
  36. package/dist/integrations/next-js.d.ts.map +1 -0
  37. package/dist/integrations/next-js.js +17 -0
  38. package/dist/integrations/next-js.js.map +1 -0
  39. package/dist/integrations/react-ssr.d.ts +19 -0
  40. package/dist/integrations/react-ssr.d.ts.map +1 -0
  41. package/dist/integrations/react-ssr.js +38 -0
  42. package/dist/integrations/react-ssr.js.map +1 -0
  43. package/dist/integrations/svelte-kit.d.ts +21 -0
  44. package/dist/integrations/svelte-kit.d.ts.map +1 -0
  45. package/dist/integrations/svelte-kit.js +18 -0
  46. package/dist/integrations/svelte-kit.js.map +1 -0
  47. package/dist/mod.d.ts +3 -0
  48. package/dist/mod.js +177 -0
  49. package/dist/mod.js.map +1 -0
  50. package/dist/route-Bp6eByhz.js +331 -0
  51. package/dist/route-Bp6eByhz.js.map +1 -0
  52. package/dist/ssr-tJHqcNSw.js +48 -0
  53. package/dist/ssr-tJHqcNSw.js.map +1 -0
  54. package/package.json +127 -0
  55. package/src/api/api.test.ts +140 -0
  56. package/src/api/api.ts +106 -0
  57. package/src/api/error.ts +47 -0
  58. package/src/api/fragment.test.ts +509 -0
  59. package/src/api/fragment.ts +277 -0
  60. package/src/api/internal/path-runtime.test.ts +121 -0
  61. package/src/api/internal/path-type.test.ts +602 -0
  62. package/src/api/internal/path.ts +322 -0
  63. package/src/api/internal/response-stream.ts +118 -0
  64. package/src/api/internal/route.test.ts +56 -0
  65. package/src/api/internal/route.ts +9 -0
  66. package/src/api/request-input-context.test.ts +437 -0
  67. package/src/api/request-input-context.ts +201 -0
  68. package/src/api/request-middleware.test.ts +544 -0
  69. package/src/api/request-middleware.ts +126 -0
  70. package/src/api/request-output-context.test.ts +626 -0
  71. package/src/api/request-output-context.ts +175 -0
  72. package/src/api/route.test.ts +176 -0
  73. package/src/api/route.ts +152 -0
  74. package/src/client/client-builder.test.ts +264 -0
  75. package/src/client/client-error.test.ts +15 -0
  76. package/src/client/client-error.ts +141 -0
  77. package/src/client/client-types.test.ts +493 -0
  78. package/src/client/client.ssr.test.ts +173 -0
  79. package/src/client/client.svelte.test.ts +837 -0
  80. package/src/client/client.svelte.ts +278 -0
  81. package/src/client/client.test.ts +1690 -0
  82. package/src/client/client.ts +1035 -0
  83. package/src/client/component.test.svelte +21 -0
  84. package/src/client/internal/ndjson-streaming.test.ts +457 -0
  85. package/src/client/internal/ndjson-streaming.ts +248 -0
  86. package/src/client/react.test.ts +947 -0
  87. package/src/client/react.ts +241 -0
  88. package/src/client/vanilla.test.ts +867 -0
  89. package/src/client/vanilla.ts +265 -0
  90. package/src/client/vue.test.ts +754 -0
  91. package/src/client/vue.ts +242 -0
  92. package/src/http/http-status.ts +60 -0
  93. package/src/integrations/astro.ts +17 -0
  94. package/src/integrations/next-js.ts +31 -0
  95. package/src/integrations/react-ssr.ts +40 -0
  96. package/src/integrations/svelte-kit.ts +41 -0
  97. package/src/mod.ts +20 -0
  98. package/src/util/async.test.ts +85 -0
  99. package/src/util/async.ts +96 -0
  100. package/src/util/content-type.test.ts +136 -0
  101. package/src/util/content-type.ts +84 -0
  102. package/src/util/nanostores.test.ts +28 -0
  103. package/src/util/nanostores.ts +65 -0
  104. package/src/util/ssr.ts +75 -0
  105. package/src/util/types-util.ts +16 -0
  106. package/tsconfig.json +10 -0
  107. package/tsdown.config.ts +21 -0
  108. package/vitest.config.ts +10 -0
@@ -0,0 +1,544 @@
1
+ import { test, expect, describe, expectTypeOf } from "vitest";
2
+ import { defineFragment, createFragment } from "./fragment";
3
+ import { defineRoute } from "./route";
4
+ import { z } from "zod";
5
+ import { FragnoApiValidationError } from "./error";
6
+
7
+ describe("Request Middleware", () => {
8
+ test("middleware can intercept and return early", async () => {
9
+ const config = { apiKey: "test" };
10
+
11
+ const fragment = defineFragment<typeof config>("test-lib").withServices(() => ({
12
+ auth: { isAuthorized: (token?: string) => token === "valid-token" },
13
+ }));
14
+
15
+ const routes = [
16
+ defineRoute({
17
+ method: "GET",
18
+ path: "/protected",
19
+ handler: async (_input, { json }) => {
20
+ return json({ message: "You accessed protected resource" });
21
+ },
22
+ }),
23
+ ] as const;
24
+
25
+ const instance = createFragment(fragment, config, routes, {
26
+ mountRoute: "/api",
27
+ });
28
+
29
+ // Add middleware that checks authorization
30
+ const withAuth = instance.withMiddleware(async ({ queryParams }, { services, error }) => {
31
+ const q = queryParams.get("q");
32
+
33
+ if (services.auth.isAuthorized(q ?? undefined)) {
34
+ return undefined;
35
+ }
36
+
37
+ return error({ message: "Unauthorized", code: "UNAUTHORIZED" }, 401);
38
+ });
39
+
40
+ // Test unauthorized request
41
+ const unauthorizedReq = new Request("http://localhost/api/protected", {
42
+ method: "GET",
43
+ });
44
+ const unauthorizedRes = await withAuth.handler(unauthorizedReq);
45
+ expect(unauthorizedRes.status).toBe(401);
46
+ const unauthorizedBody = await unauthorizedRes.json();
47
+ expect(unauthorizedBody).toEqual({
48
+ error: "Unauthorized",
49
+ code: "UNAUTHORIZED",
50
+ });
51
+
52
+ // Test authorized request
53
+ const authorizedReq = new Request("http://localhost/api/protected?q=valid-token", {
54
+ method: "GET",
55
+ });
56
+ const authorizedRes = await withAuth.handler(authorizedReq);
57
+ expect(authorizedRes.status).toBe(200);
58
+ const authorizedBody = await authorizedRes.json();
59
+ expect(authorizedBody).toEqual({
60
+ message: "You accessed protected resource",
61
+ });
62
+ });
63
+
64
+ test("ifMatchesRoute - middleware has access to matched route information", async () => {
65
+ const config = {};
66
+
67
+ const fragment = defineFragment<typeof config>("test-lib");
68
+
69
+ const routes = [
70
+ defineRoute({
71
+ method: "GET",
72
+ path: "/users",
73
+ outputSchema: z.object({ id: z.number(), name: z.string() }),
74
+ handler: async (_, { json }) =>
75
+ json({
76
+ id: 1,
77
+ name: "John Doe",
78
+ }),
79
+ }),
80
+ defineRoute({
81
+ method: "POST",
82
+ path: "/users/:id",
83
+ inputSchema: z.object({ name: z.string() }),
84
+ outputSchema: z.object({ id: z.number(), name: z.string() }),
85
+ handler: async ({ input, pathParams }, { json }) => {
86
+ const body = await input.valid();
87
+
88
+ return json({
89
+ id: +pathParams.id,
90
+ name: body?.name,
91
+ });
92
+ },
93
+ }),
94
+ ] as const;
95
+
96
+ const instance = createFragment(fragment, config, routes, {
97
+ mountRoute: "/api",
98
+ }).withMiddleware(async ({ ifMatchesRoute }) => {
99
+ const result = await ifMatchesRoute(
100
+ "POST",
101
+ "/users/:id",
102
+ async ({ path, pathParams, input }, { error }) => {
103
+ expectTypeOf(path).toEqualTypeOf<"/users/:id">();
104
+ expectTypeOf(pathParams).toEqualTypeOf<{ id: string }>();
105
+
106
+ expectTypeOf(input.schema).toEqualTypeOf<z.ZodObject<{ name: z.ZodString }>>();
107
+
108
+ return error(
109
+ {
110
+ message: "Creating users has been disabled.",
111
+ code: "CREATE_USERS_DISABLED",
112
+ },
113
+ 403,
114
+ );
115
+ },
116
+ );
117
+
118
+ if (result) {
119
+ return result;
120
+ }
121
+
122
+ // This was a request to any other route
123
+ return undefined;
124
+ });
125
+
126
+ // Request to POST, should be disabled.
127
+ const req = new Request("http://localhost/api/users/123", {
128
+ method: "POST",
129
+ headers: { "Content-Type": "application/json" },
130
+ body: JSON.stringify({ name: "John Doe" }),
131
+ });
132
+
133
+ const res = await instance.handler(req);
134
+ expect(res.status).toBe(403);
135
+
136
+ expect(await res.json()).toEqual({
137
+ error: "Creating users has been disabled.",
138
+ code: "CREATE_USERS_DISABLED",
139
+ });
140
+
141
+ // Request to GET, should be enabled.
142
+ const getReq = new Request("http://localhost/api/users", {
143
+ method: "GET",
144
+ });
145
+ const getRes = await instance.handler(getReq);
146
+ expect(getRes.status).toBe(200);
147
+ expect(await getRes.json()).toEqual({
148
+ id: 1,
149
+ name: "John Doe",
150
+ });
151
+ });
152
+
153
+ test("ifMatchesRoute - not called for other routes", async () => {
154
+ const config = {};
155
+
156
+ const fragment = defineFragment<typeof config>("test-lib");
157
+
158
+ const routes = [
159
+ defineRoute({
160
+ method: "GET",
161
+ path: "/users",
162
+ outputSchema: z.object({ id: z.number(), name: z.string() }),
163
+ handler: async (_, { json }) =>
164
+ json({
165
+ id: 1,
166
+ name: "John Doe",
167
+ }),
168
+ }),
169
+ defineRoute({
170
+ method: "POST",
171
+ path: "/users/:id",
172
+ inputSchema: z.object({ name: z.string() }),
173
+ outputSchema: z.object({ id: z.number(), name: z.string() }),
174
+ handler: async ({ input, pathParams }, { json }) => {
175
+ const body = await input.valid();
176
+
177
+ return json({
178
+ id: +pathParams.id,
179
+ name: body?.name,
180
+ });
181
+ },
182
+ }),
183
+ ] as const;
184
+
185
+ let middlewareCalled = false;
186
+
187
+ const instance = createFragment(fragment, config, routes, {
188
+ mountRoute: "/api",
189
+ }).withMiddleware(async ({ ifMatchesRoute }) => {
190
+ return ifMatchesRoute("GET", "/users", () => {
191
+ middlewareCalled = true;
192
+ });
193
+ });
194
+
195
+ // Request to POST, should be disabled.
196
+ const req = new Request("http://localhost/api/users/123", {
197
+ method: "POST",
198
+ headers: { "Content-Type": "application/json" },
199
+ body: JSON.stringify({ name: "John Doe" }),
200
+ });
201
+
202
+ const res = await instance.handler(req);
203
+ expect(res.status).toBe(200);
204
+
205
+ expect(await res.json()).toEqual({
206
+ id: 123,
207
+ name: "John Doe",
208
+ });
209
+
210
+ expect(middlewareCalled).toBe(false);
211
+ });
212
+
213
+ test("ifMatchesRoute - can return undefined", async () => {
214
+ const config = {};
215
+
216
+ const fragment = defineFragment<typeof config>("test-lib");
217
+
218
+ const routes = [
219
+ defineRoute({
220
+ method: "GET",
221
+ path: "/users",
222
+ outputSchema: z.object({ id: z.number(), name: z.string() }),
223
+ handler: async (_, { json }) =>
224
+ json({
225
+ id: 1,
226
+ name: "John Doe",
227
+ }),
228
+ }),
229
+ ] as const;
230
+
231
+ let middlewareCalled = false;
232
+
233
+ const instance = createFragment(fragment, config, routes, {
234
+ mountRoute: "/api",
235
+ }).withMiddleware(async ({ ifMatchesRoute }) => {
236
+ await ifMatchesRoute("GET", "/users", async ({ path, pathParams, input }) => {
237
+ expectTypeOf(path).toEqualTypeOf<"/users">();
238
+ expectTypeOf(pathParams).toEqualTypeOf<{ [x: string]: never }>();
239
+ expectTypeOf(input).toEqualTypeOf<undefined>();
240
+
241
+ middlewareCalled = true;
242
+
243
+ return undefined;
244
+ });
245
+
246
+ return undefined;
247
+ });
248
+
249
+ const getReq = new Request("http://localhost/api/users", {
250
+ method: "GET",
251
+ });
252
+ const getRes = await instance.handler(getReq);
253
+ expect(getRes.status).toBe(200);
254
+ expect(await getRes.json()).toEqual({
255
+ id: 1,
256
+ name: "John Doe",
257
+ });
258
+ expect(middlewareCalled).toBe(true);
259
+ });
260
+
261
+ test("only one middleware is supported", async () => {
262
+ const config = {};
263
+
264
+ const fragment = defineFragment<typeof config>("test-lib");
265
+
266
+ const routes = [
267
+ defineRoute({
268
+ method: "GET",
269
+ path: "/test",
270
+ handler: async (_input, output) => {
271
+ return output.json({ message: "test" });
272
+ },
273
+ }),
274
+ ] as const;
275
+
276
+ const instance = createFragment(fragment, config, routes);
277
+
278
+ const withMiddleware = instance.withMiddleware(async () => {
279
+ return undefined;
280
+ });
281
+
282
+ // Trying to add a third middleware should throw
283
+ expect(() => {
284
+ withMiddleware.withMiddleware(async () => {
285
+ return undefined;
286
+ });
287
+ }).toThrow("Middleware already set");
288
+ });
289
+
290
+ test("middleware and handler can both consume request body without double consumption", async () => {
291
+ const config = {};
292
+
293
+ const fragment = defineFragment<typeof config>("test-lib");
294
+
295
+ const routes = [
296
+ defineRoute({
297
+ method: "POST",
298
+ path: "/users",
299
+ inputSchema: z.object({ name: z.string() }),
300
+ outputSchema: z.object({ id: z.number(), name: z.string() }),
301
+ handler: async ({ input }, { json }) => {
302
+ const body = await input.valid();
303
+ return json({
304
+ id: 1,
305
+ name: body?.name,
306
+ });
307
+ },
308
+ }),
309
+ ] as const;
310
+
311
+ const instance = createFragment(fragment, config, routes, {
312
+ mountRoute: "/api",
313
+ }).withMiddleware(async ({ ifMatchesRoute }) => {
314
+ // Middleware consumes the request body
315
+ const result = await ifMatchesRoute("POST", "/users", async ({ input }) => {
316
+ const body = await input.valid();
317
+ // Middleware can read the body
318
+ expect(body).toEqual({ name: "John Doe" });
319
+ return undefined; // Continue to handler
320
+ });
321
+
322
+ return result;
323
+ });
324
+
325
+ const req = new Request("http://localhost/api/users", {
326
+ method: "POST",
327
+ headers: { "Content-Type": "application/json" },
328
+ body: JSON.stringify({ name: "John Doe" }),
329
+ });
330
+
331
+ const res = await instance.handler(req);
332
+ expect(res.status).toBe(200);
333
+ expect(await res.json()).toEqual({
334
+ id: 1,
335
+ name: "John Doe",
336
+ });
337
+ });
338
+
339
+ test("middleware can modify path parameters", async () => {
340
+ const fragment = defineFragment("test-lib");
341
+
342
+ const routes = [
343
+ defineRoute({
344
+ method: "GET",
345
+ path: "/users/:id",
346
+ outputSchema: z.object({ id: z.number(), name: z.string(), role: z.string() }),
347
+ handler: async ({ pathParams, query }, { json }) => {
348
+ return json({
349
+ id: +pathParams.id,
350
+ name: "John Doe",
351
+ role: query.get("role") ?? "user",
352
+ });
353
+ },
354
+ }),
355
+ ] as const;
356
+
357
+ const instance = createFragment(fragment, {}, routes, {
358
+ mountRoute: "/api",
359
+ }).withMiddleware(async ({ ifMatchesRoute }) => {
360
+ // Middleware can read path and query parameters
361
+ const result = await ifMatchesRoute("GET", "/users/:id", async ({ pathParams }) => {
362
+ pathParams.id = "9999";
363
+ });
364
+
365
+ return result;
366
+ });
367
+
368
+ // Test with admin role
369
+ const adminReq = new Request("http://localhost/api/users/123?role=admin", {
370
+ method: "GET",
371
+ });
372
+ const adminRes = await instance.handler(adminReq);
373
+ expect(adminRes.status).toBe(200);
374
+ expect(await adminRes.json()).toEqual({
375
+ id: 9999,
376
+ name: "John Doe",
377
+ role: "admin",
378
+ });
379
+ });
380
+
381
+ test("middleware calling input.valid() can catch validation error", async () => {
382
+ const config = {};
383
+
384
+ const fragment = defineFragment<typeof config>("test-lib");
385
+
386
+ const routes = [
387
+ defineRoute({
388
+ method: "POST",
389
+ path: "/users",
390
+ inputSchema: z.object({
391
+ name: z.string().min(1, "Name is required"),
392
+ email: z.string().email("Invalid email format"),
393
+ }),
394
+ outputSchema: z.object({ id: z.number(), name: z.string(), email: z.string() }),
395
+ handler: async ({ input }, { json }) => {
396
+ const body = await input.valid();
397
+ return json({
398
+ id: 1,
399
+ name: body.name,
400
+ email: body.email,
401
+ });
402
+ },
403
+ }),
404
+ ] as const;
405
+
406
+ const instance = createFragment(fragment, config, routes, {
407
+ mountRoute: "/api",
408
+ }).withMiddleware(async ({ ifMatchesRoute }) => {
409
+ // Middleware tries to validate the input
410
+ const result = await ifMatchesRoute("POST", "/users", async ({ input }, { error }) => {
411
+ try {
412
+ await input.valid();
413
+ return undefined; // Continue to handler if valid
414
+ } catch (validationError) {
415
+ expect(validationError).toBeInstanceOf(FragnoApiValidationError);
416
+
417
+ return error(
418
+ {
419
+ message: "Request validation failed in middleware",
420
+ code: "MIDDLEWARE_VALIDATION_ERROR",
421
+ },
422
+ 400,
423
+ );
424
+ }
425
+ });
426
+
427
+ return result;
428
+ });
429
+
430
+ // Test with invalid request (missing required fields)
431
+ const invalidReq = new Request("http://localhost/api/users", {
432
+ method: "POST",
433
+ headers: { "Content-Type": "application/json" },
434
+ body: JSON.stringify({ name: "" }), // Invalid: empty name and missing email
435
+ });
436
+
437
+ const res = await instance.handler(invalidReq);
438
+ expect(res.status).toBe(400);
439
+
440
+ const body = await res.json();
441
+ expect(body).toEqual({
442
+ error: "Request validation failed in middleware",
443
+ code: "MIDDLEWARE_VALIDATION_ERROR",
444
+ });
445
+ });
446
+
447
+ test("middleware calling input.valid() can ignore validation error", async () => {
448
+ const config = {};
449
+
450
+ const fragment = defineFragment<typeof config>("test-lib");
451
+
452
+ const routes = [
453
+ defineRoute({
454
+ method: "POST",
455
+ path: "/users",
456
+ inputSchema: z.object({
457
+ name: z.string().min(1, "Name is required"),
458
+ email: z.email("Invalid email format"),
459
+ }),
460
+ outputSchema: z.object({ id: z.number(), name: z.string(), email: z.string() }),
461
+ handler: async (_ctx, { error }) => {
462
+ return error(
463
+ {
464
+ message: "Handler should not be called",
465
+ code: "HANDLER_SHOULD_NOT_BE_CALLED",
466
+ },
467
+ 400,
468
+ );
469
+ },
470
+ }),
471
+ ] as const;
472
+
473
+ const instance = createFragment(fragment, config, routes, {
474
+ mountRoute: "/api",
475
+ }).withMiddleware(async ({ ifMatchesRoute }) => {
476
+ // Middleware tries to validate the input
477
+ const result = await ifMatchesRoute("POST", "/users", async ({ input }) => {
478
+ await input.valid();
479
+ });
480
+
481
+ return result;
482
+ });
483
+
484
+ // Test with invalid request (missing required fields)
485
+ const invalidReq = new Request("http://localhost/api/users", {
486
+ method: "POST",
487
+ headers: { "Content-Type": "application/json" },
488
+ body: JSON.stringify({ name: "" }), // Invalid: empty name and missing email
489
+ });
490
+
491
+ const res = await instance.handler(invalidReq);
492
+ expect(res.status).toBe(400);
493
+
494
+ const body = await res.json();
495
+ expect(body).toEqual({
496
+ message: "Validation failed",
497
+ issues: expect.any(Array),
498
+ code: "FRAGNO_VALIDATION_ERROR",
499
+ });
500
+ });
501
+
502
+ // TODO: This is not currently supported
503
+ test.todo("middleware can modify query parameters", async () => {
504
+ const fragment = defineFragment("test-lib");
505
+
506
+ const routes = [
507
+ defineRoute({
508
+ method: "GET",
509
+ path: "/users/:id",
510
+ outputSchema: z.object({ id: z.number(), name: z.string(), role: z.string() }),
511
+ handler: async ({ pathParams, query }, { json }) => {
512
+ return json({
513
+ id: +pathParams.id,
514
+ name: "John Doe",
515
+ role: query.get("role") ?? "user",
516
+ });
517
+ },
518
+ }),
519
+ ] as const;
520
+
521
+ const instance = createFragment(fragment, {}, routes, {
522
+ mountRoute: "/api",
523
+ }).withMiddleware(async ({ ifMatchesRoute }) => {
524
+ // Middleware can read path and query parameters
525
+ const result = await ifMatchesRoute("GET", "/users/:id", async ({ query }) => {
526
+ query.set("role", "some-other-role-defined-in-middleware");
527
+ });
528
+
529
+ return result;
530
+ });
531
+
532
+ // Test with admin role
533
+ const adminReq = new Request("http://localhost/api/users/123?role=admin", {
534
+ method: "GET",
535
+ });
536
+ const adminRes = await instance.handler(adminReq);
537
+ expect(adminRes.status).toBe(200);
538
+ expect(await adminRes.json()).toEqual({
539
+ id: 123,
540
+ name: "John Doe",
541
+ role: "some-other-role-defined-in-middleware",
542
+ });
543
+ });
544
+ });
@@ -0,0 +1,126 @@
1
+ import type { StandardSchemaV1 } from "@standard-schema/spec";
2
+ import type { ExtractRouteByPath, ExtractRoutePath } from "../client/client";
3
+ import type { HTTPMethod } from "./api";
4
+ import type { ExtractPathParams } from "./internal/path";
5
+ import type { RequestBodyType } from "./request-input-context";
6
+ import type { AnyFragnoRouteConfig } from "./route";
7
+ import { RequestInputContext } from "./request-input-context";
8
+ import { OutputContext, RequestOutputContext } from "./request-output-context";
9
+
10
+ export type FragnoMiddlewareCallback<
11
+ TRoutes extends readonly AnyFragnoRouteConfig[],
12
+ TDeps,
13
+ TServices extends Record<string, unknown>,
14
+ > = (
15
+ inputContext: RequestMiddlewareInputContext<TRoutes>,
16
+ outputContext: RequestMiddlewareOutputContext<TDeps, TServices>,
17
+ ) => Promise<Response | undefined> | Response | undefined;
18
+
19
+ export interface RequestMiddlewareOptions {
20
+ path: string;
21
+ method: HTTPMethod;
22
+ pathParams?: Record<string, string>;
23
+ searchParams: URLSearchParams;
24
+ body: RequestBodyType;
25
+ request: Request;
26
+ }
27
+
28
+ export class RequestMiddlewareOutputContext<
29
+ const TDeps,
30
+ const TServices extends Record<string, unknown>,
31
+ > extends OutputContext<unknown, string> {
32
+ readonly #deps: TDeps;
33
+ readonly #services: TServices;
34
+
35
+ constructor(deps: TDeps, services: TServices) {
36
+ super();
37
+ this.#deps = deps;
38
+ this.#services = services;
39
+ }
40
+
41
+ get deps(): TDeps {
42
+ return this.#deps;
43
+ }
44
+
45
+ get services(): TServices {
46
+ return this.#services;
47
+ }
48
+ }
49
+
50
+ export class RequestMiddlewareInputContext<const TRoutes extends readonly AnyFragnoRouteConfig[]> {
51
+ readonly #options: RequestMiddlewareOptions;
52
+ readonly #route: TRoutes[number];
53
+
54
+ constructor(routes: TRoutes, options: RequestMiddlewareOptions) {
55
+ this.#options = options;
56
+
57
+ const route = routes.find(
58
+ (route) => route.path === options.path && route.method === options.method,
59
+ );
60
+
61
+ if (!route) {
62
+ throw new Error(`Route not found: ${options.path} ${options.method}`);
63
+ }
64
+
65
+ this.#route = route;
66
+ }
67
+
68
+ get path(): string {
69
+ return this.#options.path;
70
+ }
71
+
72
+ get method(): HTTPMethod {
73
+ return this.#options.method;
74
+ }
75
+
76
+ get pathParams(): Record<string, string> {
77
+ return this.#options.pathParams ?? {};
78
+ }
79
+
80
+ get queryParams(): URLSearchParams {
81
+ return this.#options.searchParams;
82
+ }
83
+
84
+ get inputSchema(): StandardSchemaV1 | undefined {
85
+ return this.#route.inputSchema;
86
+ }
87
+
88
+ get outputSchema(): StandardSchemaV1 | undefined {
89
+ return this.#route.outputSchema;
90
+ }
91
+
92
+ // Defined as a field so that `this` reference stays in tact when destructuring
93
+ ifMatchesRoute = async <
94
+ const TMethod extends HTTPMethod,
95
+ const TPath extends ExtractRoutePath<TRoutes>,
96
+ const TRoute extends ExtractRouteByPath<TRoutes, TPath, TMethod> = ExtractRouteByPath<
97
+ TRoutes,
98
+ TPath,
99
+ TMethod
100
+ >,
101
+ >(
102
+ method: TMethod,
103
+ path: TPath,
104
+ handler: (
105
+ ...args: Parameters<TRoute["handler"]>
106
+ ) => Promise<Response | undefined | void> | Response | undefined | void,
107
+ ): Promise<Response | undefined> => {
108
+ if (this.path !== path || this.method !== method) {
109
+ return undefined;
110
+ }
111
+
112
+ // TODO(Wilco): We should support reading/modifying headers here.
113
+ const inputContext = await RequestInputContext.fromRequest({
114
+ request: this.#options.request,
115
+ method: this.#options.method,
116
+ path: path,
117
+ pathParams: this.pathParams as ExtractPathParams<TPath>,
118
+ inputSchema: this.#route.inputSchema,
119
+ });
120
+
121
+ const outputContext = new RequestOutputContext(this.#route.outputSchema);
122
+
123
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
124
+ return (handler as any)(inputContext, outputContext);
125
+ };
126
+ }