@fragno-dev/core 0.1.3 → 0.1.5

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 (44) hide show
  1. package/.turbo/turbo-build.log +19 -19
  2. package/CHANGELOG.md +13 -0
  3. package/dist/api/api.d.ts +1 -1
  4. package/dist/api/fragment-builder.d.ts +2 -2
  5. package/dist/api/fragment-instantiation.d.ts +2 -2
  6. package/dist/api/fragment-instantiation.js +2 -2
  7. package/dist/{api-BX90b4-D.d.ts → api-B1-h7jPC.d.ts} +5 -6
  8. package/dist/api-B1-h7jPC.d.ts.map +1 -0
  9. package/dist/client/client.d.ts +2 -2
  10. package/dist/client/client.js +2 -2
  11. package/dist/client/client.svelte.d.ts +2 -2
  12. package/dist/client/client.svelte.js +2 -2
  13. package/dist/client/react.d.ts +2 -2
  14. package/dist/client/react.js +2 -2
  15. package/dist/client/solid.d.ts +2 -2
  16. package/dist/client/solid.js +2 -2
  17. package/dist/client/vanilla.d.ts +2 -2
  18. package/dist/client/vanilla.js +2 -2
  19. package/dist/client/vue.d.ts +2 -2
  20. package/dist/client/vue.js +2 -2
  21. package/dist/{client-C6LChM0Y.js → client-YUZaNg5U.js} +2 -2
  22. package/dist/{client-C6LChM0Y.js.map → client-YUZaNg5U.js.map} +1 -1
  23. package/dist/{fragment-builder-BZr2JkuW.d.ts → fragment-builder-DsqUOfJ5.d.ts} +4 -2
  24. package/dist/{fragment-builder-BZr2JkuW.d.ts.map → fragment-builder-DsqUOfJ5.d.ts.map} +1 -1
  25. package/dist/{fragment-instantiation-DMw8OKMC.js → fragment-instantiation-Cp0K8zdS.js} +24 -6
  26. package/dist/fragment-instantiation-Cp0K8zdS.js.map +1 -0
  27. package/dist/mod.d.ts +2 -2
  28. package/dist/mod.js +2 -2
  29. package/dist/{route-D1MZR6JL.js → route-Dk1GyqHs.js} +10 -10
  30. package/dist/route-Dk1GyqHs.js.map +1 -0
  31. package/dist/test/test.d.ts +2 -2
  32. package/dist/test/test.js +2 -2
  33. package/dist/test/test.js.map +1 -1
  34. package/package.json +3 -3
  35. package/src/api/fragment-instantiation.ts +36 -5
  36. package/src/api/request-input-context.test.ts +37 -29
  37. package/src/api/request-input-context.ts +16 -14
  38. package/src/api/request-middleware.test.ts +251 -0
  39. package/src/api/request-middleware.ts +1 -1
  40. package/src/test/test.test.ts +56 -1
  41. package/src/test/test.ts +1 -1
  42. package/dist/api-BX90b4-D.d.ts.map +0 -1
  43. package/dist/fragment-instantiation-DMw8OKMC.js.map +0 -1
  44. package/dist/route-D1MZR6JL.js.map +0 -1
@@ -42,61 +42,67 @@ describe("RequestContext", () => {
42
42
  pathParams: {},
43
43
  searchParams: new URLSearchParams(),
44
44
  headers: new Headers(),
45
- body: undefined,
45
+ parsedBody: undefined,
46
46
  });
47
47
 
48
- const { path, pathParams, query: searchParams, input, rawBody: body } = ctx;
48
+ const { path, pathParams, query: searchParams, input, rawBody } = ctx;
49
49
 
50
50
  expect(path).toBe("/");
51
51
  expect(pathParams).toEqual({});
52
52
  expect(searchParams).toBeInstanceOf(URLSearchParams);
53
53
  expect(input).toBeUndefined();
54
- expect(body).toBeUndefined();
54
+ expect(rawBody).toBeUndefined();
55
55
  });
56
56
 
57
57
  test("Should support body in constructor", () => {
58
58
  const jsonBody = { test: "data" };
59
+ const rawBodyText = JSON.stringify(jsonBody);
59
60
  const ctx = new RequestInputContext({
60
61
  method: "POST",
61
62
  path: "/api/test",
62
63
  pathParams: {},
63
64
  searchParams: new URLSearchParams(),
64
65
  headers: new Headers(),
65
- body: jsonBody,
66
+ parsedBody: jsonBody,
67
+ rawBody: rawBodyText,
66
68
  });
67
69
 
68
- expect(ctx.rawBody).toEqual(jsonBody);
70
+ expect(ctx.rawBody).toEqual(rawBodyText);
69
71
  });
