@fragno-dev/core 0.1.5 → 0.1.7

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 (71) hide show
  1. package/.turbo/turbo-build.log +49 -45
  2. package/CHANGELOG.md +52 -0
  3. package/dist/api/api.d.ts +2 -2
  4. package/dist/api/fragment-builder.d.ts +3 -2
  5. package/dist/api/fragment-instantiation.d.ts +4 -3
  6. package/dist/api/fragment-instantiation.js +3 -3
  7. package/dist/api/route.d.ts +3 -0
  8. package/dist/api/route.js +3 -0
  9. package/dist/{api-B1-h7jPC.d.ts → api-BWN97TOr.d.ts} +17 -3
  10. package/dist/api-BWN97TOr.d.ts.map +1 -0
  11. package/dist/api-DngJDcmO.js.map +1 -1
  12. package/dist/client/client.d.ts +4 -3
  13. package/dist/client/client.js +3 -3
  14. package/dist/client/client.svelte.d.ts +3 -3
  15. package/dist/client/client.svelte.d.ts.map +1 -1
  16. package/dist/client/client.svelte.js +3 -3
  17. package/dist/client/react.d.ts +3 -3
  18. package/dist/client/react.d.ts.map +1 -1
  19. package/dist/client/react.js +3 -3
  20. package/dist/client/solid.d.ts +3 -3
  21. package/dist/client/solid.d.ts.map +1 -1
  22. package/dist/client/solid.js +3 -3
  23. package/dist/client/vanilla.d.ts +3 -3
  24. package/dist/client/vanilla.d.ts.map +1 -1
  25. package/dist/client/vanilla.js +3 -3
  26. package/dist/client/vue.d.ts +3 -3
  27. package/dist/client/vue.d.ts.map +1 -1
  28. package/dist/client/vue.js +7 -7
  29. package/dist/client/vue.js.map +1 -1
  30. package/dist/{client-YUZaNg5U.js → client-C5LsYHEI.js} +92 -11
  31. package/dist/client-C5LsYHEI.js.map +1 -0
  32. package/dist/{fragment-builder-DsqUOfJ5.d.ts → fragment-builder-MGr68GNb.d.ts} +80 -44
  33. package/dist/fragment-builder-MGr68GNb.d.ts.map +1 -0
  34. package/dist/{fragment-instantiation-Cp0K8zdS.js → fragment-instantiation-C4wvwl6V.js} +108 -3
  35. package/dist/fragment-instantiation-C4wvwl6V.js.map +1 -0
  36. package/dist/mod.d.ts +3 -2
  37. package/dist/mod.js +3 -3
  38. package/dist/{route-Dk1GyqHs.js → request-output-context-CdIjwmEN.js} +13 -24
  39. package/dist/request-output-context-CdIjwmEN.js.map +1 -0
  40. package/dist/route-Bl9Zr1Yv.d.ts +26 -0
  41. package/dist/route-Bl9Zr1Yv.d.ts.map +1 -0
  42. package/dist/route-C5Uryylh.js +21 -0
  43. package/dist/route-C5Uryylh.js.map +1 -0
  44. package/dist/test/test.d.ts +24 -70
  45. package/dist/test/test.d.ts.map +1 -1
  46. package/dist/test/test.js +27 -115
  47. package/dist/test/test.js.map +1 -1
  48. package/package.json +6 -1
  49. package/src/api/api.ts +1 -0
  50. package/src/api/fragment-instantiation.test.ts +460 -0
  51. package/src/api/fragment-instantiation.ts +121 -0
  52. package/src/api/fragno-response.ts +132 -0
  53. package/src/api/internal/path-type.test.ts +7 -7
  54. package/src/api/internal/path.ts +1 -1
  55. package/src/api/request-output-context.test.ts +10 -10
  56. package/src/api/request-output-context.ts +3 -3
  57. package/src/api/route-handler-input-options.ts +15 -0
  58. package/src/client/client-types.test.ts +4 -4
  59. package/src/client/client.test.ts +341 -0
  60. package/src/client/client.ts +96 -15
  61. package/src/client/internal/fetcher-merge.ts +59 -0
  62. package/src/test/test.test.ts +110 -165
  63. package/src/test/test.ts +56 -266
  64. package/tsdown.config.ts +1 -0
  65. package/dist/api-B1-h7jPC.d.ts.map +0 -1
  66. package/dist/client-YUZaNg5U.js.map +0 -1
  67. package/dist/fragment-builder-DsqUOfJ5.d.ts.map +0 -1
  68. package/dist/fragment-instantiation-Cp0K8zdS.js.map +0 -1
  69. package/dist/route-CTxjMtGZ.js +0 -10
  70. package/dist/route-CTxjMtGZ.js.map +0 -1
  71. package/dist/route-Dk1GyqHs.js.map +0 -1
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Discriminated union representing all possible Fragno response types
3
+ */
4
+ export type FragnoResponse<T> =
5
+ | {
6
+ type: "empty";
7
+ status: number;
8
+ headers: Headers;
9
+ }
10
+ | {
11
+ type: "error";
12
+ status: number;
13
+ headers: Headers;
14
+ error: { message: string; code: string };
15
+ }
16
+ | {
17
+ type: "json";
18
+ status: number;
19
+ headers: Headers;
20
+ data: T;
21
+ }
22
+ | {
23
+ type: "jsonStream";
24
+ status: number;
25
+ headers: Headers;
26
+ stream: AsyncGenerator<T extends unknown[] ? T[number] : T>;
27
+ };
28
+
29
+ /**
30
+ * Parse a Response object into a FragnoResponse discriminated union
31
+ */
32
+ export async function parseFragnoResponse<T>(response: Response): Promise<FragnoResponse<T>> {
33
+ const status = response.status;
34
+ const headers = response.headers;
35
+ const contentType = headers.get("content-type") || "";
36
+
37
+ // Check for streaming response
38
+ if (contentType.includes("application/x-ndjson")) {
39
+ return {
40
+ type: "jsonStream",
41
+ status,
42
+ headers,
43
+ stream: parseNDJSONStream<T>(response),
44
+ };
45
+ }
46
+
47
+ // Parse JSON body
48
+ const text = await response.text();
49
+
50
+ // Empty response
51
+ if (!text || text === "null") {
52
+ return {
53
+ type: "empty",
54
+ status,
55
+ headers,
56
+ };
57
+ }
58
+
59
+ const data = JSON.parse(text);
60
+
61
+ // Error response (has message and code, or error and code)
62
+ if (data && typeof data === "object" && "code" in data) {
63
+ if ("message" in data) {
64
+ return {
65
+ type: "error",
66
+ status,
67
+ headers,
68
+ error: { message: data.message, code: data.code },
69
+ };
70
+ }
71
+ if ("error" in data) {
72
+ return {
73
+ type: "error",
74
+ status,
75
+ headers,
76
+ error: { message: data.error, code: data.code },
77
+ };
78
+ }
79
+ }
80
+
81
+ // JSON response
82
+ return {
83
+ type: "json",
84
+ status,
85
+ headers,
86
+ data: data as T,
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Parse an NDJSON stream into an async generator
92
+ */
93
+ async function* parseNDJSONStream<T>(
94
+ response: Response,
95
+ ): AsyncGenerator<T extends unknown[] ? T[number] : T> {
96
+ if (!response.body) {
97
+ return;
98
+ }
99
+
100
+ const reader = response.body.getReader();
101
+ const decoder = new TextDecoder();
102
+ let buffer = "";
103
+
104
+ try {
105
+ while (true) {
106
+ const { done, value } = await reader.read();
107
+
108
+ if (done) {
109
+ break;
110
+ }
111
+
112
+ buffer += decoder.decode(value, { stream: true });
113
+ const lines = buffer.split("\n");
114
+
115
+ // Keep the last incomplete line in the buffer
116
+ buffer = lines.pop() || "";
117
+
118
+ for (const line of lines) {
119
+ if (line.trim()) {
120
+ yield JSON.parse(line) as T extends unknown[] ? T[number] : T;
121
+ }
122
+ }
123
+ }
124
+
125
+ // Process any remaining data in the buffer
126
+ if (buffer.trim()) {
127
+ yield JSON.parse(buffer) as T extends unknown[] ? T[number] : T;
128
+ }
129
+ } finally {
130
+ reader.releaseLock();
131
+ }
132
+ }
@@ -446,39 +446,39 @@ test("MaybeExtractPathParamsOrWiden type tests", () => {
446
446
  test("QueryParamsHint type tests", () => {
447
447
  // Basic usage with string union
448
448
  expectTypeOf<QueryParamsHint<"page" | "limit">>().toEqualTypeOf<
449
- Partial<Record<"page" | "limit", string>> & Record<string, string>
449
+ Partial<Record<"page" | "limit", string>> & Record<string, string | undefined>
450
450
  >();
451
451
 
452
452
  // Single parameter hint
453
453
  expectTypeOf<QueryParamsHint<"search">>().toEqualTypeOf<
454
- Partial<Record<"search", string>> & Record<string, string>
454
+ Partial<Record<"search", string>> & Record<string, string | undefined>
455
455
  >();
456
456
 
457
457
  // Empty hint (never) - should still allow any string keys
458
458
  expectTypeOf<QueryParamsHint<never>>().toEqualTypeOf<
459
- Partial<Record<never, string>> & Record<string, string>
459
+ Partial<Record<never, string>> & Record<string, string | undefined>
460
460
  >();
461
461
 
462
462
  // With custom value type
463
463
  expectTypeOf<QueryParamsHint<"page" | "limit", number>>().toEqualTypeOf<
464
- Partial<Record<"page" | "limit", number>> & Record<string, number>
464
+ Partial<Record<"page" | "limit", number>> & Record<string, number | undefined>
465
465
  >();
466
466
 
467
467
  // With boolean value type
468
468
  expectTypeOf<QueryParamsHint<"enabled" | "debug", boolean>>().toEqualTypeOf<
469
- Partial<Record<"enabled" | "debug", boolean>> & Record<string, boolean>
469
+ Partial<Record<"enabled" | "debug", boolean>> & Record<string, boolean | undefined>
470
470
  >();
471
471
 
472
472
  // With union value type
473
473
  type StringOrNumber = string | number;
474
474
  expectTypeOf<QueryParamsHint<"value", StringOrNumber>>().toEqualTypeOf<
475
- Partial<Record<"value", StringOrNumber>> & Record<string, StringOrNumber>
475
+ Partial<Record<"value", StringOrNumber>> & Record<string, StringOrNumber | undefined>
476
476
  >();
477
477
 
478
478
  // With custom object type
479
479
  type CustomType = { raw: string; parsed: boolean };
480
480
  expectTypeOf<QueryParamsHint<"data", CustomType>>().toEqualTypeOf<
481
- Partial<Record<"data", CustomType>> & Record<string, CustomType>
481
+ Partial<Record<"data", CustomType>> & Record<string, CustomType | undefined>
482
482
  >();
483
483
  });
484
484
 
@@ -124,7 +124,7 @@ export type HasPathParams<T extends string> = ExtractPathParamNames<T> extends n
124
124
  export type QueryParamsHint<TQueryParameters extends string, ValueType = string> = Partial<
125
125
  Record<TQueryParameters, ValueType>
126
126
  > &
127
- Record<string, ValueType>;
127
+ Record<string, ValueType | undefined>;
128
128
 
129
129
  // Runtime utilities
130
130
 
@@ -53,8 +53,8 @@ describe("RequestOutputContext", () => {
53
53
  expect(response).toBeInstanceOf(Response);
54
54
  expect(response.status).toBe(201);
55
55
 
56
- const body = await response.json();
57
- expect(body).toBe(null);
56
+ const body = await response.text();
57
+ expect(body).toBe("");
58
58
  });
59
59
 
60
60
  test("Should return empty response with custom status number", async () => {
@@ -63,8 +63,8 @@ describe("RequestOutputContext", () => {
63
63
 
64
64
  expect(response.status).toBe(204);
65
65
 
66
- const body = await response.json();
67
- expect(body).toBe(null);
66
+ const body = await response.text();
67
+ expect(body).toBe("");
68
68
  });
69
69
 
70
70
  test("Should return empty response with custom headers via second parameter", async () => {
@@ -75,8 +75,8 @@ describe("RequestOutputContext", () => {
75
75
  expect(response.status).toBe(201);
76
76
  expect(response.headers.get("X-Custom")).toBe("test-value");
77
77
 
78
- const body = await response.json();
79
- expect(body).toBe(null);
78
+ const body = await response.text();
79
+ expect(body).toBe("");
80
80
  });
81
81
 
82
82
  test("Should return empty response with status and headers via second parameter", async () => {
@@ -87,8 +87,8 @@ describe("RequestOutputContext", () => {
87
87
  expect(response.status).toBe(204);
88
88
  expect(response.headers.get("X-Custom")).toBe("test-value");
89
89
 
90
- const body = await response.json();
91
- expect(body).toBe(null);
90
+ const body = await response.text();
91
+ expect(body).toBe("");
92
92
  });
93
93
 
94
94
  test("Should return empty response with ResponseInit object", async () => {
@@ -103,8 +103,8 @@ describe("RequestOutputContext", () => {
103
103
  expect(response.status).toBe(204);
104
104
  expect(response.headers.get("X-Custom")).toBe("test-value");
105
105
 
106
- const body = await response.json();
107
- expect(body).toBe(null);
106
+ const body = await response.text();
107
+ expect(body).toBe("");
108
108
  });
109
109
 
110
110
  test("Should handle multiple headers in ResponseInit", async () => {
@@ -75,7 +75,7 @@ export abstract class OutputContext<const TOutput, const TErrorCode extends stri
75
75
 
76
76
  if (typeof initOrStatus === "undefined") {
77
77
  const mergedHeaders = mergeHeaders(defaultHeaders, headers);
78
- return Response.json(null, {
78
+ return new Response(null, {
79
79
  status: 201,
80
80
  headers: mergedHeaders,
81
81
  });
@@ -83,14 +83,14 @@ export abstract class OutputContext<const TOutput, const TErrorCode extends stri
83
83
 
84
84
  if (typeof initOrStatus === "number") {
85
85
  const mergedHeaders = mergeHeaders(defaultHeaders, headers);
86
- return Response.json(null, {
86
+ return new Response(null, {
87
87
  status: initOrStatus,
88
88
  headers: mergedHeaders,
89
89
  });
90
90
  }
91
91
 
92
92
  const mergedHeaders = mergeHeaders(defaultHeaders, initOrStatus.headers, headers);
93
- return Response.json(null, {
93
+ return new Response(null, {
94
94
  status: initOrStatus.status,
95
95
  headers: mergedHeaders,
96
96
  });
@@ -0,0 +1,15 @@
1
+ import type { StandardSchemaV1 } from "@standard-schema/spec";
2
+ import type { ExtractPathParams } from "./internal/path";
3
+ import type { InferOr } from "../util/types-util";
4
+
5
+ /**
6
+ * Options for calling a route handler
7
+ */
8
+ export type RouteHandlerInputOptions<
9
+ TPath extends string,
10
+ TInputSchema extends StandardSchemaV1 | undefined,
11
+ > = {
12
+ pathParams?: ExtractPathParams<TPath>;
13
+ query?: URLSearchParams | Record<string, string>;
14
+ headers?: Headers | Record<string, string>;
15
+ } & (TInputSchema extends undefined ? { body?: never } : { body: InferOr<TInputSchema, unknown> });
@@ -417,7 +417,7 @@ describe("FragnoClientMutatorData", () => {
417
417
  (args?: {
418
418
  body?: undefined;
419
419
  path?: Record<"id", string> | undefined;
420
- query?: Record<string, string>;
420
+ query?: Record<string, string | undefined>;
421
421
  }) => Promise<undefined>
422
422
  >();
423
423
  });
@@ -437,7 +437,7 @@ describe("FragnoClientMutatorData", () => {
437
437
  (args?: {
438
438
  body?: undefined;
439
439
  path?: Record<"id", string> | undefined;
440
- query?: Record<string, string>;
440
+ query?: Record<string, string | undefined>;
441
441
  }) => Promise<{
442
442
  id: number;
443
443
  name: string;
@@ -465,7 +465,7 @@ describe("FragnoClientMutatorData", () => {
465
465
  }
466
466
  | undefined;
467
467
  path?: Record<"id", string> | undefined;
468
- query?: Record<string, string>;
468
+ query?: Record<string, string | undefined>;
469
469
  }) => Promise<undefined>
470
470
  >();
471
471
  });
@@ -486,7 +486,7 @@ describe("FragnoClientMutatorData", () => {
486
486
  (args?: {
487
487
  body?: undefined;
488
488
  path?: Record<"id", string> | undefined;
489
- query?: Record<"id" | "name", string>;
489
+ query?: Record<"id" | "name", string | undefined>;
490
490
  }) => Promise<undefined>
491
491
  >();
492
492
  });
@@ -445,6 +445,83 @@ describe("hook parameter reactivity", () => {
445
445
  }
446
446
  });
447
447
 
448
+ test("should react to optional query parameters", async () => {
449
+ const testFragment = defineFragment("test-fragment");
450
+ const testRoutes = [
451
+ defineRoute({
452
+ method: "GET",
453
+ path: "/users",
454
+ queryParameters: ["thing"],
455
+ outputSchema: z.array(z.object({ id: z.number(), name: z.string(), suffix: z.string() })),
456
+ handler: async (_ctx, { json }) => json([{ id: 1, name: "John", suffix: "default" }]),
457
+ }),
458
+ ] as const;
459
+
460
+ vi.mocked(global.fetch).mockImplementation(async (input) => {
461
+ assert(typeof input === "string");
462
+
463
+ const url = new URL(input);
464
+ const thing = url.searchParams.get("thing");
465
+
466
+ const suffix = thing ? `with-${thing}` : "without-thing";
467
+
468
+ return {
469
+ headers: new Headers(),
470
+ ok: true,
471
+ json: async () => [{ id: 1, name: "John", suffix }],
472
+ } as Response;
473
+ });
474
+
475
+ const cb = createClientBuilder(testFragment, clientConfig, testRoutes);
476
+ const useUsers = cb.createHook("/users");
477
+
478
+ // This atom with string | undefined type should be accepted by the query parameter
479
+ const thingAtom = atom<string | undefined>("initial");
480
+
481
+ const store = useUsers.store({ query: { thing: thingAtom } });
482
+
483
+ const itt = createAsyncIteratorFromCallback(store.listen);
484
+
485
+ {
486
+ const { value } = await itt.next();
487
+ expect(value).toEqual({
488
+ loading: true,
489
+ promise: expect.any(Promise),
490
+ data: undefined,
491
+ error: undefined,
492
+ });
493
+ }
494
+
495
+ {
496
+ const { value } = await itt.next();
497
+ expect(value).toEqual({
498
+ loading: false,
499
+ data: [{ id: 1, name: "John", suffix: "with-initial" }],
500
+ });
501
+ }
502
+
503
+ // Change thing to another value
504
+ thingAtom.set(undefined);
505
+
506
+ {
507
+ const { value } = await itt.next();
508
+ expect(value).toEqual({
509
+ loading: true,
510
+ promise: expect.any(Promise),
511
+ data: undefined,
512
+ error: undefined,
513
+ });
514
+ }
515
+
516
+ {
517
+ const { value } = await itt.next();
518
+ expect(value).toEqual({
519
+ loading: false,
520
+ data: [{ id: 1, name: "John", suffix: "without-thing" }],
521
+ });
522
+ }
523
+ });
524
+
448
525
  test("should react to combined path and query parameters", async () => {
449
526
  const testFragment = defineFragment("test-fragment");
450
527
  const testRoutes = [
@@ -1688,3 +1765,267 @@ describe("type guards", () => {
1688
1765
  expect(isGetHook(Symbol("fragno-get-hook"))).toBe(false);
1689
1766
  });
1690
1767
  });
1768
+
1769
+ describe("Custom Fetcher Configuration", () => {
1770
+ const testFragment = defineFragment("test-fragment");
1771
+ const testRoutes = [
1772
+ defineRoute({
1773
+ method: "GET",
1774
+ path: "/users",
1775
+ outputSchema: z.array(z.object({ id: z.number(), name: z.string() })),
1776
+ handler: async (_ctx, { json }) => json([{ id: 1, name: "John" }]),
1777
+ }),
1778
+ defineRoute({
1779
+ method: "POST",
1780
+ path: "/users",
1781
+ inputSchema: z.object({ name: z.string() }),
1782
+ outputSchema: z.object({ id: z.number(), name: z.string() }),
1783
+ handler: async (_ctx, { json }) => json({ id: 2, name: "Jane" }),
1784
+ }),
1785
+ ] as const;
1786
+
1787
+ const clientConfig: FragnoPublicClientConfig = {
1788
+ baseUrl: "http://localhost:3000",
1789
+ };
1790
+
1791
+ beforeEach(() => {
1792
+ vi.clearAllMocks();
1793
+ (global.fetch as ReturnType<typeof vi.fn>).mockReset();
1794
+ });
1795
+
1796
+ afterEach(() => {
1797
+ vi.restoreAllMocks();
1798
+ });
1799
+
1800
+ test("fragment author sets RequestInit options (credentials)", async () => {
1801
+ let capturedOptions: RequestInit | undefined;
1802
+
1803
+ (global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async (_url, options) => {
1804
+ capturedOptions = options;
1805
+ return {
1806
+ headers: new Headers(),
1807
+ ok: true,
1808
+ json: async () => [{ id: 1, name: "John" }],
1809
+ } as Response;
1810
+ });
1811
+
1812
+ const client = createClientBuilder(testFragment, clientConfig, testRoutes, {
1813
+ type: "options",
1814
+ options: { credentials: "include" },
1815
+ });
1816
+
1817
+ const useUsers = client.createHook("/users");
1818
+ await useUsers.query();
1819
+
1820
+ expect(capturedOptions).toBeDefined();
1821
+ expect(capturedOptions?.credentials).toBe("include");
1822
+ });
1823
+
1824
+ test("user overrides with their own RequestInit (deep merge)", async () => {
1825
+ let capturedOptions: RequestInit | undefined;
1826
+
1827
+ (global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async (_url, options) => {
1828
+ capturedOptions = options;
1829
+ return {
1830
+ headers: new Headers(),
1831
+ ok: true,
1832
+ json: async () => [{ id: 1, name: "John" }],
1833
+ } as Response;
1834
+ });
1835
+
1836
+ const authorConfig = { type: "options", options: { credentials: "include" } } as const;
1837
+ const userConfig = {
1838
+ ...clientConfig,
1839
+ fetcherConfig: { type: "options", options: { mode: "cors" } } as const,
1840
+ };
1841
+
1842
+ const client = createClientBuilder(testFragment, userConfig, testRoutes, authorConfig);
1843
+
1844
+ const useUsers = client.createHook("/users");
1845
+ await useUsers.query();
1846
+
1847
+ expect(capturedOptions).toBeDefined();
1848
+ expect(capturedOptions?.credentials).toBe("include");
1849
+ expect(capturedOptions?.mode).toBe("cors");
1850
+ });
1851
+
1852
+ test("user provides custom fetch function (takes full precedence)", async () => {
1853
+ const customFetch = vi.fn(async () => ({
1854
+ headers: new Headers(),
1855
+ ok: true,
1856
+ json: async () => [{ id: 999, name: "Custom" }],
1857
+ })) as unknown as typeof fetch;
1858
+
1859
+ const authorConfig = { type: "options", options: { credentials: "include" } } as const;
1860
+ const userConfig = {
1861
+ ...clientConfig,
1862
+ fetcherConfig: { type: "function", fetcher: customFetch } as const,
1863
+ };
1864
+
1865
+ const client = createClientBuilder(testFragment, userConfig, testRoutes, authorConfig);
1866
+
1867
+ const useUsers = client.createHook("/users");
1868
+ const result = await useUsers.query();
1869
+
1870
+ expect(customFetch).toHaveBeenCalled();
1871
+ expect(result).toEqual([{ id: 999, name: "Custom" }]);
1872
+ });
1873
+
1874
+ test("author provides custom fetch, user provides RequestInit (user RequestInit used)", async () => {
1875
+ const authorFetch = vi.fn(async () => ({
1876
+ headers: new Headers(),
1877
+ ok: true,
1878
+ json: async () => [{ id: 777, name: "Author" }],
1879
+ })) as unknown as typeof fetch;
1880
+
1881
+ let capturedOptions: RequestInit | undefined;
1882
+ (global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async (_url, options) => {
1883
+ capturedOptions = options;
1884
+ return {
1885
+ headers: new Headers(),
1886
+ ok: true,
1887
+ json: async () => [{ id: 1, name: "John" }],
1888
+ } as Response;
1889
+ });
1890
+
1891
+ const authorConfig = { type: "function", fetcher: authorFetch } as const;
1892
+ const userConfig = {
1893
+ ...clientConfig,
1894
+ fetcherConfig: { type: "options", options: { credentials: "include" } } as const,
1895
+ };
1896
+
1897
+ const client = createClientBuilder(testFragment, userConfig, testRoutes, authorConfig);
1898
+
1899
+ const useUsers = client.createHook("/users");
1900
+ await useUsers.query();
1901
+
1902
+ // User's RequestInit takes precedence, so global fetch should be used
1903
+ expect(authorFetch).not.toHaveBeenCalled();
1904
+ expect(global.fetch).toHaveBeenCalled();
1905
+ expect(capturedOptions?.credentials).toBe("include");
1906
+ });
1907
+
1908
+ test("headers merge correctly (user headers override author headers)", async () => {
1909
+ let capturedOptions: RequestInit | undefined;
1910
+
1911
+ (global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async (_url, options) => {
1912
+ capturedOptions = options;
1913
+ return {
1914
+ headers: new Headers(),
1915
+ ok: true,
1916
+ json: async () => [{ id: 1, name: "John" }],
1917
+ } as Response;
1918
+ });
1919
+
1920
+ const authorConfig = {
1921
+ type: "options",
1922
+ options: {
1923
+ headers: {
1924
+ "X-Author-Header": "author-value",
1925
+ "X-Shared-Header": "author-shared",
1926
+ },
1927
+ },
1928
+ } as const;
1929
+
1930
+ const userConfig = {
1931
+ ...clientConfig,
1932
+ fetcherConfig: {
1933
+ type: "options",
1934
+ options: {
1935
+ headers: {
1936
+ "X-User-Header": "user-value",
1937
+ "X-Shared-Header": "user-shared",
1938
+ },
1939
+ },
1940
+ } as const,
1941
+ };
1942
+
1943
+ const client = createClientBuilder(testFragment, userConfig, testRoutes, authorConfig);
1944
+
1945
+ const useUsers = client.createHook("/users");
1946
+ await useUsers.query();
1947
+
1948
+ expect(capturedOptions).toBeDefined();
1949
+ const headers = new Headers(capturedOptions?.headers);
1950
+ expect(headers.get("X-Author-Header")).toBe("author-value");
1951
+ expect(headers.get("X-User-Header")).toBe("user-value");
1952
+ expect(headers.get("X-Shared-Header")).toBe("user-shared"); // User overrides
1953
+ });
1954
+
1955
+ test("custom fetcher works with mutators", async () => {
1956
+ let capturedOptions: RequestInit | undefined;
1957
+
1958
+ (global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async (_url, options) => {
1959
+ capturedOptions = options;
1960
+ return {
1961
+ headers: new Headers(),
1962
+ ok: true,
1963
+ status: 200,
1964
+ json: async () => ({ id: 2, name: "Jane" }),
1965
+ } as Response;
1966
+ });
1967
+
1968
+ const client = createClientBuilder(testFragment, clientConfig, testRoutes, {
1969
+ type: "options",
1970
+ options: { credentials: "include" },
1971
+ });
1972
+
1973
+ const mutator = client.createMutator("POST", "/users");
1974
+ await mutator.mutateQuery({ body: { name: "Jane" } });
1975
+
1976
+ expect(capturedOptions).toBeDefined();
1977
+ expect(capturedOptions?.credentials).toBe("include");
1978
+ expect(capturedOptions?.method).toBe("POST");
1979
+ });
1980
+
1981
+ test("buildUrl method works correctly", () => {
1982
+ const client = createClientBuilder(testFragment, clientConfig, testRoutes);
1983
+
1984
+ const url1 = client.buildUrl("/users");
1985
+ expect(url1).toBe("http://localhost:3000/api/test-fragment/users");
1986
+
1987
+ const url2 = client.buildUrl("/users/:id", { path: { id: "123" } });
1988
+ expect(url2).toBe("http://localhost:3000/api/test-fragment/users/123");
1989
+
1990
+ const url3 = client.buildUrl("/users", { query: { sort: "name", order: "asc" } });
1991
+ expect(url3).toBe("http://localhost:3000/api/test-fragment/users?sort=name&order=asc");
1992
+
1993
+ const url4 = client.buildUrl("/users/:id", {
1994
+ path: { id: "456" },
1995
+ query: { include: "posts" },
1996
+ });
1997
+ expect(url4).toBe("http://localhost:3000/api/test-fragment/users/456?include=posts");
1998
+ });
1999
+
2000
+ test("getFetcher returns correct fetcher and options", () => {
2001
+ const customFetch = vi.fn() as unknown as typeof fetch;
2002
+ const client = createClientBuilder(
2003
+ testFragment,
2004
+ {
2005
+ ...clientConfig,
2006
+ fetcherConfig: { type: "function", fetcher: customFetch },
2007
+ },
2008
+ testRoutes,
2009
+ );
2010
+
2011
+ const { fetcher, defaultOptions } = client.getFetcher();
2012
+ expect(fetcher).toBe(customFetch);
2013
+ expect(defaultOptions).toBeUndefined();
2014
+ });
2015
+
2016
+ test("getFetcher returns default fetch and options", () => {
2017
+ const client = createClientBuilder(
2018
+ testFragment,
2019
+ {
2020
+ ...clientConfig,
2021
+ fetcherConfig: { type: "options", options: { credentials: "include" } },
2022
+ },
2023
+ testRoutes,
2024
+ );
2025
+
2026
+ const { fetcher, defaultOptions } = client.getFetcher();
2027
+ expect(fetcher).toBe(fetch);
2028
+ expect(defaultOptions).toBeDefined();
2029
+ expect(defaultOptions?.credentials).toBe("include");
2030
+ });
2031
+ });