@fragno-dev/core 0.1.11 → 0.2.2

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 (155) hide show
  1. package/.turbo/turbo-build.log +87 -69
  2. package/CHANGELOG.md +79 -0
  3. package/dist/api/api.d.ts +21 -2
  4. package/dist/api/api.d.ts.map +1 -1
  5. package/dist/api/api.js +2 -1
  6. package/dist/api/api.js.map +1 -1
  7. package/dist/api/bind-services.d.ts +0 -1
  8. package/dist/api/bind-services.d.ts.map +1 -1
  9. package/dist/api/bind-services.js.map +1 -1
  10. package/dist/api/error.d.ts.map +1 -1
  11. package/dist/api/error.js.map +1 -1
  12. package/dist/api/fragment-definition-builder.d.ts +32 -40
  13. package/dist/api/fragment-definition-builder.d.ts.map +1 -1
  14. package/dist/api/fragment-definition-builder.js +15 -21
  15. package/dist/api/fragment-definition-builder.js.map +1 -1
  16. package/dist/api/fragment-instantiator.d.ts +51 -30
  17. package/dist/api/fragment-instantiator.d.ts.map +1 -1
  18. package/dist/api/fragment-instantiator.js +201 -52
  19. package/dist/api/fragment-instantiator.js.map +1 -1
  20. package/dist/api/request-context-storage.d.ts +4 -0
  21. package/dist/api/request-context-storage.d.ts.map +1 -1
  22. package/dist/api/request-context-storage.js +6 -0
  23. package/dist/api/request-context-storage.js.map +1 -1
  24. package/dist/api/request-input-context.d.ts +57 -1
  25. package/dist/api/request-input-context.d.ts.map +1 -1
  26. package/dist/api/request-input-context.js +67 -0
  27. package/dist/api/request-input-context.js.map +1 -1
  28. package/dist/api/request-middleware.d.ts +2 -2
  29. package/dist/api/request-middleware.d.ts.map +1 -1
  30. package/dist/api/request-middleware.js.map +1 -1
  31. package/dist/api/request-output-context.d.ts +1 -1
  32. package/dist/api/request-output-context.d.ts.map +1 -1
  33. package/dist/api/request-output-context.js.map +1 -1
  34. package/dist/api/route-caller.d.ts +30 -0
  35. package/dist/api/route-caller.d.ts.map +1 -0
  36. package/dist/api/route-caller.js +63 -0
  37. package/dist/api/route-caller.js.map +1 -0
  38. package/dist/api/route-handler-input-options.d.ts.map +1 -1
  39. package/dist/api/route.d.ts +8 -8
  40. package/dist/api/route.d.ts.map +1 -1
  41. package/dist/api/route.js.map +1 -1
  42. package/dist/api/shared-types.d.ts.map +1 -1
  43. package/dist/client/client-error.d.ts.map +1 -1
  44. package/dist/client/client-error.js.map +1 -1
  45. package/dist/client/client.d.ts +90 -50
  46. package/dist/client/client.d.ts.map +1 -1
  47. package/dist/client/client.js +128 -16
  48. package/dist/client/client.js.map +1 -1
  49. package/dist/client/client.svelte.d.ts +6 -5
  50. package/dist/client/client.svelte.d.ts.map +1 -1
  51. package/dist/client/client.svelte.js +10 -2
  52. package/dist/client/client.svelte.js.map +1 -1
  53. package/dist/client/internal/ndjson-streaming.js.map +1 -1
  54. package/dist/client/react.d.ts +5 -4
  55. package/dist/client/react.d.ts.map +1 -1
  56. package/dist/client/react.js +104 -12
  57. package/dist/client/react.js.map +1 -1
  58. package/dist/client/solid.d.ts +7 -5
  59. package/dist/client/solid.d.ts.map +1 -1
  60. package/dist/client/solid.js +23 -9
  61. package/dist/client/solid.js.map +1 -1
  62. package/dist/client/vanilla.d.ts +16 -4
  63. package/dist/client/vanilla.d.ts.map +1 -1
  64. package/dist/client/vanilla.js +21 -1
  65. package/dist/client/vanilla.js.map +1 -1
  66. package/dist/client/vue.d.ts +10 -4
  67. package/dist/client/vue.d.ts.map +1 -1
  68. package/dist/client/vue.js +24 -1
  69. package/dist/client/vue.js.map +1 -1
  70. package/dist/id.d.ts +2 -0
  71. package/dist/id.js +3 -0
  72. package/dist/internal/cuid.d.ts +16 -0
  73. package/dist/internal/cuid.d.ts.map +1 -0
  74. package/dist/internal/cuid.js +82 -0
  75. package/dist/internal/cuid.js.map +1 -0
  76. package/dist/internal/trace-context.d.ts +23 -0
  77. package/dist/internal/trace-context.d.ts.map +1 -0
  78. package/dist/internal/trace-context.js +14 -0
  79. package/dist/internal/trace-context.js.map +1 -0
  80. package/dist/mod-client.d.ts +7 -20
  81. package/dist/mod-client.d.ts.map +1 -1
  82. package/dist/mod-client.js +25 -13
  83. package/dist/mod-client.js.map +1 -1
  84. package/dist/mod.d.ts +8 -6
  85. package/dist/mod.js +3 -1
  86. package/dist/runtime.d.ts +15 -0
  87. package/dist/runtime.d.ts.map +1 -0
  88. package/dist/runtime.js +33 -0
  89. package/dist/runtime.js.map +1 -0
  90. package/dist/test/test.d.ts +6 -6
  91. package/dist/test/test.d.ts.map +1 -1
  92. package/dist/test/test.js.map +1 -1
  93. package/dist/util/ssr.js.map +1 -1
  94. package/package.json +42 -52
  95. package/src/api/api.test.ts +3 -1
  96. package/src/api/api.ts +28 -0
  97. package/src/api/bind-services.ts +0 -5
  98. package/src/api/error.ts +1 -0
  99. package/src/api/fragment-definition-builder.extend.test.ts +2 -1
  100. package/src/api/fragment-definition-builder.test.ts +2 -1
  101. package/src/api/fragment-definition-builder.ts +56 -112
  102. package/src/api/fragment-instantiator.test.ts +311 -166
  103. package/src/api/fragment-instantiator.ts +470 -131
  104. package/src/api/fragment-services.test.ts +1 -0
  105. package/src/api/internal/path-runtime.test.ts +8 -0
  106. package/src/api/internal/path-type.test.ts +3 -1
  107. package/src/api/internal/route.test.ts +1 -0
  108. package/src/api/request-context-storage.ts +7 -0
  109. package/src/api/request-input-context.test.ts +156 -2
  110. package/src/api/request-input-context.ts +87 -1
  111. package/src/api/request-middleware.test.ts +43 -2
  112. package/src/api/request-middleware.ts +4 -3
  113. package/src/api/request-output-context.test.ts +3 -1
  114. package/src/api/request-output-context.ts +2 -1
  115. package/src/api/route-caller.test.ts +195 -0
  116. package/src/api/route-caller.ts +167 -0
  117. package/src/api/route-handler-input-options.ts +2 -1
  118. package/src/api/route.test.ts +4 -2
  119. package/src/api/route.ts +9 -3
  120. package/src/api/shared-types.ts +2 -1
  121. package/src/client/client-builder.test.ts +4 -2
  122. package/src/client/client-error.test.ts +2 -1
  123. package/src/client/client-error.ts +1 -1
  124. package/src/client/client-types.test.ts +19 -5
  125. package/src/client/client.ssr.test.ts +6 -4
  126. package/src/client/client.svelte.test.ts +18 -9
  127. package/src/client/client.svelte.ts +38 -13
  128. package/src/client/client.test.ts +244 -10
  129. package/src/client/client.ts +473 -148
  130. package/src/client/internal/ndjson-streaming.test.ts +6 -3
  131. package/src/client/internal/ndjson-streaming.ts +1 -0
  132. package/src/client/react.test.ts +176 -6
  133. package/src/client/react.ts +226 -31
  134. package/src/client/solid.test.ts +29 -5
  135. package/src/client/solid.ts +60 -22
  136. package/src/client/vanilla.test.ts +148 -6
  137. package/src/client/vanilla.ts +63 -9
  138. package/src/client/vue.test.ts +397 -8
  139. package/src/client/vue.ts +74 -4
  140. package/src/id.ts +1 -0
  141. package/src/internal/cuid.test.ts +164 -0
  142. package/src/internal/cuid.ts +133 -0
  143. package/src/internal/trace-context.ts +35 -0
  144. package/src/mod-client.ts +55 -9
  145. package/src/mod.ts +9 -3
  146. package/src/runtime.ts +48 -0
  147. package/src/test/test.test.ts +4 -2
  148. package/src/test/test.ts +14 -7
  149. package/src/util/async.test.ts +1 -0
  150. package/src/util/content-type.test.ts +1 -0
  151. package/src/util/nanostores.test.ts +3 -1
  152. package/src/util/ssr.ts +1 -0
  153. package/tsconfig.json +1 -1
  154. package/tsdown.config.ts +2 -0
  155. package/vitest.config.ts +2 -1