70
72
 
71
73
  test("Should support FormData body", () => {
72
74
  const formData = new FormData();
73
75
  formData.append("key", "value");
76
+ const rawBodyText = "form-data-as-text";
74
77
 
75
78
  const ctx = new RequestInputContext({
76
79
  path: "/api/form",
77
80
  pathParams: {},
78
81
  searchParams: new URLSearchParams(),
79
82
  headers: new Headers(),
80
- body: formData,
83
+ parsedBody: formData,
84
+ rawBody: rawBodyText,
81
85
  method: "POST",
82
86
  });
83
87
 
84
- expect(ctx.rawBody).toBe(formData);
88
+ expect(ctx.rawBody).toBe(rawBodyText);
85
89
  });
86
90
 
87
91
  test("Should support Blob body", () => {
88
92
  const blob = new Blob(["test content"], { type: "text/plain" });
93
+ const rawBodyText = "test content";
89
94
 
90
95
  const ctx = new RequestInputContext({
91
96
  path: "/api/upload",
92
97
  pathParams: {},
93
98
  searchParams: new URLSearchParams(),
94
99
  headers: new Headers(),
95
- body: blob,
100
+ parsedBody: blob,
101
+ rawBody: rawBodyText,
96
102
  method: "POST",
97
103
  });
98
104
 
99
- expect(ctx.rawBody).toBe(blob);
105
+ expect(ctx.rawBody).toBe(rawBodyText);
100
106
  });
101
107
 
