@fragno-dev/core 0.1.4 → 0.1.6

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 +49 -45
  2. package/CHANGELOG.md +53 -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-BX90b4-D.d.ts → api-CoCkNi6h.d.ts} +20 -7
  10. package/dist/api-CoCkNi6h.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 +3 -3
  29. package/dist/{client-C6LChM0Y.js → client-DJfCJiHK.js} +81 -7
  30. package/dist/client-DJfCJiHK.js.map +1 -0
  31. package/dist/{fragment-builder-BZr2JkuW.d.ts → fragment-builder-8-tiECi5.d.ts} +75 -38
  32. package/dist/fragment-builder-8-tiECi5.d.ts.map +1 -0
  33. package/dist/{fragment-instantiation-D74OQjbn.js → fragment-instantiation-C4wvwl6V.js} +129 -6
  34. package/dist/fragment-instantiation-C4wvwl6V.js.map +1 -0
  35. package/dist/mod.d.ts +3 -2
  36. package/dist/mod.js +3 -3
  37. package/dist/{route-D1MZR6JL.js → request-output-context-CdIjwmEN.js} +22 -33
  38. package/dist/request-output-context-CdIjwmEN.js.map +1 -0
  39. package/dist/route-C5Uryylh.js +21 -0
  40. package/dist/route-C5Uryylh.js.map +1 -0
  41. package/dist/route-mGLYSUvD.d.ts +26 -0
  42. package/dist/route-mGLYSUvD.d.ts.map +1 -0
  43. package/dist/test/test.d.ts +24 -70
  44. package/dist/test/test.d.ts.map +1 -1
  45. package/dist/test/test.js +27 -115
  46. package/dist/test/test.js.map +1 -1
  47. package/package.json +6 -1
  48. package/src/api/api.ts +1 -0
  49. package/src/api/fragment-instantiation.test.ts +460 -0
  50. package/src/api/fragment-instantiation.ts +157 -5
  51. package/src/api/fragno-response.ts +132 -0
  52. package/src/api/request-input-context.test.ts +37 -29
  53. package/src/api/request-input-context.ts +16 -14
  54. package/src/api/request-output-context.test.ts +10 -10
  55. package/src/api/request-output-context.ts +3 -3
  56. package/src/api/route-handler-input-options.ts +15 -0
  57. package/src/client/client.test.ts +264 -0
  58. package/src/client/client.ts +65 -3
  59. package/src/client/internal/fetcher-merge.ts +59 -0
  60. package/src/test/test.test.ts +110 -165
  61. package/src/test/test.ts +56 -266
  62. package/tsdown.config.ts +1 -0
  63. package/dist/api-BX90b4-D.d.ts.map +0 -1
  64. package/dist/client-C6LChM0Y.js.map +0 -1
  65. package/dist/fragment-builder-BZr2JkuW.d.ts.map +0 -1
  66. package/dist/fragment-instantiation-D74OQjbn.js.map +0 -1
  67. package/dist/route-CTxjMtGZ.js +0 -10
  68. package/dist/route-CTxjMtGZ.js.map +0 -1
  69. package/dist/route-D1MZR6JL.js.map +0 -1
@@ -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);
@@ -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> });
@@ -1688,3 +1688,267 @@ describe("type guards", () => {
1688
1688
  expect(isGetHook(Symbol("fragno-get-hook"))).toBe(false);
1689
1689
  });
1690
1690
  });