@@ -1,4 +1,5 @@
1
1
  import { describe, test, expect, expectTypeOf } from "vitest";
2
+
2
3
  import { defineFragment } from "./fragment-definition-builder";
3
4
  import { instantiate } from "./fragment-instantiator";
4
5
 
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from "vitest";
2
+
2
3
  import { buildPath, extractPathParams, matchPathParams } from "./path";
3
4
 
4
5
  describe("extractPathParams (runtime names)", () => {
@@ -77,6 +78,13 @@ describe("matchPathParams (runtime extraction)", () => {
77
78
  expect(matchPathParams("/users/:id", "/users/123/")).toEqual({ id: "123" });
78
79
  });
79
80
 
81
+ test("URL decodes named params", () => {
82
+ expect(matchPathParams("/users/:name", "/users/a%20b")).toEqual({ name: "a b" });
83
+ expect(matchPathParams("/files/:path", "/files/folder%2Fsubfolder")).toEqual({
84
+ path: "folder/subfolder",
85
+ });
86
+ });
87
+
80
88
  test("pattern longer than path fills empty strings for remaining params", () => {
81
89
  // Remaining ":id" becomes empty string
82
90
  expect(matchPathParams("/users/:id", "/users")).toEqual({ id: "" });
@@ -1,4 +1,7 @@
1
1
  import { test, expect, expectTypeOf } from "vitest";
2
+
3
+ import type { StandardSchemaV1 } from "@standard-schema/spec";
4
+
2
5
  import type {
3
6
  ExtractPathParams,
4
7
  ExtractPathParamNames,
@@ -9,7 +12,6 @@ import type {
9
12
  MaybeExtractPathParamsOrWiden,
10
13
  QueryParamsHint,
11
14
  } from "./path";
12
- import type { StandardSchemaV1 } from "@standard-schema/spec";
13
15
 
14
16
  // Type-only tests using expectTypeOf from vitest
15
17
  test("ExtractPathParams type tests", () => {
@@ -1,4 +1,5 @@
1
1
  import { test, expect } from "vitest";
2
+
2
3
  import { getMountRoute } from "./route";
3
4
 
4
5
  test("getMountRoute - default mount route", () => {
@@ -28,6 +28,13 @@ export class RequestContextStorage<TRequestStorage> {
28
28
  return this.#storage.run(data, callback);
29
29
  }
30
30
 
31
+ /**
32
+ * Check whether a store is currently active.
33
+ */
34
+ hasStore(): boolean {
35
+ return this.#storage.getStore() !== undefined;
36
+ }
37
+
31
38
  /**
32
39
  * Get the current stored data from AsyncLocalStorage.
33
40
  * @throws an error if called outside of a run() callback.
@@ -1,8 +1,10 @@
1
1
  import { test, expect, describe } from "vitest";
2
- import { RequestInputContext } from "./request-input-context";
3
- import { FragnoApiValidationError } from "./api";
2
+
4
3
  import type { StandardSchemaV1 } from "@standard-schema/spec";
4
+
5
+ import { FragnoApiValidationError } from "./api";
5
6
  import { MutableRequestState } from "./mutable-request-state";
7
+ import { RequestInputContext } from "./request-input-context";
6
8
 
7
9
  // Mock schema implementations for testing
8
10
  const createMockSchema = (shouldPass: boolean, returnValue?: unknown): StandardSchemaV1 => ({
@@ -292,6 +294,29 @@ describe("RequestContext", () => {
292
294
  );
293
295
  });
294
296
 
297
+ test("Should throw error when trying to validate ReadableStream", async () => {
298
+ const stream = new ReadableStream<Uint8Array>({
299
+ start(controller) {
300
+ controller.enqueue(new TextEncoder().encode("stream body"));
301
+ controller.close();
302
+ },
303
+ });
304
+
305
+ const ctx = new RequestInputContext({
306
+ path: "/test",
307
+ pathParams: {},
308
+ searchParams: new URLSearchParams(),
309
+ headers: new Headers(),
310
+ parsedBody: stream,
311
+ inputSchema: validStringSchema,
312
+ method: "POST",
313
+ });
314
+
315
+ await expect(ctx.input.valid()).rejects.toThrow(
316
+ "Schema validation is only supported for JSON data, not FormData, Blob, or ReadableStream",
317
+ );
318
+ });
319
+
295
320
  test("Should handle null body", async () => {
296
321
  const ctx = new RequestInputContext({
297
322
  path: "/test",
@@ -493,4 +518,133 @@ describe("RequestContext", () => {
493
518
  expect(ctx.query.get("ssr")).toBe("true");
494
519
  });
495
520
  });
521
+
522
+ describe("FormData handling", () => {
523
+ test("formData() should return FormData when body is FormData", () => {
524
+ const formData = new FormData();
525
+ formData.append("file", new Blob(["test"]), "test.txt");
526
+ formData.append("description", "A test file");
527
+
528
+ const ctx = new RequestInputContext({
529
+ path: "/upload",
530
+ pathParams: {},
531
+ searchParams: new URLSearchParams(),
532
+ headers: new Headers(),
533
+ parsedBody: formData,
534
+ method: "POST",
535
+ });
536
+
537
+ const result = ctx.formData();
538
+ expect(result).toBe(formData);
539
+ expect(result.get("description")).toBe("A test file");
540
+ });
541
+
542
+ test("formData() should throw when body is not FormData", () => {
543
+ const ctx = new RequestInputContext({
544
+ path: "/upload",
545
+ pathParams: {},
546
+ searchParams: new URLSearchParams(),
547
+ headers: new Headers(),
548
+ parsedBody: { key: "value" },
549
+ method: "POST",
550
+ });
551
+
552
+ expect(() => ctx.formData()).toThrow(
553
+ "Request body is not FormData. Ensure the request was sent with Content-Type: multipart/form-data.",
554
+ );
555
+ });
556
+
557
+ test("formData() should throw when body is undefined", () => {
558
+ const ctx = new RequestInputContext({
559
+ path: "/upload",
560
+ pathParams: {},
561
+ searchParams: new URLSearchParams(),
562
+ headers: new Headers(),
563
+ parsedBody: undefined,
564
+ method: "POST",
565
+ });
566
+
567
+ expect(() => ctx.formData()).toThrow(
568
+ "Request body is not FormData. Ensure the request was sent with Content-Type: multipart/form-data.",
569
+ );
570
+ });
571
+
572
+ test("isFormData() should return true when body is FormData", () => {
573
+ const formData = new FormData();
574
+ formData.append("key", "value");
575
+
576
+ const ctx = new RequestInputContext({
577
+ path: "/upload",
578
+ pathParams: {},
579
+ searchParams: new URLSearchParams(),
580
+ headers: new Headers(),
581
+ parsedBody: formData,
582
+ method: "POST",
583
+ });
584
+
585
+ expect(ctx.isFormData()).toBe(true);
586
+ });
587
+
588
+ test("isFormData() should return false when body is JSON", () => {
589
+ const ctx = new RequestInputContext({
590
+ path: "/upload",
591
+ pathParams: {},
592
+ searchParams: new URLSearchParams(),
593
+ headers: new Headers(),
594
+ parsedBody: { key: "value" },
595
+ method: "POST",
596
+ });
597
+
598
+ expect(ctx.isFormData()).toBe(false);
599
+ });
600
+
601
+ test("isFormData() should return false when body is undefined", () => {
602
+ const ctx = new RequestInputContext({
603
+ path: "/upload",
604
+ pathParams: {},
605
+ searchParams: new URLSearchParams(),
606
+ headers: new Headers(),
607
+ parsedBody: undefined,
608
+ method: "POST",
609
+ });
610
+
611
+ expect(ctx.isFormData()).toBe(false);
612
+ });
613
+
614
+ test("isFormData() should return false when body is Blob", () => {
615
+ const blob = new Blob(["test content"], { type: "text/plain" });
616
+
617
+ const ctx = new RequestInputContext({
618
+ path: "/upload",
619
+ pathParams: {},
620
+ searchParams: new URLSearchParams(),
621
+ headers: new Headers(),
622
+ parsedBody: blob,
623
+ method: "POST",
624
+ });
625
+
626
+ expect(ctx.isFormData()).toBe(false);
627
+ });
628
+
629
+ test("Can use isFormData() to conditionally access formData()", () => {
630
+ const formData = new FormData();
631
+ formData.append("file", new Blob(["test"]), "test.txt");
632
+
633
+ const ctx = new RequestInputContext({
634
+ path: "/upload",
635
+ pathParams: {},
636
+ searchParams: new URLSearchParams(),
637
+ headers: new Headers(),
638
+ parsedBody: formData,
639
+ method: "POST",
640
+ });
641
+
642
+ if (ctx.isFormData()) {
643
+ const result = ctx.formData();
644
+ expect(result.get("file")).toBeInstanceOf(Blob);
645
+ } else {
646
+ expect.fail("Should have detected FormData");
647
+ }
648
+ });
649
+ });
496
650
  });
@@ -1,12 +1,14 @@
1
1
  import type { StandardSchemaV1 } from "@standard-schema/spec";
2
- import type { ExtractPathParams } from "./internal/path";
2
+
3
3
  import { FragnoApiValidationError, type HTTPMethod } from "./api";
4
+ import type { ExtractPathParams } from "./internal/path";
4
5
  import type { MutableRequestState } from "./mutable-request-state";
5
6
 
6
7
  export type RequestBodyType =
7
8
  | unknown // JSON
8
9
  | FormData
9
10
  | Blob
11
+ | ReadableStream<Uint8Array>
10
12
  | null
11
13
  | undefined;
12
14
 
@@ -153,6 +155,84 @@ export class RequestInputContext<
153
155
  return this.#body;
154
156
  }
155
157
 
158
+ /**
159
+ * Access the request body as FormData.
160
+ *
161
+ * Use this method when handling file uploads or multipart form submissions.
162
+ * The request must have been sent with Content-Type: multipart/form-data.
163
+ *
164
+ * @throws Error if the request body is not FormData
165
+ *
166
+ * @example
167
+ * ```typescript
168
+ * defineRoute({
169
+ * method: "POST",
170
+ * path: "/upload",
171
+ * async handler(ctx, res) {
172
+ * const formData = ctx.formData();
173
+ * const file = formData.get("file") as File;
174
+ * const description = formData.get("description") as string;
175
+ * // ... process file
176
+ * }
177
+ * });
178
+ * ```
179
+ */
180
+ formData(): FormData {
181
+ if (!(this.#parsedBody instanceof FormData)) {
182
+ throw new Error(
183
+ "Request body is not FormData. Ensure the request was sent with Content-Type: multipart/form-data.",
184
+ );
185
+ }
186
+ return this.#parsedBody;
187
+ }
188
+
189
+ /**
190
+ * Check if the request body is FormData.
191
+ *
192
+ * Useful for routes that accept both JSON and FormData payloads.
193
+ *
194
+ * @example
195
+ * ```typescript
196
+ * defineRoute({
197
+ * method: "POST",
198
+ * path: "/upload",
199
+ * async handler(ctx, res) {
200
+ * if (ctx.isFormData()) {
201
+ * const formData = ctx.formData();
202
+ * // handle file upload
203
+ * } else {
204
+ * const json = await ctx.input.valid();
205
+ * // handle JSON payload
206
+ * }
207
+ * }
208
+ * });
209
+ * ```
210
+ */
211
+ isFormData(): boolean {
212
+ return this.#parsedBody instanceof FormData;
213
+ }
214
+
215
+ /**
216
+ * Access the request body as a ReadableStream (application/octet-stream).
217
+ *
218
+ * @throws Error if the request body is not a ReadableStream
219
+ */
220
+ bodyStream(): ReadableStream<Uint8Array> {
221
+ if (!(this.#parsedBody instanceof ReadableStream)) {
222
+ throw new Error(
223
+ "Request body is not a ReadableStream. Ensure the request was sent with Content-Type: application/octet-stream.",
224
+ );
225
+ }
226
+ return this.#parsedBody;
227
+ }
228
+
229
+ /**
230
+ * Check if the request body is a ReadableStream.
231
+ */
232
+ isBodyStream(): boolean {
233
+ return this.#parsedBody instanceof ReadableStream;
234
+ }
235
+
156
236
  /**
157
237
  * Input validation context (only if inputSchema is defined)
158
238
  * @remarks `InputContext`
@@ -197,6 +277,12 @@ export class RequestInputContext<
197
277
  throw new Error("Schema validation is only supported for JSON data, not FormData or Blob");
198
278
  }
199
279
 
280
+ if (this.#parsedBody instanceof ReadableStream) {
281
+ throw new Error(
282
+ "Schema validation is only supported for JSON data, not FormData, Blob, or ReadableStream",
283
+ );
284
+ }
285
+
200
286
  const result = await this.#inputSchema["~standard"].validate(this.#parsedBody);
201
287
 
202
288
  if (result.issues) {
@@ -1,9 +1,11 @@
1
1
  import { test, expect, describe, expectTypeOf } from "vitest";
2
+
3
+ import { z } from "zod";
4
+
5
+ import { FragnoApiValidationError } from "./error";
2
6
  import { defineFragment } from "./fragment-definition-builder";
3
7
  import { instantiate } from "./fragment-instantiator";
4
8
  import { defineRoute } from "./route";
5
- import { z } from "zod";
6
- import { FragnoApiValidationError } from "./error";
7
9
 
8
10
  describe("Request Middleware", () => {
9
11
  test("middleware can intercept and return early", async () => {
@@ -276,6 +278,45 @@ describe("Request Middleware", () => {
276
278
  expect(middlewareCalled).toBe(true);
277
279
  });
278
280
 
281
+ test("ifMatchesRoute - supports internal routes", async () => {
282
+ const config = {};
283
+
284
+ const internalRoutes = [
285
+ defineRoute({
286
+ method: "GET",
287
+ path: "/status",
288
+ handler: async (_input, { json }) => {
289
+ return json({ ok: true });
290
+ },
291
+ }),
292
+ ] as const;
293
+
294
+ const definition = defineFragment<typeof config>("test-lib")
295
+ .withInternalRoutes(internalRoutes)
296
+ .build();
297
+
298
+ const instance = instantiate(definition)
299
+ .withConfig(config)
300
+ .withOptions({
301
+ mountRoute: "/api",
302
+ })
303
+ .build()
304
+ .withMiddleware(async ({ ifMatchesRoute }) => {
305
+ return await ifMatchesRoute("GET", "/_internal/status", async ({ path }, { json }) => {
306
+ expectTypeOf(path).toEqualTypeOf<"/_internal/status">();
307
+ return json({ ok: false }, 418);
308
+ });
309
+ });
310
+
311
+ const req = new Request("http://localhost/api/_internal/status", {
312
+ method: "GET",
313
+ });
314
+
315
+ const res = await instance.handler(req);
316
+ expect(res.status).toBe(418);
317
+ expect(await res.json()).toEqual({ ok: false });
318
+ });
319
+
279
320
  test("only one middleware is supported", async () => {
280
321
  const config = {};
281
322
 
@@ -1,11 +1,12 @@
1
1
  import type { StandardSchemaV1 } from "@standard-schema/spec";
2
+
2
3
  import type { ExtractRouteByPath, ExtractRoutePath } from "../client/client";
3
4
  import type { HTTPMethod } from "./api";
4
5
  import type { ExtractPathParams } from "./internal/path";
5
- import type { AnyFragnoRouteConfig } from "./route";
6
+ import { MutableRequestState } from "./mutable-request-state";
6
7
  import { RequestInputContext } from "./request-input-context";
7
8
  import { OutputContext, RequestOutputContext } from "./request-output-context";
8
- import { MutableRequestState } from "./mutable-request-state";
9
+ import type { AnyFragnoRouteConfig } from "./route";
9
10
 
10
11
  export type FragnoMiddlewareCallback<
11
12
  TRoutes extends readonly AnyFragnoRouteConfig[],
@@ -113,7 +114,7 @@ export class RequestMiddlewareInputContext<const TRoutes extends readonly AnyFra
113
114
  // Defined as a field so that `this` reference stays in tact when destructuring
114
115
  ifMatchesRoute = async <
115
116
  const TMethod extends HTTPMethod,
116
- const TPath extends ExtractRoutePath<TRoutes>,
117
+ const TPath extends ExtractRoutePath<TRoutes, TMethod>,
117
118
  const TRoute extends ExtractRouteByPath<TRoutes, TPath, TMethod> = ExtractRouteByPath<
118
119
  TRoutes,
119
120
  TPath,
@@ -1,7 +1,9 @@
1
1
  import { test, expect, describe, vi } from "vitest";
2
- import { RequestOutputContext } from "./request-output-context";
2
+
3
3
  import type { StandardSchemaV1 } from "@standard-schema/spec";
4
+
4
5
  import { ResponseStream } from "./internal/response-stream";
6
+ import { RequestOutputContext } from "./request-output-context";
5
7
 
6
8
  // Mock schema implementations for testing
7
9
  const createMockSchema = (shouldPass: boolean, returnValue?: unknown): StandardSchemaV1 => ({
@@ -1,7 +1,8 @@
1
1
  import type { StandardSchemaV1 } from "@standard-schema/spec";
2
+
2
3
  import type { ContentlessStatusCode, StatusCode } from "../http/http-status";
3
- import { ResponseStream } from "./internal/response-stream";
4
4
  import type { InferOrUnknown } from "../util/types-util";
5
+ import { ResponseStream } from "./internal/response-stream";
5
6
 
6
7
  export type ResponseData = string | ArrayBuffer | ReadableStream | Uint8Array<ArrayBuffer>;
7
8
 
@@ -0,0 +1,195 @@
1
+ import { describe, expect, expectTypeOf, test } from "vitest";
2
+
3
+ import { z } from "zod";
4
+
5
+ import type { FragnoResponse } from "./fragno-response";
6
+ import { defineRoute } from "./route";
7
+ import { createRouteCaller } from "./route-caller";
8
+
9
+ describe("createRouteCaller", () => {
10
+ test("replaces inherited content-type with application/json for object bodies", async () => {
11
+ let capturedRequest: Request | null = null;
12
+
13
+ const callRoute = createRouteCaller<{
14
+ callRoute: (
15
+ method: "POST",
16
+ path: "/test",
17
+ input: { body: { hello: string } },
18
+ ) => Promise<unknown>;
19
+ }>({
20
+ baseUrl: "https://example.com/app",
21
+ baseHeaders: {
22
+ "content-type": "application/x-www-form-urlencoded",
23
+ },
24
+ fetch: async (request) => {
25
+ capturedRequest = request;
26
+ return new Response(JSON.stringify({ ok: true }), {
27
+ headers: { "content-type": "application/json" },
28
+ });
29
+ },
30
+ });
31
+
32
+ await callRoute("POST", "/test", {
33
+ body: { hello: "world" },
34
+ });
35
+
36
+ if (!capturedRequest) {
37
+ throw new Error("Expected fetch to receive a request.");
38
+ }
39
+
40
+ const request = capturedRequest as Request;
41
+ expect(request.headers.get("content-type")).toBe("application/json");
42
+ expect(await request.text()).toBe('{"hello":"world"}');
43
+ });
44
+
45
+ test("keeps an explicit application/octet-stream content-type for binary bodies", async () => {
46
+ let capturedRequest: Request | null = null;
47
+
48
+ const callRoute = createRouteCaller<{
49
+ callRoute: (
50
+ method: "POST",
51
+ path: "/binary",
52
+ input: { body: ArrayBuffer; headers: HeadersInit },
53
+ ) => Promise<unknown>;
54
+ }>({
55
+ baseUrl: "https://example.com/app",
56
+ baseHeaders: {
57
+ "content-type": "multipart/form-data; boundary=---original",
58
+ },
59
+ fetch: async (request) => {
60
+ capturedRequest = request;
61
+ return new Response(JSON.stringify({ ok: true }), {
62
+ headers: { "content-type": "application/json" },
63
+ });
64
+ },
65
+ });
66
+
67
+ await callRoute("POST", "/binary", {
68
+ body: new Uint8Array([1, 2, 3]).buffer,
69
+ headers: {
70
+ "content-type": "application/octet-stream",
71
+ },
72
+ });
73
+
74
+ if (!capturedRequest) {
75
+ throw new Error("Expected fetch to receive a request.");
76
+ }
77
+
78
+ const request = capturedRequest as Request;
79
+ expect(request.headers.get("content-type")).toBe("application/octet-stream");
80
+ expect(new Uint8Array(await request.arrayBuffer())).toEqual(new Uint8Array([1, 2, 3]));
81
+ });
82
+
83
+ test("infers route-specific inputs and outputs from fragment routes", async () => {
84
+ const routes = [
85
+ defineRoute({
86
+ method: "GET",
87
+ path: "/threads",
88
+ outputSchema: z.object({
89
+ threads: z.array(z.object({ id: z.string() })),
90
+ hasNextPage: z.boolean(),
91
+ }),
92
+ handler: async (_ctx, { json }) =>
93
+ json({ threads: [{ id: "thread-1" }], hasNextPage: false }),
94
+ }),
95
+ defineRoute({
96
+ method: "GET",
97
+ path: "/threads/:threadId/messages",
98
+ outputSchema: z.object({
99
+ messages: z.array(z.object({ id: z.string(), threadId: z.string() })),
100
+ cursor: z.string().optional(),
101
+ hasNextPage: z.boolean(),
102
+ }),
103
+ handler: async ({ pathParams, query }, { json }) =>
104
+ json({
105
+ messages: [{ id: "message-1", threadId: pathParams.threadId }],
106
+ cursor: query.get("cursor") ?? undefined,
107
+ hasNextPage: false,
108
+ }),
109
+ }),
110
+ defineRoute({
111
+ method: "POST",
112
+ path: "/threads/:threadId/reply",
113
+ inputSchema: z.object({ text: z.string() }),
114
+ outputSchema: z.object({ ok: z.boolean() }),
115
+ handler: async (_ctx, { json }) => json({ ok: true }),
116
+ }),
117
+ ] as const;
118
+
119
+ type FakeFragment = {
120
+ routes: typeof routes;
121
+ };
122
+
123
+ const callRoute = createRouteCaller<FakeFragment>({
124
+ baseUrl: "https://example.com/app",
125
+ mountRoute: "/api",
126
+ fetch: async (request) => {
127
+ const url = new URL(request.url);
128
+
129
+ if (url.pathname === "/api/threads") {
130
+ return Response.json({
131
+ threads: [{ id: "thread-1" }],
132
+ hasNextPage: false,
133
+ });
134
+ }
135
+
136
+ if (url.pathname === "/api/threads/thread-1/messages") {
137
+ return Response.json({
138
+ messages: [{ id: "message-1", threadId: "thread-1" }],
139
+ cursor: url.searchParams.get("cursor") ?? undefined,
140
+ hasNextPage: false,
141
+ });
142
+ }
143
+
144
+ if (url.pathname === "/api/threads/thread-1/reply") {
145
+ const body = (await request.json()) as { text: string };
146
+ return Response.json({ ok: body.text === "hello" });
147
+ }
148
+
149
+ return Response.json({ message: "Not found", code: "NOT_FOUND" }, { status: 404 });
150
+ },
151
+ });
152
+
153
+ const listResponse = await callRoute("GET", "/threads", {
154
+ query: { cursor: "cursor-1" },
155
+ });
156
+ const messagesResponse = await callRoute("GET", "/threads/:threadId/messages", {
157
+ pathParams: { threadId: "thread-1" },
158
+ query: { cursor: "cursor-1" },
159
+ });
160
+ const replyResponse = await callRoute("POST", "/threads/:threadId/reply", {
161
+ pathParams: { threadId: "thread-1" },
162
+ body: { text: "hello" },
163
+ });
164
+
165
+ expectTypeOf<Extract<typeof listResponse, { type: "json" }>["data"]>().toEqualTypeOf<{
166
+ threads: { id: string }[];
167
+ hasNextPage: boolean;
168
+ }>();
169
+ expectTypeOf<Extract<typeof messagesResponse, { type: "json" }>["data"]>().toEqualTypeOf<{
170
+ messages: { id: string; threadId: string }[];
171
+ cursor?: string | undefined;
172
+ hasNextPage: boolean;
173
+ }>();
174
+ expectTypeOf<Extract<typeof replyResponse, { type: "json" }>["data"]>().toEqualTypeOf<{
175
+ ok: boolean;
176
+ }>();
177
+ expectTypeOf<typeof listResponse>().toExtend<FragnoResponse<unknown>>();
178
+
179
+ expect(listResponse.type).toBe("json");
180
+ if (listResponse.type === "json") {
181
+ expect(listResponse.data.threads[0]?.id).toBe("thread-1");
182
+ }
183
+
184
+ expect(messagesResponse.type).toBe("json");
185
+ if (messagesResponse.type === "json") {
186
+ expect(messagesResponse.data.messages[0]?.threadId).toBe("thread-1");
187
+ expect(messagesResponse.data.cursor).toBe("cursor-1");
188
+ }
189
+
190
+ expect(replyResponse.type).toBe("json");
191
+ if (replyResponse.type === "json") {
192
+ expect(replyResponse.data.ok).toBe(true);
193
+ }
194
+ });
195
+ });