102
108
  test("Should create RequestContext with fromRequest static method", async () => {
@@ -105,10 +111,10 @@ describe("RequestContext", () => {
105
111
  body: JSON.stringify({ test: "data" }),
106
112
  });
107
113
 
108
- const bodyData = { test: "data" };
109
114
  const url = new URL(request.url);
110
115
  const clonedReq = request.clone();
111
116
  const body = clonedReq.body instanceof ReadableStream ? await clonedReq.json() : undefined;
117
+ const rawBodyText = JSON.stringify({ test: "data" });
112
118
 
113
119
  const state = new MutableRequestState({
114
120
  pathParams: {},
@@ -123,10 +129,11 @@ describe("RequestContext", () => {
123
129
  path: "/api/test",
124
130
  pathParams: {},
125
131
  state,
132
+ rawBody: rawBodyText,
126
133
  });
127
134
 
128
135
  expect(ctx.path).toBe("/api/test");
129
- expect(ctx.rawBody).toEqual(bodyData);
136
+ expect(ctx.rawBody).toEqual(rawBodyText);
130
137
  });
131
138
 
132
139
  test("Should create RequestContext with fromSSRContext static method", () => {
@@ -139,7 +146,8 @@ describe("RequestContext", () => {
139
146
  });
140
147
 
141
148
  expect(ctx.path).toBe("/api/ssr");
142
- expect(ctx.rawBody).toEqual(bodyData);
149
+ // rawBody is not set in fromSSRContext
150
+ expect(ctx.rawBody).toBeUndefined();
143
151
  });
144
152
 
145
153
  describe("Input handling", () => {
@@ -149,7 +157,7 @@ describe("RequestContext", () => {
149
157
  pathParams: {},
150
158
  searchParams: new URLSearchParams(),
151
159
  headers: new Headers(),
152
- body: { test: "data" },
160
+ parsedBody: { test: "data" },
153
161
  method: "POST",
154
162
  });
155
163
 
@@ -162,7 +170,7 @@ describe("RequestContext", () => {
162
170
  pathParams: {},
163
171
  searchParams: new URLSearchParams(),
164
172
  headers: new Headers(),
165
- body: { test: "data" },
173
+ parsedBody: { test: "data" },
166
174
  inputSchema: validStringSchema,
167
175
  method: "POST",
168
176
  });
@@ -178,7 +186,7 @@ describe("RequestContext", () => {
178
186
  pathParams: {},
179
187
  searchParams: new URLSearchParams(),
180
188
  headers: new Headers(),
181
- body: "test string",
189
+ parsedBody: "test string",
182
190
  inputSchema: validStringSchema,
183
191
  method: "POST",
184
192
  });
@@ -193,7 +201,7 @@ describe("RequestContext", () => {
193
201
  pathParams: {},
194
202
  searchParams: new URLSearchParams(),
195
203
  headers: new Headers(),
196
- body: 123, // Invalid for string schema
204
+ parsedBody: 123, // Invalid for string schema
197
205
  inputSchema: invalidSchema,
198
206
  method: "POST",
199
207
  });
@@ -207,7 +215,7 @@ describe("RequestContext", () => {
207
215
  pathParams: {},
208
216
  searchParams: new URLSearchParams(),
209
217
  headers: new Headers(),
210
- body: 123,
218
+ parsedBody: 123,
211
219
  inputSchema: invalidSchema,
212
220
  method: "POST",
213
221
  });
@@ -236,13 +244,13 @@ describe("RequestContext", () => {
236
244
  pathParams: {},
237
245
  searchParams: new URLSearchParams(),
238
246
  headers: new Headers(),
239
- body: 123,
247
+ parsedBody: 123,
240
248
  inputSchema: invalidSchema,
241
249
  shouldValidateInput: false,
242
250
  method: "POST",
243
251
  });
244
252
 
245
- // Should return the raw body without validation when validation is disabled
253
+ // Should return the parsed body without validation when validation is disabled
246
254
  const result = await ctx.input?.valid();
247
255
  expect(result).toBe(123);
248
256
  });
@@ -256,7 +264,7 @@ describe("RequestContext", () => {
256
264
  pathParams: {},
257
265
  searchParams: new URLSearchParams(),
258
266
  headers: new Headers(),
259
- body: formData,
267
+ parsedBody: formData,
260
268
  inputSchema: validStringSchema,
261
269
  method: "POST",
262
270
  });
@@ -274,7 +282,7 @@ describe("RequestContext", () => {
274
282
  pathParams: {},
275
283
  searchParams: new URLSearchParams(),
276
284
  headers: new Headers(),
277
- body: blob,
285
+ parsedBody: blob,
278
286
  inputSchema: validStringSchema,
279
287
  method: "POST",
280
288
  });
@@ -290,7 +298,7 @@ describe("RequestContext", () => {
290
298
  pathParams: {},
291
299
  searchParams: new URLSearchParams(),
292
300
  headers: new Headers(),
293
- body: null,
301
+ parsedBody: null,
294
302
  inputSchema: validStringSchema,
295
303
  method: "POST",
296
304
  });
@@ -305,7 +313,7 @@ describe("RequestContext", () => {
305
313
  pathParams: {},
306
314
  searchParams: new URLSearchParams(),
307
315
  headers: new Headers(),
308
- body: undefined,
316
+ parsedBody: undefined,
309
317
  inputSchema: validStringSchema,
310
318
  method: "POST",
311
319
  });
@@ -324,7 +332,7 @@ describe("RequestContext", () => {
324
332
  headers: new Headers(),
325
333
  inputSchema: validStringSchema,
326
334
  method: "POST",
327
- body: undefined,
335
+ parsedBody: undefined,
328
336
  });
329
337
 
330
338
  // We can't directly access the private field, but we can test the behavior
@@ -340,7 +348,7 @@ describe("RequestContext", () => {
340
348
  inputSchema: validStringSchema,
341
349
  shouldValidateInput: true,
342
350
  method: "POST",
343
- body: undefined,
351
+ parsedBody: undefined,
344
352
  });
345
353
 
346
354
  expect(ctx.input).toBeDefined();
@@ -355,7 +363,7 @@ describe("RequestContext", () => {
355
363
  inputSchema: validStringSchema,
356
364
  shouldValidateInput: false,
357
365
  method: "POST",
358
- body: undefined,
366
+ parsedBody: undefined,
359
367
  });
360
368
 
361
369
  expect(ctx.input).toBeDefined();
@@ -406,7 +414,7 @@ describe("RequestContext", () => {
406
414
  searchParams: new URLSearchParams(),
407
415
  headers: new Headers(),
408
416
  method: "POST",
409
- body: undefined,
417
+ parsedBody: undefined,
410
418
  });
411
419
 
412
420
  expect(ctx.pathParams).toEqual({});
@@ -419,7 +427,7 @@ describe("RequestContext", () => {
419
427
  searchParams: new URLSearchParams(),
420
428
  headers: new Headers(),
421
429
  method: "POST",
422
- body: undefined,
430
+ parsedBody: undefined,
423
431
  });
424
432
 
425
433
  expect(ctx.query.toString()).toBe("");
@@ -433,7 +441,7 @@ describe("RequestContext", () => {
433
441
  searchParams,
434
442
  headers: new Headers(),
435
443
  method: "POST",
436
- body: undefined,
444
+ parsedBody: undefined,
437
445
  });
438
446
 
439
447
  expect(ctx.query.get("key")).toBe("value");
@@ -19,7 +19,8 @@ export class RequestInputContext<
19
19
  readonly #pathParams: ExtractPathParams<TPath>;
20
20
  readonly #searchParams: URLSearchParams;
21
21
  readonly #headers: Headers;
22
- readonly #body: RequestBodyType;
22
+ readonly #body: string | undefined;
23
+ readonly #parsedBody: RequestBodyType;
23
24
  readonly #inputSchema: TInputSchema | undefined;
24
25
  readonly #shouldValidateInput: boolean;
25
26
 
@@ -28,9 +29,9 @@ export class RequestInputContext<
28
29
  method: string;
29
30
  pathParams: ExtractPathParams<TPath>;
30
31
  searchParams: URLSearchParams;
32
+ parsedBody: RequestBodyType;
33
+ rawBody?: string;
31
34
  headers: Headers;
32
- body: RequestBodyType;
33
-
34
35
  request?: Request;
35
36
  inputSchema?: TInputSchema;
36
37
  shouldValidateInput?: boolean;
@@ -40,7 +41,8 @@ export class RequestInputContext<
40
41
  this.#pathParams = config.pathParams;
41
42
  this.#searchParams = config.searchParams;
42
43
  this.#headers = config.headers;
43
- this.#body = config.body;
44
+ this.#body = config.rawBody;
45
+ this.#parsedBody = config.parsedBody;
44
46
  this.#inputSchema = config.inputSchema;
45
47
  this.#shouldValidateInput = config.shouldValidateInput ?? true;
46
48
  }
@@ -59,6 +61,7 @@ export class RequestInputContext<
59
61
  inputSchema?: TInputSchema;
60
62
  shouldValidateInput?: boolean;
61
63
  state: MutableRequestState;
64
+ rawBody?: string;
62
65
  }): Promise<RequestInputContext<TPath, TInputSchema>> {
63
66
  // Use the mutable state (potentially modified by middleware)
64
67
  return new RequestInputContext({
@@ -67,7 +70,8 @@ export class RequestInputContext<
67
70
  pathParams: config.state.pathParams as ExtractPathParams<TPath>,
68
71
  searchParams: config.state.searchParams,
69
72
  headers: config.state.headers,
70
- body: config.state.body,
73
+ parsedBody: config.state.body,
74
+ rawBody: config.rawBody,
71
75
  inputSchema: config.inputSchema,
72
76
  shouldValidateInput: config.shouldValidateInput,
73
77
  });
@@ -104,7 +108,7 @@ export class RequestInputContext<
104
108
  pathParams: config.pathParams,
105
109
  searchParams: config.searchParams ?? new URLSearchParams(),
106
110
  headers: config.headers ?? new Headers(),
107
- body: "body" in config ? config.body : undefined,
111
+ parsedBody: "body" in config ? config.body : undefined,
108
112
  inputSchema: "inputSchema" in config ? config.inputSchema : undefined,
109
113
  shouldValidateInput: false, // No input validation in SSR context
110
114
  });