1691
+
1692
+ describe("Custom Fetcher Configuration", () => {
1693
+ const testFragment = defineFragment("test-fragment");
1694
+ const testRoutes = [
1695
+ defineRoute({
1696
+ method: "GET",
1697
+ path: "/users",
1698
+ outputSchema: z.array(z.object({ id: z.number(), name: z.string() })),
1699
+ handler: async (_ctx, { json }) => json([{ id: 1, name: "John" }]),
1700
+ }),
1701
+ defineRoute({
1702
+ method: "POST",
1703
+ path: "/users",
1704
+ inputSchema: z.object({ name: z.string() }),
1705
+ outputSchema: z.object({ id: z.number(), name: z.string() }),
1706
+ handler: async (_ctx, { json }) => json({ id: 2, name: "Jane" }),
1707
+ }),
1708
+ ] as const;
1709
+
1710
+ const clientConfig: FragnoPublicClientConfig = {
1711
+ baseUrl: "http://localhost:3000",
1712
+ };
1713
+
1714
+ beforeEach(() => {
1715
+ vi.clearAllMocks();
1716
+ (global.fetch as ReturnType<typeof vi.fn>).mockReset();
1717
+ });
1718
+
1719
+ afterEach(() => {
1720
+ vi.restoreAllMocks();
1721
+ });
1722
+
1723
+ test("fragment author sets RequestInit options (credentials)", async () => {
1724
+ let capturedOptions: RequestInit | undefined;
1725
+
1726
+ (global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async (_url, options) => {
1727
+ capturedOptions = options;
1728
+ return {
1729
+ headers: new Headers(),
1730
+ ok: true,
1731
+ json: async () => [{ id: 1, name: "John" }],
1732
+ } as Response;
1733
+ });
1734
+
1735
+ const client = createClientBuilder(testFragment, clientConfig, testRoutes, {
1736
+ type: "options",
1737
+ options: { credentials: "include" },
1738
+ });
1739
+
1740
+ const useUsers = client.createHook("/users");
1741
+ await useUsers.query();
1742
+
1743
+ expect(capturedOptions).toBeDefined();
1744
+ expect(capturedOptions?.credentials).toBe("include");
1745
+ });
1746
+
1747
+ test("user overrides with their own RequestInit (deep merge)", async () => {
1748
+ let capturedOptions: RequestInit | undefined;
1749
+
1750
+ (global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async (_url, options) => {
1751
+ capturedOptions = options;
1752
+ return {
1753
+ headers: new Headers(),
1754
+ ok: true,
1755
+ json: async () => [{ id: 1, name: "John" }],
1756
+ } as Response;
1757
+ });
1758
+
1759
+ const authorConfig = { type: "options", options: { credentials: "include" } } as const;
1760
+ const userConfig = {
1761
+ ...clientConfig,
1762
+ fetcherConfig: { type: "options", options: { mode: "cors" } } as const,
1763
+ };
1764
+
1765
+ const client = createClientBuilder(testFragment, userConfig, testRoutes, authorConfig);
1766
+
1767
+ const useUsers = client.createHook("/users");
1768
+ await useUsers.query();
1769
+
1770
+ expect(capturedOptions).toBeDefined();
1771
+ expect(capturedOptions?.credentials).toBe("include");
1772
+ expect(capturedOptions?.mode).toBe("cors");
1773
+ });
1774
+
1775
+ test("user provides custom fetch function (takes full precedence)", async () => {
1776
+ const customFetch = vi.fn(async () => ({
1777
+ headers: new Headers(),
1778
+ ok: true,
1779
+ json: async () => [{ id: 999, name: "Custom" }],
1780
+ })) as unknown as typeof fetch;
1781
+
1782
+ const authorConfig = { type: "options", options: { credentials: "include" } } as const;
1783
+ const userConfig = {
1784
+ ...clientConfig,
1785
+ fetcherConfig: { type: "function", fetcher: customFetch } as const,
1786
+ };
1787
+
1788
+ const client = createClientBuilder(testFragment, userConfig, testRoutes, authorConfig);
1789
+
1790
+ const useUsers = client.createHook("/users");
1791
+ const result = await useUsers.query();
1792
+
1793
+ expect(customFetch).toHaveBeenCalled();
1794
+ expect(result).toEqual([{ id: 999, name: "Custom" }]);
1795
+ });
1796
+
1797
+ test("author provides custom fetch, user provides RequestInit (user RequestInit used)", async () => {
1798
+ const authorFetch = vi.fn(async () => ({
1799
+ headers: new Headers(),
1800
+ ok: true,
1801
+ json: async () => [{ id: 777, name: "Author" }],
1802
+ })) as unknown as typeof fetch;
1803
+
1804
+ let capturedOptions: RequestInit | undefined;
1805
+ (global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async (_url, options) => {
1806
+ capturedOptions = options;
1807
+ return {
1808
+ headers: new Headers(),
1809
+ ok: true,
1810
+ json: async () => [{ id: 1, name: "John" }],
1811
+ } as Response;
1812
+ });
1813
+
1814
+ const authorConfig = { type: "function", fetcher: authorFetch } as const;
1815
+ const userConfig = {
1816
+ ...clientConfig,
1817
+ fetcherConfig: { type: "options", options: { credentials: "include" } } as const,
1818
+ };
1819
+
1820
+ const client = createClientBuilder(testFragment, userConfig, testRoutes, authorConfig);
1821
+
1822
+ const useUsers = client.createHook("/users");
1823
+ await useUsers.query();
1824
+
1825
+ // User's RequestInit takes precedence, so global fetch should be used
1826
+ expect(authorFetch).not.toHaveBeenCalled();
1827
+ expect(global.fetch).toHaveBeenCalled();
1828
+ expect(capturedOptions?.credentials).toBe("include");
1829
+ });
1830
+
1831
+ test("headers merge correctly (user headers override author headers)", async () => {
1832
+ let capturedOptions: RequestInit | undefined;
1833
+
1834
+ (global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async (_url, options) => {
1835
+ capturedOptions = options;
1836
+ return {
1837
+ headers: new Headers(),
1838
+ ok: true,
1839
+ json: async () => [{ id: 1, name: "John" }],
1840
+ } as Response;
1841
+ });
1842
+
1843
+ const authorConfig = {
1844
+ type: "options",
1845
+ options: {
1846
+ headers: {
1847
+ "X-Author-Header": "author-value",
1848
+ "X-Shared-Header": "author-shared",
1849
+ },
1850
+ },
1851
+ } as const;
1852
+
1853
+ const userConfig = {
1854
+ ...clientConfig,
1855
+ fetcherConfig: {
1856
+ type: "options",
1857
+ options: {
1858
+ headers: {
1859
+ "X-User-Header": "user-value",
1860
+ "X-Shared-Header": "user-shared",
1861
+ },
1862
+ },
1863
+ } as const,
1864
+ };
1865
+
1866
+ const client = createClientBuilder(testFragment, userConfig, testRoutes, authorConfig);
1867
+
1868
+ const useUsers = client.createHook("/users");
1869
+ await useUsers.query();
1870
+
1871
+ expect(capturedOptions).toBeDefined();
1872
+ const headers = new Headers(capturedOptions?.headers);
1873
+ expect(headers.get("X-Author-Header")).toBe("author-value");
1874
+ expect(headers.get("X-User-Header")).toBe("user-value");
1875
+ expect(headers.get("X-Shared-Header")).toBe("user-shared"); // User overrides
1876
+ });
1877
+
1878
+ test("custom fetcher works with mutators", async () => {
1879
+ let capturedOptions: RequestInit | undefined;
1880
+
1881
+ (global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async (_url, options) => {
1882
+ capturedOptions = options;
1883
+ return {
1884
+ headers: new Headers(),
1885
+ ok: true,
1886
+ status: 200,
1887
+ json: async () => ({ id: 2, name: "Jane" }),
1888
+ } as Response;
1889
+ });
1890
+
1891
+ const client = createClientBuilder(testFragment, clientConfig, testRoutes, {
1892
+ type: "options",
1893
+ options: { credentials: "include" },
1894
+ });
1895
+
1896
+ const mutator = client.createMutator("POST", "/users");
1897
+ await mutator.mutateQuery({ body: { name: "Jane" } });
1898
+
1899
+ expect(capturedOptions).toBeDefined();
1900
+ expect(capturedOptions?.credentials).toBe("include");
1901
+ expect(capturedOptions?.method).toBe("POST");
1902
+ });
1903
+
1904
+ test("buildUrl method works correctly", () => {
1905
+ const client = createClientBuilder(testFragment, clientConfig, testRoutes);
1906
+
1907
+ const url1 = client.buildUrl("/users");
1908
+ expect(url1).toBe("http://localhost:3000/api/test-fragment/users");
1909
+
1910
+ const url2 = client.buildUrl("/users/:id", { path: { id: "123" } });
1911
+ expect(url2).toBe("http://localhost:3000/api/test-fragment/users/123");
1912
+
1913
+ const url3 = client.buildUrl("/users", { query: { sort: "name", order: "asc" } });
1914
+ expect(url3).toBe("http://localhost:3000/api/test-fragment/users?sort=name&order=asc");
1915
+
1916
+ const url4 = client.buildUrl("/users/:id", {
1917
+ path: { id: "456" },
1918
+ query: { include: "posts" },
1919
+ });
1920
+ expect(url4).toBe("http://localhost:3000/api/test-fragment/users/456?include=posts");
1921
+ });
1922
+
1923
+ test("getFetcher returns correct fetcher and options", () => {
1924
+ const customFetch = vi.fn() as unknown as typeof fetch;
1925
+ const client = createClientBuilder(
1926
+ testFragment,
1927
+ {
1928
+ ...clientConfig,
1929
+ fetcherConfig: { type: "function", fetcher: customFetch },
1930
+ },
1931
+ testRoutes,
1932
+ );
1933
+
1934
+ const { fetcher, defaultOptions } = client.getFetcher();
1935
+ expect(fetcher).toBe(customFetch);
1936
+ expect(defaultOptions).toBeUndefined();
1937
+ });
1938
+
1939
+ test("getFetcher returns default fetch and options", () => {
1940
+ const client = createClientBuilder(
1941
+ testFragment,
1942
+ {
1943
+ ...clientConfig,
1944
+ fetcherConfig: { type: "options", options: { credentials: "include" } },
1945
+ },
1946
+ testRoutes,
1947
+ );
1948
+
1949
+ const { fetcher, defaultOptions } = client.getFetcher();
1950
+ expect(fetcher).toBe(fetch);
1951
+ expect(defaultOptions).toBeDefined();
1952
+ expect(defaultOptions?.credentials).toBe("include");
1953
+ });
1954
+ });
@@ -13,6 +13,7 @@ import { getMountRoute } from "../api/internal/route";
13
13
  import { RequestInputContext } from "../api/request-input-context";
