@fragno-dev/core 0.1.10 → 0.2.0

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 (69) hide show
  1. package/.turbo/turbo-build.log +139 -131
  2. package/CHANGELOG.md +63 -0
  3. package/dist/api/api.d.ts +23 -5
  4. package/dist/api/api.d.ts.map +1 -1
  5. package/dist/api/api.js.map +1 -1
  6. package/dist/api/fragment-definition-builder.d.ts +17 -7
  7. package/dist/api/fragment-definition-builder.d.ts.map +1 -1
  8. package/dist/api/fragment-definition-builder.js +3 -2
  9. package/dist/api/fragment-definition-builder.js.map +1 -1
  10. package/dist/api/fragment-instantiator.d.ts +129 -32
  11. package/dist/api/fragment-instantiator.d.ts.map +1 -1
  12. package/dist/api/fragment-instantiator.js +232 -50
  13. package/dist/api/fragment-instantiator.js.map +1 -1
  14. package/dist/api/request-input-context.d.ts +57 -1
  15. package/dist/api/request-input-context.d.ts.map +1 -1
  16. package/dist/api/request-input-context.js +67 -0
  17. package/dist/api/request-input-context.js.map +1 -1
  18. package/dist/api/request-middleware.d.ts +1 -1
  19. package/dist/api/request-middleware.d.ts.map +1 -1
  20. package/dist/api/request-middleware.js.map +1 -1
  21. package/dist/api/route.d.ts +7 -7
  22. package/dist/api/route.d.ts.map +1 -1
  23. package/dist/api/route.js.map +1 -1
  24. package/dist/client/client.d.ts +4 -3
  25. package/dist/client/client.d.ts.map +1 -1
  26. package/dist/client/client.js +103 -7
  27. package/dist/client/client.js.map +1 -1
  28. package/dist/client/vue.d.ts +7 -3
  29. package/dist/client/vue.d.ts.map +1 -1
  30. package/dist/client/vue.js +16 -1
  31. package/dist/client/vue.js.map +1 -1
  32. package/dist/internal/trace-context.d.ts +23 -0
  33. package/dist/internal/trace-context.d.ts.map +1 -0
  34. package/dist/internal/trace-context.js +14 -0
  35. package/dist/internal/trace-context.js.map +1 -0
  36. package/dist/mod-client.d.ts +5 -27
  37. package/dist/mod-client.d.ts.map +1 -1
  38. package/dist/mod-client.js +50 -13
  39. package/dist/mod-client.js.map +1 -1
  40. package/dist/mod.d.ts +4 -3
  41. package/dist/mod.js +2 -1
  42. package/dist/runtime.d.ts +15 -0
  43. package/dist/runtime.d.ts.map +1 -0
  44. package/dist/runtime.js +33 -0
  45. package/dist/runtime.js.map +1 -0
  46. package/dist/test/test.d.ts +2 -2
  47. package/dist/test/test.d.ts.map +1 -1
  48. package/dist/test/test.js.map +1 -1
  49. package/package.json +31 -18
  50. package/src/api/api.ts +24 -0
  51. package/src/api/fragment-definition-builder.ts +36 -17
  52. package/src/api/fragment-instantiator.test.ts +429 -1
  53. package/src/api/fragment-instantiator.ts +572 -58
  54. package/src/api/internal/path-runtime.test.ts +7 -0
  55. package/src/api/request-input-context.test.ts +152 -0
  56. package/src/api/request-input-context.ts +85 -0
  57. package/src/api/request-middleware.test.ts +47 -1
  58. package/src/api/request-middleware.ts +1 -1
  59. package/src/api/route.ts +7 -2
  60. package/src/client/client.test.ts +195 -0
  61. package/src/client/client.ts +185 -10
  62. package/src/client/vue.test.ts +253 -3
  63. package/src/client/vue.ts +44 -1
  64. package/src/internal/trace-context.ts +35 -0
  65. package/src/mod-client.ts +89 -9
  66. package/src/mod.ts +7 -1
  67. package/src/runtime.ts +48 -0
  68. package/src/test/test.ts +13 -4
  69. package/tsdown.config.ts +1 -0
