@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.
- package/.turbo/turbo-build.log +49 -45
- package/CHANGELOG.md +53 -0
- package/dist/api/api.d.ts +2 -2
- package/dist/api/fragment-builder.d.ts +3 -2
- package/dist/api/fragment-instantiation.d.ts +4 -3
- package/dist/api/fragment-instantiation.js +3 -3
- package/dist/api/route.d.ts +3 -0
- package/dist/api/route.js +3 -0
- package/dist/{api-BX90b4-D.d.ts → api-CoCkNi6h.d.ts} +20 -7
- package/dist/api-CoCkNi6h.d.ts.map +1 -0
- package/dist/api-DngJDcmO.js.map +1 -1
- package/dist/client/client.d.ts +4 -3
- package/dist/client/client.js +3 -3
- package/dist/client/client.svelte.d.ts +3 -3
- package/dist/client/client.svelte.d.ts.map +1 -1
- package/dist/client/client.svelte.js +3 -3
- package/dist/client/react.d.ts +3 -3
- package/dist/client/react.d.ts.map +1 -1
- package/dist/client/react.js +3 -3
- package/dist/client/solid.d.ts +3 -3
- package/dist/client/solid.d.ts.map +1 -1
- package/dist/client/solid.js +3 -3
- package/dist/client/vanilla.d.ts +3 -3
- package/dist/client/vanilla.d.ts.map +1 -1
- package/dist/client/vanilla.js +3 -3
- package/dist/client/vue.d.ts +3 -3
- package/dist/client/vue.d.ts.map +1 -1
- package/dist/client/vue.js +3 -3
- package/dist/{client-C6LChM0Y.js → client-DJfCJiHK.js} +81 -7
- package/dist/client-DJfCJiHK.js.map +1 -0
- package/dist/{fragment-builder-BZr2JkuW.d.ts → fragment-builder-8-tiECi5.d.ts} +75 -38
- package/dist/fragment-builder-8-tiECi5.d.ts.map +1 -0
- package/dist/{fragment-instantiation-D74OQjbn.js → fragment-instantiation-C4wvwl6V.js} +129 -6
- package/dist/fragment-instantiation-C4wvwl6V.js.map +1 -0
- package/dist/mod.d.ts +3 -2
- package/dist/mod.js +3 -3
- package/dist/{route-D1MZR6JL.js → request-output-context-CdIjwmEN.js} +22 -33
- package/dist/request-output-context-CdIjwmEN.js.map +1 -0
- package/dist/route-C5Uryylh.js +21 -0
- package/dist/route-C5Uryylh.js.map +1 -0
- package/dist/route-mGLYSUvD.d.ts +26 -0
- package/dist/route-mGLYSUvD.d.ts.map +1 -0
- package/dist/test/test.d.ts +24 -70
- package/dist/test/test.d.ts.map +1 -1
- package/dist/test/test.js +27 -115
- package/dist/test/test.js.map +1 -1
- package/package.json +6 -1
- package/src/api/api.ts +1 -0
- package/src/api/fragment-instantiation.test.ts +460 -0
- package/src/api/fragment-instantiation.ts +157 -5
- package/src/api/fragno-response.ts +132 -0
- package/src/api/request-input-context.test.ts +37 -29
- package/src/api/request-input-context.ts +16 -14
- package/src/api/request-output-context.test.ts +10 -10
- package/src/api/request-output-context.ts +3 -3
- package/src/api/route-handler-input-options.ts +15 -0
- package/src/client/client.test.ts +264 -0
- package/src/client/client.ts +65 -3
- package/src/client/internal/fetcher-merge.ts +59 -0
- package/src/test/test.test.ts +110 -165
- package/src/test/test.ts +56 -266
- package/tsdown.config.ts +1 -0
- package/dist/api-BX90b4-D.d.ts.map +0 -1
- package/dist/client-C6LChM0Y.js.map +0 -1
- package/dist/fragment-builder-BZr2JkuW.d.ts.map +0 -1
- package/dist/fragment-instantiation-D74OQjbn.js.map +0 -1
- package/dist/route-CTxjMtGZ.js +0 -10
- package/dist/route-CTxjMtGZ.js.map +0 -1
- 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:
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.
|
|
57
|
-
expect(body).toBe(
|
|
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.
|
|
67
|
-
expect(body).toBe(
|
|
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.
|
|
79
|
-
expect(body).toBe(
|
|
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.
|
|
91
|
-
expect(body).toBe(
|
|
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.
|
|
107
|
-
expect(body).toBe(
|
|
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
|
|
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
|
|
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
|
|
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
|
+
});
|
package/src/client/client.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
+
}
|