14
14
  import { RequestOutputContext } from "../api/request-output-context";
15
15
  import type {
16
+ FetcherConfig,
16
17
  FragnoFragmentSharedConfig,
17
18
  FragnoPublicClientConfig,
18
19
  } from "../api/fragment-instantiation";
@@ -31,6 +32,7 @@ import {
31
32
  type FlattenRouteFactories,
32
33
  resolveRouteFactories,
33
34
  } from "../api/route";
35
+ import { mergeFetcherConfigs } from "./internal/fetcher-merge";
34
36
 
35
37
  /**
36
38
  * Symbols used to identify hook types
@@ -487,6 +489,7 @@ export class ClientBuilder<
487
489
  > {
488
490
  #publicConfig: FragnoPublicClientConfig;
489
491
  #fragmentConfig: TFragmentConfig;
492
+ #fetcherConfig?: FetcherConfig;
490
493
 
491
494
  #cache = new Map<string, CacheLine>();
492
495
 
@@ -497,6 +500,7 @@ export class ClientBuilder<
497
500
  constructor(publicConfig: FragnoPublicClientConfig, fragmentConfig: TFragmentConfig) {
498
501
  this.#publicConfig = publicConfig;
499
502
  this.#fragmentConfig = fragmentConfig;
503
+ this.#fetcherConfig = publicConfig.fetcherConfig;
500
504
 
501
505
  const [createFetcherStore, createMutatorStore, { invalidateKeys }] = nanoquery({
502
506
  cache: this.#cache,
@@ -514,6 +518,54 @@ export class ClientBuilder<
514
518
  return { obj: obj, [STORE_SYMBOL]: true };
515
519
  }
516
520
 
521
+ /**
522
+ * Build a URL for a custom backend call using the configured baseUrl and mountRoute.
523
+ * Useful for fragment authors who need to make custom fetch calls.
524
+ */
525
+ buildUrl<TPath extends string>(
526
+ path: TPath,
527
+ params?: {
528
+ path?: MaybeExtractPathParamsOrWiden<TPath, string>;
529
+ query?: Record<string, string>;
530
+ },
531
+ ): string {
532
+ const baseUrl = this.#publicConfig.baseUrl ?? "";
533
+ const mountRoute = getMountRoute(this.#fragmentConfig);
534
+
535
+ return buildUrl(
536
+ { baseUrl, mountRoute, path },
537
+ { pathParams: params?.path, queryParams: params?.query },
538
+ );
539
+ }
540
+
541
+ /**
542
+ * Get the configured fetcher function for custom backend calls.
543
+ * Returns fetch with merged options applied.
544
+ */
545
+ getFetcher(): {
546
+ fetcher: typeof fetch;
547
+ defaultOptions: RequestInit | undefined;
548
+ } {
549
+ return {
550
+ fetcher: this.#getFetcher(),
551
+ defaultOptions: this.#getFetcherOptions(),
552
+ };
553
+ }
554
+
555
+ #getFetcher(): typeof fetch {
556
+ if (this.#fetcherConfig?.type === "function") {
557
+ return this.#fetcherConfig.fetcher;
558
+ }
559
+ return fetch;
560
+ }
561
+
562
+ #getFetcherOptions(): RequestInit | undefined {
563
+ if (this.#fetcherConfig?.type === "options") {
564
+ return this.#fetcherConfig.options;
565
+ }
566
+ return undefined;
567
+ }
568
+
517
569
  createHook<TPath extends ExtractGetRoutePaths<TFragmentConfig["routes"]>>(
518
570
  path: ValidateGetRoutePath<TFragmentConfig["routes"], TPath>,
519
571
  options?: CreateHookOptions,
@@ -611,6 +663,8 @@ export class ClientBuilder<
611
663
 
612
664
  const baseUrl = this.#publicConfig.baseUrl ?? "";
613
665
  const mountRoute = getMountRoute(this.#fragmentConfig);
666
+ const fetcher = this.#getFetcher();
667
+ const fetcherOptions = this.#getFetcherOptions();
614
668
 
615
669
  async function callServerSideHandler(params: {
616
670
  pathParams?: Record<string, string | ReadableAtom<string>>;
@@ -650,7 +704,7 @@ export class ClientBuilder<
650
704
 
651
705
  let response: Response;
652
706
  try {
653
- response = await fetch(url);
707
+ response = fetcherOptions ? await fetcher(url, fetcherOptions) : await fetcher(url);
654
708
  } catch (error) {
655
709
  throw FragnoClientFetchError.fromUnknownFetchError(error);
656
710
  }
@@ -795,6 +849,8 @@ export class ClientBuilder<
795
849
 
796
850
  const baseUrl = this.#publicConfig.baseUrl ?? "";
797
851
  const mountRoute = getMountRoute(this.#fragmentConfig);
852
+ const fetcher = this.#getFetcher();
853
+ const fetcherOptions = this.#getFetcherOptions();
798
854
 
799
855
  async function executeMutateQuery({
800
856
  body,
@@ -828,10 +884,12 @@ export class ClientBuilder<
828
884
 
829
885
  let response: Response;
830
886
  try {
831
- response = await fetch(url, {
887
+ const requestOptions: RequestInit = {
888
+ ...fetcherOptions,
832
889
  method,
833
890
  body: body !== undefined ? JSON.stringify(body) : undefined,
834
- });
891
+ };
892
+ response = await fetcher(url, requestOptions);
835
893
  } catch (error) {
836
894
  throw FragnoClientFetchError.fromUnknownFetchError(error);
837
895
  }
@@ -1008,6 +1066,7 @@ export function createClientBuilder<
1008
1066
  },
1009
1067
  publicConfig: FragnoPublicClientConfig,
1010
1068
  routesOrFactories: TRoutesOrFactories,
1069
+ authorFetcherConfig?: FetcherConfig,
1011
1070
  ): ClientBuilder<
1012
1071
  FlattenRouteFactories<TRoutesOrFactories>,
1013
1072
  FragnoFragmentSharedConfig<FlattenRouteFactories<TRoutesOrFactories>>
@@ -1030,12 +1089,15 @@ export function createClientBuilder<
1030
1089
  };
1031
1090
 
1032
1091
  const mountRoute = publicConfig.mountRoute ?? `/${definition.name}`;
1092
+ const mergedFetcherConfig = mergeFetcherConfigs(authorFetcherConfig, publicConfig.fetcherConfig);
1033
1093
  const fullPublicConfig = {
1034
1094
  ...publicConfig,
1035
1095
  mountRoute,
1096
+ fetcherConfig: mergedFetcherConfig,
1036
1097
  };
1037
1098
 
1038
1099
  return new ClientBuilder(fullPublicConfig, fragmentConfig);
1039
1100
  }
1040
1101
 
1041
1102
  export * from "./client-error";
1103
+ export type { FetcherConfig };
@@ -0,0 +1,59 @@
1
+ import type { FetcherConfig } from "../../api/fragment-instantiation";
2
+
3
+ /**
4
+ * Merge two fetcher configurations, with user config taking precedence.
5
+ * If user provides a custom function, it takes full precedence.
6
+ * Otherwise, deep merge RequestInit options.
7
+ */
8
+ export function mergeFetcherConfigs(
9
+ authorConfig?: FetcherConfig,
10
+ userConfig?: FetcherConfig,
11
+ ): FetcherConfig | undefined {
12
+ // If user provides custom function, it takes full precedence
13
+ if (userConfig?.type === "function") {
14
+ return userConfig;
15
+ }
16
+
17
+ if (!userConfig && authorConfig?.type === "function") {
18
+ return authorConfig;
19
+ }
20
+
21
+ // Deep merge RequestInit options
22
+ const authorOpts = authorConfig?.type === "options" ? authorConfig.options : {};
23
+ const userOpts = userConfig?.type === "options" ? userConfig.options : {};
24
+
25
+ // If both are empty, return undefined
26
+ if (Object.keys(authorOpts).length === 0 && Object.keys(userOpts).length === 0) {
27
+ return undefined;
28
+ }
29
+
30
+ return {
31
+ type: "options",
32
+ options: {
33
+ ...authorOpts,
34
+ ...userOpts,
35
+ headers: mergeHeaders(authorOpts.headers, userOpts.headers),
36
+ },
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Merge headers from author and user configs.
42
+ * User headers override author headers.
43
+ */
44
+ function mergeHeaders(author?: HeadersInit, user?: HeadersInit): HeadersInit | undefined {
45
+ if (!author && !user) {
46
+ return undefined;
47
+ }
48
+
49
+ // Convert to Headers objects and merge
50
+ const merged = new Headers(author);
51
+ new Headers(user).forEach((value, key) => merged.set(key, value));
52
+
53
+ // If no headers after merge, return undefined
54
+ if (merged.keys().next().done) {
55
+ return undefined;
56
+ }
57
+
58
+ return merged;
59
+ }