@@ -77,6 +77,13 @@ describe("matchPathParams (runtime extraction)", () => {
77
77
  expect(matchPathParams("/users/:id", "/users/123/")).toEqual({ id: "123" });
78
78
  });
79
79
 
80
+ test("URL decodes named params", () => {
81
+ expect(matchPathParams("/users/:name", "/users/a%20b")).toEqual({ name: "a b" });
82
+ expect(matchPathParams("/files/:path", "/files/folder%2Fsubfolder")).toEqual({
83
+ path: "folder/subfolder",
84
+ });
85
+ });
86
+
80
87
  test("pattern longer than path fills empty strings for remaining params", () => {
81
88
  // Remaining ":id" becomes empty string
82
89
  expect(matchPathParams("/users/:id", "/users")).toEqual({ id: "" });
@@ -292,6 +292,29 @@ describe("RequestContext", () => {
292
292
  );
293
293
  });
294
294
 
295
+ test("Should throw error when trying to validate ReadableStream", async () => {
296
+ const stream = new ReadableStream<Uint8Array>({
297
+ start(controller) {
298
+ controller.enqueue(new TextEncoder().encode("stream body"));
299
+ controller.close();
300
+ },
301
+ });
302
+
303
+ const ctx = new RequestInputContext({
304
+ path: "/test",
305
+ pathParams: {},
306
+ searchParams: new URLSearchParams(),
307
+ headers: new Headers(),
308
+ parsedBody: stream,
309
+ inputSchema: validStringSchema,
310
+ method: "POST",
311
+ });
312
+
313
+ await expect(ctx.input.valid()).rejects.toThrow(
314
+ "Schema validation is only supported for JSON data, not FormData, Blob, or ReadableStream",
315
+ );
316
+ });
317
+
295
318
  test("Should handle null body", async () => {
296
319
  const ctx = new RequestInputContext({
297
320
  path: "/test",
@@ -493,4 +516,133 @@ describe("RequestContext", () => {
493
516
  expect(ctx.query.get("ssr")).toBe("true");
494
517
  });
495
518
  });
519
+
520
+ describe("FormData handling", () => {
521
+ test("formData() should return FormData when body is FormData", () => {
522
+ const formData = new FormData();
523
+ formData.append("file", new Blob(["test"]), "test.txt");
524
+ formData.append("description", "A test file");
525
+
526
+ const ctx = new RequestInputContext({
527
+ path: "/upload",
528
+ pathParams: {},
529
+ searchParams: new URLSearchParams(),
530
+ headers: new Headers(),
531
+ parsedBody: formData,
532
+ method: "POST",
533
+ });
534
+
535
+ const result = ctx.formData();
536
+ expect(result).toBe(formData);
537
+ expect(result.get("description")).toBe("A test file");
538
+ });
539
+
540
+ test("formData() should throw when body is not FormData", () => {
541
+ const ctx = new RequestInputContext({
542
+ path: "/upload",
543
+ pathParams: {},
544
+ searchParams: new URLSearchParams(),
545
+ headers: new Headers(),
546
+ parsedBody: { key: "value" },
547
+ method: "POST",
548
+ });
549
+
550
+ expect(() => ctx.formData()).toThrow(
551
+ "Request body is not FormData. Ensure the request was sent with Content-Type: multipart/form-data.",
552
+ );
553
+ });
554
+
555
+ test("formData() should throw when body is undefined", () => {
556
+ const ctx = new RequestInputContext({
557
+ path: "/upload",
558
+ pathParams: {},
559
+ searchParams: new URLSearchParams(),
560
+ headers: new Headers(),
561
+ parsedBody: undefined,
562
+ method: "POST",
563
+ });
564
+
565
+ expect(() => ctx.formData()).toThrow(
566
+ "Request body is not FormData. Ensure the request was sent with Content-Type: multipart/form-data.",
567
+ );
568
+ });
569
+
570
+ test("isFormData() should return true when body is FormData", () => {
571
+ const formData = new FormData();
572
+ formData.append("key", "value");
573
+
574
+ const ctx = new RequestInputContext({
575
+ path: "/upload",
576
+ pathParams: {},
577
+ searchParams: new URLSearchParams(),
578
+ headers: new Headers(),
579
+ parsedBody: formData,
580
+ method: "POST",
581
+ });
582
+
583
+ expect(ctx.isFormData()).toBe(true);
584
+ });
585
+
586
+ test("isFormData() should return false when body is JSON", () => {
587
+ const ctx = new RequestInputContext({
588
+ path: "/upload",
589
+ pathParams: {},
590
+ searchParams: new URLSearchParams(),
591
+ headers: new Headers(),
592
+ parsedBody: { key: "value" },
593
+ method: "POST",
594
+ });
595
+
596
+ expect(ctx.isFormData()).toBe(false);
597
+ });
598
+
599
+ test("isFormData() should return false when body is undefined", () => {
600
+ const ctx = new RequestInputContext({
601
+ path: "/upload",
602
+ pathParams: {},
603
+ searchParams: new URLSearchParams(),
604
+ headers: new Headers(),
605
+ parsedBody: undefined,
606
+ method: "POST",
607
+ });
608
+
609
+ expect(ctx.isFormData()).toBe(false);
610
+ });
611
+
612
+ test("isFormData() should return false when body is Blob", () => {
613
+ const blob = new Blob(["test content"], { type: "text/plain" });
614
+
615
+ const ctx = new RequestInputContext({
616
+ path: "/upload",
617
+ pathParams: {},
618
+ searchParams: new URLSearchParams(),
619
+ headers: new Headers(),
620
+ parsedBody: blob,
621
+ method: "POST",
622
+ });
623
+
624
+ expect(ctx.isFormData()).toBe(false);
625
+ });
626
+
627
+ test("Can use isFormData() to conditionally access formData()", () => {
628
+ const formData = new FormData();
629
+ formData.append("file", new Blob(["test"]), "test.txt");
630
+
631
+ const ctx = new RequestInputContext({
632
+ path: "/upload",
633
+ pathParams: {},
634
+ searchParams: new URLSearchParams(),
635
+ headers: new Headers(),
636
+ parsedBody: formData,
637
+ method: "POST",
638
+ });
639
+
640
+ if (ctx.isFormData()) {
641
+ const result = ctx.formData();
642
+ expect(result.get("file")).toBeInstanceOf(Blob);
643
+ } else {
644
+ expect.fail("Should have detected FormData");
645
+ }
646
+ });
647
+ });
496
648
  });
@@ -7,6 +7,7 @@ export type RequestBodyType =
7
7
  | unknown // JSON
8
8
  | FormData
9
9
  | Blob
10
+ | ReadableStream<Uint8Array>
10
11
  | null
11
12
  | undefined;
12
13
 
@@ -153,6 +154,84 @@ export class RequestInputContext<
153
154
  return this.#body;
154
155
  }