@@ -144,13 +148,11 @@ export class RequestInputContext<
144
148
  get headers(): Headers {
145
149
  return this.#headers;
146
150
  }
147
- // TODO: Should probably remove this
148
- /**
149
- * @internal
150
- */
151
- get rawBody(): RequestBodyType {
151
+
152
+ get rawBody(): string | undefined {
152
153
  return this.#body;
153
154
  }
155
+
154
156
  /**
155
157
  * Input validation context (only if inputSchema is defined)
156
158
  * @remarks `InputContext`
@@ -175,7 +177,7 @@ export class RequestInputContext<
175
177
  valid: async () => {
176
178
  if (!this.#shouldValidateInput) {
177
179
  // In SSR context, return the body directly without validation
178
- return this.#body;
180
+ return this.#parsedBody;
179
181
  }
180
182
 
181
183
  return this.#validateInput();
@@ -191,11 +193,11 @@ export class RequestInputContext<
191
193
  throw new Error("No input schema defined for this route");
192
194
  }
193
195
 
194
- if (this.#body instanceof FormData || this.#body instanceof Blob) {
196
+ if (this.#parsedBody instanceof FormData || this.#parsedBody instanceof Blob) {
195
197
  throw new Error("Schema validation is only supported for JSON data, not FormData or Blob");
196
198
  }
197
199
 
198
- const result = await this.#inputSchema["~standard"].validate(this.#body);
200
+ const result = await this.#inputSchema["~standard"].validate(this.#parsedBody);
199
201
 
200
202
  if (result.issues) {
201
203
  throw new FragnoApiValidationError("Validation failed", result.issues);
@@ -628,4 +628,255 @@ describe("Request Middleware", () => {
628
628
  custom: "middleware-value",
629
629
  });
630
630
  });
631
+
632
+ test("ifMatchesRoute properly awaits async handlers", async () => {
633
+ const fragment = defineFragment("test-lib");
634
+
635
+ const routes = [
636
+ defineRoute({
637
+ method: "POST",
638
+ path: "/users",
639
+ inputSchema: z.object({ name: z.string() }),
640
+ outputSchema: z.object({ id: z.number(), name: z.string(), verified: z.boolean() }),
641
+ handler: async ({ input }, { json }) => {
642
+ const body = await input.valid();
643
+ return json({
644
+ id: 1,
645
+ name: body.name,
646
+ verified: false,
647
+ });
648
+ },
649
+ }),
650
+ ] as const;
651
+
652
+ let asyncOperationCompleted = false;
653
+
654
+ const instance = createFragment(fragment, {}, routes, {
655
+ mountRoute: "/api",
656
+ }).withMiddleware(async ({ ifMatchesRoute }) => {
657
+ const result = await ifMatchesRoute("POST", "/users", async ({ input }) => {
658
+ // Simulate async operation (e.g., database lookup)
659
+ await new Promise((resolve) => setTimeout(resolve, 10));
660
+ const body = await input.valid();
661
+
662
+ // Verify the async operation completed
663
+ asyncOperationCompleted = true;
664
+
665
+ // Check if user exists
666
+ if (body.name === "existing-user") {
667
+ return new Response(
668
+ JSON.stringify({
669
+ message: "User already exists",
670
+ code: "USER_EXISTS",
671
+ }),
672
+ { status: 409, headers: { "Content-Type": "application/json" } },
673
+ );
674
+ }
675
+
676
+ return undefined;
677
+ });
678
+
679
+ return result;
680
+ });
681
+
682
+ // Test with existing user
683
+ const existingUserReq = new Request("http://localhost/api/users", {
684
+ method: "POST",
685
+ headers: { "Content-Type": "application/json" },
686
+ body: JSON.stringify({ name: "existing-user" }),
687
+ });
688
+
689
+ const existingUserRes = await instance.handler(existingUserReq);
690
+ expect(asyncOperationCompleted).toBe(true);
691
+ expect(existingUserRes.status).toBe(409);
692
+ expect(await existingUserRes.json()).toEqual({
693
+ message: "User already exists",
694
+ code: "USER_EXISTS",
695
+ });
696
+
697
+ // Reset flag
698
+ asyncOperationCompleted = false;
699
+
700
+ // Test with new user
701
+ const newUserReq = new Request("http://localhost/api/users", {
702
+ method: "POST",
703
+ headers: { "Content-Type": "application/json" },
704
+ body: JSON.stringify({ name: "new-user" }),
705
+ });
706
+
707
+ const newUserRes = await instance.handler(newUserReq);
708
+ expect(asyncOperationCompleted).toBe(true);
709
+ expect(newUserRes.status).toBe(200);
710
+ expect(await newUserRes.json()).toEqual({
711
+ id: 1,
712
+ name: "new-user",
713
+ verified: false,
714
+ });
715
+ });
716
+
717
+ test("ifMatchesRoute handles async errors properly", async () => {
718
+ const fragment = defineFragment("test-lib");
719
+
720
+ const routes = [
721
+ defineRoute({
722
+ method: "GET",
723
+ path: "/data",
724
+ outputSchema: z.object({ data: z.string() }),
725
+ handler: async (_, { json }) => {
726
+ return json({ data: "test" });
727
+ },
728
+ }),
729
+ ] as const;
730
+
731
+ const instance = createFragment(fragment, {}, routes, {
732
+ mountRoute: "/api",
733
+ }).withMiddleware(async ({ ifMatchesRoute }) => {
734
+ const result = await ifMatchesRoute("GET", "/data", async (_, { error }) => {
735
+ // Simulate async operation that fails
736
+ await new Promise((resolve) => setTimeout(resolve, 5));
737
+
738
+ // Simulate an error condition (e.g., database unavailable)
739
+ return error(
740
+ {
741
+ message: "Service temporarily unavailable",
742
+ code: "SERVICE_UNAVAILABLE",
743
+ },
744
+ 503,
745
+ );
746
+ });
747
+
748
+ return result;
749
+ });
750
+
751
+ const req = new Request("http://localhost/api/data", {
752
+ method: "GET",
753
+ });
754
+
755
+ const res = await instance.handler(req);
756
+ expect(res.status).toBe(503);
757
+ expect(await res.json()).toEqual({
758
+ message: "Service temporarily unavailable",
759
+ code: "SERVICE_UNAVAILABLE",
760
+ });
761
+ });
762
+
763
+ test("ifMatchesRoute with async body modification", async () => {
764
+ const fragment = defineFragment("test-lib");
765
+
766
+ const routes = [
767
+ defineRoute({
768
+ method: "POST",
769
+ path: "/posts",
770
+ inputSchema: z.object({ title: z.string(), content: z.string() }),
771
+ outputSchema: z.object({
772
+ title: z.string(),
773
+ content: z.string(),
774
+ slug: z.string(),
775
+ createdAt: z.number(),
776
+ }),
777
+ handler: async ({ input }, { json }) => {
778
+ const body = await input.valid();
779
+ return json({
780
+ title: body.title,
781
+ content: body.content,
782
+ slug: body.title.toLowerCase().replace(/\s+/g, "-"),
783
+ createdAt: Date.now(),
784
+ });
785
+ },
786
+ }),
787
+ ] as const;
788
+
789
+ const instance = createFragment(fragment, {}, routes, {
790
+ mountRoute: "/api",
791
+ }).withMiddleware(async ({ ifMatchesRoute, requestState }) => {
792
+ const result = await ifMatchesRoute("POST", "/posts", async ({ input }) => {
793
+ // Simulate async operation to enrich the body (e.g., fetching user data)
794
+ await new Promise((resolve) => setTimeout(resolve, 5));
795
+
796
+ const body = await input.valid();
797
+
798
+ // Enrich the body with additional data
799
+ requestState.setBody({
800
+ ...body,
801
+ content: `${body.content}\n\n[Enhanced by middleware]`,
802
+ });
803
+
804
+ return undefined;
805
+ });
806
+
807
+ return result;
808
+ });
809
+
810
+ const req = new Request("http://localhost/api/posts", {
811
+ method: "POST",
812
+ headers: { "Content-Type": "application/json" },
813
+ body: JSON.stringify({
814
+ title: "Test Post",
815
+ content: "Original content",
816
+ }),
817
+ });
818
+
819
+ const res = await instance.handler(req);
820
+ expect(res.status).toBe(200);
821
+
822
+ const responseBody = await res.json();
823
+ expect(responseBody).toMatchObject({
824
+ title: "Test Post",
825
+ content: "Original content\n\n[Enhanced by middleware]",
826
+ slug: "test-post",
827
+ });
828
+ expect(responseBody.createdAt).toBeTypeOf("number");
829
+ });
830
+
831
+ test("multiple async operations in ifMatchesRoute complete in order", async () => {
832
+ const fragment = defineFragment("test-lib");
833
+
834
+ const routes = [
835
+ defineRoute({
836
+ method: "GET",
837
+ path: "/status",
838
+ outputSchema: z.object({ message: z.string() }),
839
+ handler: async (_, { json }) => {
840
+ return json({ message: "OK" });
841
+ },
842
+ }),
843
+ ] as const;
844
+
845
+ const executionOrder: string[] = [];
846
+
847
+ const instance = createFragment(fragment, {}, routes, {
848
+ mountRoute: "/api",
849
+ }).withMiddleware(async ({ ifMatchesRoute }) => {
850
+ executionOrder.push("start");
851
+
852
+ const result = await ifMatchesRoute("GET", "/status", async () => {
853
+ executionOrder.push("before-first-async");
854
+ await new Promise((resolve) => setTimeout(resolve, 5));
855
+ executionOrder.push("after-first-async");
856
+
857
+ await new Promise((resolve) => setTimeout(resolve, 5));
858
+ executionOrder.push("after-second-async");
859
+
860
+ return undefined;
861
+ });
862
+
863
+ executionOrder.push("end");
864
+
865
+ return result;
866
+ });
867
+
868
+ const req = new Request("http://localhost/api/status", {
869
+ method: "GET",
870
+ });
871
+
872
+ const res = await instance.handler(req);
873
+ expect(res.status).toBe(200);
874
+ expect(executionOrder).toEqual([
875
+ "start",
876
+ "before-first-async",
877
+ "after-first-async",
878
+ "after-second-async",
879
+ "end",
880
+ ]);
881
+ });
631
882
  });
@@ -143,6 +143,6 @@ export class RequestMiddlewareInputContext<const TRoutes extends readonly AnyFra
143
143
  const outputContext = new RequestOutputContext(this.#route.outputSchema);
144
144
 
145
145
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
146
- return (handler as any)(inputContext, outputContext);
146
+ return await (handler as any)(inputContext, outputContext);
147
147
  };
148
148
  }
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import { createFragmentForTest } from "./test";
3
3
  import { defineFragment } from "../api/fragment-builder";
4
- import { defineRoute } from "../api/route";
4
+ import { defineRoute, defineRoutes } from "../api/route";
5
5
  import { z } from "zod";
6
6
 
7
7
  describe("createFragmentForTest", () => {
@@ -317,6 +317,61 @@ describe("fragment.handler", () => {
317
317
  }
318
318
  });
319
319
 
320
+ it("should handle route factory created with defineRoutes", async () => {
321
+ const fragment = defineFragment<{ apiKey: string }>("test").withServices(() => ({
322
+ getGreeting: (name: string) => `Hello, ${name}!`,
323
+ getCount: () => 42,
324
+ }));
325
+
326
+ const testFragment = createFragmentForTest(fragment, {
327
+ config: { apiKey: "test-key" },
328
+ });
329
+
330
+ type Config = { apiKey: string };
331
+ type Deps = {};
332
+ type Services = { getGreeting: (name: string) => string; getCount: () => number };
333
+
334
+ const routeFactory = defineRoutes<Config, Deps, Services>().create(({ services }) => [
335
+ defineRoute({
336
+ method: "GET",
337
+ path: "/greeting/:name",
338
+ outputSchema: z.object({ message: z.string() }),
339
+ handler: async ({ pathParams }, { json }) => {
340
+ return json({ message: services.getGreeting(pathParams.name) });
341
+ },
342
+ }),
343
+ defineRoute({
344
+ method: "GET",
345
+ path: "/count",
346
+ outputSchema: z.object({ count: z.number() }),
347
+ handler: async (_ctx, { json }) => {
348
+ return json({ count: services.getCount() });
349
+ },
350
+ }),
351
+ ]);
352
+
353
+ const routes = [routeFactory] as const;
354
+ const [greetingRoute, countRoute] = testFragment.initRoutes(routes);
355
+
356
+ // Test first route
357
+ const greetingResponse = await testFragment.handler(greetingRoute, {
358
+ pathParams: { name: "World" },
359
+ });
360
+
361
+ expect(greetingResponse.type).toBe("json");
362
+ if (greetingResponse.type === "json") {
363
+ expect(greetingResponse.data).toEqual({ message: "Hello, World!" });
364
+ }
365
+
366
+ // Test second route
367
+ const countResponse = await testFragment.handler(countRoute);
368
+
369
+ expect(countResponse.type).toBe("json");
370
+ if (countResponse.type === "json") {
371
+ expect(countResponse.data).toEqual({ count: 42 });
372
+ }
373
+ });
374
+
320
375
  it("should handle path parameters", async () => {
321
376
  const fragment = defineFragment<{}>("test");
322
377
  const testFragment = createFragmentForTest(fragment, {
package/src/test/test.ts CHANGED
@@ -357,7 +357,7 @@ export function createFragmentForTest<
357
357
  pathParams,
358
358
  searchParams,
359
359
  headers: requestHeaders,
360
- body,
360
+ parsedBody: body,
361
361
  inputSchema: route.inputSchema,
362
362
  shouldValidateInput: false, // Skip validation in tests
363
363
  });