155
156
 
157
+ /**
158
+ * Access the request body as FormData.
159
+ *
160
+ * Use this method when handling file uploads or multipart form submissions.
161
+ * The request must have been sent with Content-Type: multipart/form-data.
162
+ *
163
+ * @throws Error if the request body is not FormData
164
+ *
165
+ * @example
166
+ * ```typescript
167
+ * defineRoute({
168
+ * method: "POST",
169
+ * path: "/upload",
170
+ * async handler(ctx, res) {
171
+ * const formData = ctx.formData();
172
+ * const file = formData.get("file") as File;
173
+ * const description = formData.get("description") as string;
174
+ * // ... process file
175
+ * }
176
+ * });
177
+ * ```
178
+ */
179
+ formData(): FormData {
180
+ if (!(this.#parsedBody instanceof FormData)) {
181
+ throw new Error(
182
+ "Request body is not FormData. Ensure the request was sent with Content-Type: multipart/form-data.",
183
+ );
184
+ }
185
+ return this.#parsedBody;
186
+ }
187
+
188
+ /**
189
+ * Check if the request body is FormData.
190
+ *
191
+ * Useful for routes that accept both JSON and FormData payloads.
192
+ *
193
+ * @example
194
+ * ```typescript
195
+ * defineRoute({
196
+ * method: "POST",
197
+ * path: "/upload",
198
+ * async handler(ctx, res) {
199
+ * if (ctx.isFormData()) {
200
+ * const formData = ctx.formData();
201
+ * // handle file upload
202
+ * } else {
203
+ * const json = await ctx.input.valid();
204
+ * // handle JSON payload
205
+ * }
206
+ * }
207
+ * });
208
+ * ```
209
+ */
210
+ isFormData(): boolean {
211
+ return this.#parsedBody instanceof FormData;
212
+ }
213
+
214
+ /**
215
+ * Access the request body as a ReadableStream (application/octet-stream).
216
+ *
217
+ * @throws Error if the request body is not a ReadableStream
218
+ */
219
+ bodyStream(): ReadableStream<Uint8Array> {
220
+ if (!(this.#parsedBody instanceof ReadableStream)) {
221
+ throw new Error(
222
+ "Request body is not a ReadableStream. Ensure the request was sent with Content-Type: application/octet-stream.",
223
+ );
224
+ }
225
+ return this.#parsedBody;
226
+ }
227
+
228
+ /**
229
+ * Check if the request body is a ReadableStream.
230
+ */
231
+ isBodyStream(): boolean {
232
+ return this.#parsedBody instanceof ReadableStream;
233
+ }
234
+
156
235
  /**
157
236
  * Input validation context (only if inputSchema is defined)
158
237
  * @remarks `InputContext`
@@ -197,6 +276,12 @@ export class RequestInputContext<
197
276
  throw new Error("Schema validation is only supported for JSON data, not FormData or Blob");
198
277
  }
199
278
 
279
+ if (this.#parsedBody instanceof ReadableStream) {
280
+ throw new Error(
281
+ "Schema validation is only supported for JSON data, not FormData, Blob, or ReadableStream",
282
+ );
283
+ }
284
+
200
285
  const result = await this.#inputSchema["~standard"].validate(this.#parsedBody);
201
286
 
202
287
  if (result.issues) {
@@ -1,7 +1,7 @@
1
1
  import { test, expect, describe, expectTypeOf } from "vitest";
2
2
  import { defineFragment } from "./fragment-definition-builder";
3
3
  import { instantiate } from "./fragment-instantiator";
4
- import { defineRoute } from "./route";
4
+ import { defineRoute, defineRoutes } from "./route";
5
5
  import { z } from "zod";
6
6
  import { FragnoApiValidationError } from "./error";
7
7
 
@@ -276,6 +276,52 @@ describe("Request Middleware", () => {
276
276
  expect(middlewareCalled).toBe(true);
277
277
  });
278
278
 
279
+ test("ifMatchesRoute - supports internal linked fragment routes", async () => {
280
+ const config = {};
281
+
282
+ const internalDef = defineFragment("internal").build();
283
+ const internalRoutes = defineRoutes(internalDef).create(({ defineRoute }) => [
284
+ defineRoute({
285
+ method: "GET",
286
+ path: "/status",
287
+ handler: async (_input, { json }) => {
288
+ return json({ ok: true });
289
+ },
290
+ }),
291
+ ]);
292
+
293
+ const definition = defineFragment<typeof config>("test-lib")
294
+ .withLinkedFragment("_fragno_internal", ({ config, options }) => {
295
+ return instantiate(internalDef)
296
+ .withConfig(config)
297
+ .withOptions(options)
298
+ .withRoutes([internalRoutes])
299
+ .build();
300
+ })
301
+ .build();
302
+
303
+ const instance = instantiate(definition)
304
+ .withConfig(config)
305
+ .withOptions({
306
+ mountRoute: "/api",
307
+ })
308
+ .build()
309
+ .withMiddleware(async ({ ifMatchesRoute }) => {
310
+ return await ifMatchesRoute("GET", "/_internal/status", async ({ path }, { json }) => {
311
+ expectTypeOf(path).toEqualTypeOf<"/_internal/status">();
312
+ return json({ ok: false }, 418);
313
+ });
314
+ });
315
+
316
+ const req = new Request("http://localhost/api/_internal/status", {
317
+ method: "GET",
318
+ });
319
+
320
+ const res = await instance.handler(req);
321
+ expect(res.status).toBe(418);
322
+ expect(await res.json()).toEqual({ ok: false });
323
+ });
324
+
279
325
  test("only one middleware is supported", async () => {
280
326
  const config = {};
281
327
 
@@ -113,7 +113,7 @@ export class RequestMiddlewareInputContext<const TRoutes extends readonly AnyFra
113
113
  // Defined as a field so that `this` reference stays in tact when destructuring
114
114
  ifMatchesRoute = async <
115
115
  const TMethod extends HTTPMethod,
116
- const TPath extends ExtractRoutePath<TRoutes>,
116
+ const TPath extends ExtractRoutePath<TRoutes, TMethod>,
117
117
  const TRoute extends ExtractRouteByPath<TRoutes, TPath, TMethod> = ExtractRouteByPath<
118
118
  TRoutes,
119
119
  TPath,
package/src/api/route.ts CHANGED
@@ -182,18 +182,19 @@ export type AnyFragmentDefinition = FragmentDefinition<
182
182
  any,
183
183
  any,
184
184
  any,
185
+ any,
185
186
  any
186
187
  >;
187
188
 
188
189
  // Extract config from FragmentDefinition
189
190
  export type ExtractFragmentConfig<T> =
190
- T extends FragmentDefinition<infer TConfig, any, any, any, any, any, any, any, any, any>
191
+ T extends FragmentDefinition<infer TConfig, any, any, any, any, any, any, any, any, any, any>
191
192
  ? TConfig
192
193
  : never;
193
194
 
194
195
  // Extract deps from FragmentDefinition
195
196
  export type ExtractFragmentDeps<T> =
196
- T extends FragmentDefinition<any, any, infer TDeps, any, any, any, any, any, any, any>
197
+ T extends FragmentDefinition<any, any, infer TDeps, any, any, any, any, any, any, any, any>
197
198
  ? TDeps
198
199
  : never;
199
200
 
@@ -211,6 +212,7 @@ export type ExtractFragmentServices<T> =
211
212
  any,
212
213
  any,
213
214
  any,
215
+ any,
214
216
  any
215
217
  >
216
218
  ? BoundServices<TBaseServices & TServices>
@@ -228,6 +230,7 @@ export type ExtractFragmentServiceDeps<T> =
228
230
  any,
229
231
  any,
230
232
  any,
233
+ any,
231
234
  any
232
235
  >
233
236
  ? TServiceDependencies
@@ -245,6 +248,7 @@ export type ExtractFragmentServiceThisContext<T> =
245
248
  any,
246
249
  infer TServiceThisContext,
247
250
  any,
251
+ any,
248
252
  any
249
253
  >
250
254
  ? TServiceThisContext
@@ -262,6 +266,7 @@ export type ExtractFragmentHandlerThisContext<T> =
262
266
  any,
263
267
  any,
264
268
  infer THandlerThisContext,
269
+ any,
265
270
  any
266
271
  >
267
272
  ? THandlerThisContext
@@ -111,6 +111,59 @@ describe("getCacheKey", () => {
111
111
  });
112
112
  });
113
113
 
114
+ describe("FormData utilities", () => {
115
+ // These tests verify the internal FormData handling behavior
116
+
117
+ test("prepareRequestBody should JSON-stringify regular objects", () => {
118
+ // We can't test internal functions directly, but we can test through the mutator behavior
119
+ // This is a placeholder to document expected behavior
120
+ const body = { name: "test", value: 123 };
121
+ expect(JSON.stringify(body)).toBe('{"name":"test","value":123}');
122
+ });
123
+
124
+ test("FormData should be detected correctly", () => {
125
+ const formData = new FormData();
126
+ formData.append("file", new Blob(["test"]), "test.txt");
127
+
128
+ expect(formData instanceof FormData).toBe(true);
129
+ expect({} instanceof FormData).toBe(false);
130
+ // Note: null instanceof X is a TS error, so we test with a nullable variable
131
+ const nullValue: unknown = null;
132
+ expect(nullValue instanceof FormData).toBe(false);
133
+ });
134
+
135
+ test("File and Blob should be detected correctly", () => {
136
+ const file = new File(["content"], "test.txt", { type: "text/plain" });
137
+ const blob = new Blob(["content"], { type: "text/plain" });
138
+
139
+ expect(file instanceof File).toBe(true);
140
+ expect(file instanceof Blob).toBe(true);
141
+ expect(blob instanceof Blob).toBe(true);
142
+ expect(blob instanceof File).toBe(false);
143
+ });
144
+
145
+ test("toFormData should convert object with files to FormData", () => {
146
+ const file = new File(["content"], "test.txt", { type: "text/plain" });
147
+ const formData = new FormData();
148
+ formData.append("file", file, file.name);
149
+ formData.append("description", "A test file");
150
+
151
+ expect(formData.get("file")).toBeInstanceOf(File);
152
+ expect(formData.get("description")).toBe("A test file");
153
+ });
154
+
155
+ test("FormData can contain multiple files", () => {
156
+ const file1 = new File(["content1"], "test1.txt", { type: "text/plain" });
157
+ const file2 = new File(["content2"], "test2.txt", { type: "text/plain" });
158
+ const formData = new FormData();
159
+ formData.append("files", file1, file1.name);
160
+ formData.append("files", file2, file2.name);
161
+
162
+ const files = formData.getAll("files");
163
+ expect(files).toHaveLength(2);
164
+ });
165
+ });
166
+
114
167
  describe("invalidation", () => {
115
168
  const testFragment = defineFragment("test-fragment").build();
116
169
  const testRoutes = [
@@ -1271,6 +1324,99 @@ describe("createMutator", () => {
1271
1324
 
1272
1325
  expect(result).toBeUndefined();
1273
1326
  });
1327
+
1328
+ test("should send octet-stream body without wrapping", async () => {
1329
+ const testFragment = defineFragment("test-fragment").build();
1330
+ const testRoutes = [
1331
+ defineRoute({
1332
+ method: "PUT",
1333
+ path: "/upload",
1334
+ contentType: "application/octet-stream",
1335
+ inputSchema: z.unknown(),
1336
+ handler: async (_ctx, { empty }) => empty(),
1337
+ }),
1338
+ ] as const;
1339
+
1340
+ vi.mocked(global.fetch).mockImplementation(async () => {
1341
+ return new Response(null, { status: 204 });
1342
+ });
1343
+
1344
+ const cb = createClientBuilder(testFragment, clientConfig, testRoutes);
1345
+ const upload = cb.createMutator("PUT", "/upload");
1346
+ const body = new Uint8Array([1, 2, 3, 4]);
1347
+
1348
+ await upload.mutateQuery({ body });
1349
+
1350
+ const [_url, options] = vi.mocked(global.fetch).mock.calls[0];
1351
+ const headers = options?.headers as Record<string, string> | undefined;
1352
+
1353
+ expect(headers?.["Content-Type"]).toBe("application/octet-stream");
1354
+ expect(options?.body).toBe(body);
1355
+ });
1356
+
1357
+ test("should send ReadableStream body with duplex for octet-stream", async () => {
1358
+ const testFragment = defineFragment("test-fragment").build();
1359
+ const testRoutes = [
1360
+ defineRoute({
1361
+ method: "PUT",
1362
+ path: "/upload",
1363
+ contentType: "application/octet-stream",
1364
+ inputSchema: z.unknown(),
1365
+ handler: async (_ctx, { empty }) => empty(),
1366
+ }),
1367
+ ] as const;
1368
+
1369
+ let capturedOptions: (RequestInit & { duplex?: "half" }) | undefined;
1370
+ let capturedBodyText = "";
1371
+
1372
+ vi.mocked(global.fetch).mockImplementation(async (_url, options) => {
1373
+ capturedOptions = options as RequestInit & { duplex?: "half" };
1374
+ const body = capturedOptions?.body;
1375
+
1376
+ if (!(body instanceof ReadableStream)) {
1377
+ throw new Error("Expected ReadableStream body");
1378
+ }
1379
+
1380
+ const reader = body.getReader();
1381
+ const chunks: Uint8Array[] = [];
1382
+ while (true) {
1383
+ const { done, value } = await reader.read();
1384
+ if (done) {
1385
+ break;
1386
+ }
1387
+ if (value) {
1388
+ chunks.push(value);
1389
+ }
1390
+ }
1391
+
1392
+ const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
1393
+ const combined = new Uint8Array(totalLength);
1394
+ let offset = 0;
1395
+ for (const chunk of chunks) {
1396
+ combined.set(chunk, offset);
1397
+ offset += chunk.length;
1398
+ }
1399
+ capturedBodyText = new TextDecoder().decode(combined);
1400
+
1401
+ return new Response(null, { status: 204 });
1402
+ });
1403
+
1404
+ const cb = createClientBuilder(testFragment, clientConfig, testRoutes);
1405
+ const upload = cb.createMutator("PUT", "/upload");
1406
+ const body = new ReadableStream<Uint8Array>({
1407
+ start(controller) {
1408
+ controller.enqueue(new TextEncoder().encode("streamed body"));
1409
+ controller.close();
1410
+ },
1411
+ });
1412
+
1413
+ await upload.mutateQuery({ body });
1414
+
1415
+ const headers = capturedOptions?.headers as Record<string, string> | undefined;
1416
+ expect(headers?.["Content-Type"]).toBe("application/octet-stream");
1417
+ expect(capturedOptions?.duplex).toBe("half");
1418
+ expect(capturedBodyText).toBe("streamed body");
1419
+ });
1274
1420
  });
1275
1421
 
1276
1422
  describe("createMutator - streaming", () => {
@@ -1997,6 +2143,55 @@ describe("Custom Fetcher Configuration", () => {
1997
2143
  expect(url4).toBe("http://localhost:3000/api/test-fragment/users/456?include=posts");
1998
2144
  });
1999
2145
 
2146
+ test("public mountRoute is used for hooks", async () => {
2147
+ let capturedUrl: string | undefined;
2148
+
2149
+ (global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async (url) => {
2150
+ capturedUrl = String(url);
2151
+ return {
2152
+ headers: new Headers(),
2153
+ ok: true,
2154
+ json: async () => [{ id: 1, name: "John" }],
2155
+ } as Response;
2156
+ });
2157
+
2158
+ const client = createClientBuilder(
2159
+ testFragment,
2160
+ { ...clientConfig, mountRoute: "/api/uploads-direct" },
2161
+ testRoutes,
2162
+ );
2163
+
2164
+ const useUsers = client.createHook("/users");
2165
+ await useUsers.query();
2166
+
2167
+ expect(capturedUrl).toBe("http://localhost:3000/api/uploads-direct/users");
2168
+ });
2169
+
2170
+ test("public mountRoute is used for mutators", async () => {
2171
+ let capturedUrl: string | undefined;
2172
+
2173
+ (global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async (url) => {
2174
+ capturedUrl = String(url);
2175
+ return {
2176
+ headers: new Headers(),
2177
+ ok: true,
2178
+ status: 200,
2179
+ json: async () => ({ id: 2, name: "Jane" }),
2180
+ } as Response;
2181
+ });
2182
+
2183
+ const client = createClientBuilder(
2184
+ testFragment,
2185
+ { ...clientConfig, mountRoute: "/api/uploads-proxy" },
2186
+ testRoutes,
2187
+ );
2188
+
2189
+ const mutator = client.createMutator("POST", "/users");
2190
+ await mutator.mutateQuery({ body: { name: "Jane" } });
2191
+
2192
+ expect(capturedUrl).toBe("http://localhost:3000/api/uploads-proxy/users");
2193
+ });
2194
+
2000
2195
  test("getFetcher returns correct fetcher and options", () => {
2001
2196
  const customFetch = vi.fn() as unknown as typeof fetch;
2002
2197
  const client = createClientBuilder(