@fragno-dev/core 0.0.1

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 (108) hide show
  1. package/.turbo/turbo-build.log +61 -0
  2. package/.turbo/turbo-types$colon$check.log +2 -0
  3. package/dist/api/api.d.ts +2 -0
  4. package/dist/api/api.js +3 -0
  5. package/dist/api-CBDGZiLC.d.ts +278 -0
  6. package/dist/api-CBDGZiLC.d.ts.map +1 -0
  7. package/dist/api-DgHfYjq2.js +54 -0
  8. package/dist/api-DgHfYjq2.js.map +1 -0
  9. package/dist/client/client.d.ts +3 -0
  10. package/dist/client/client.js +6 -0
  11. package/dist/client/client.svelte.d.ts +33 -0
  12. package/dist/client/client.svelte.d.ts.map +1 -0
  13. package/dist/client/client.svelte.js +123 -0
  14. package/dist/client/client.svelte.js.map +1 -0
  15. package/dist/client/react.d.ts +58 -0
  16. package/dist/client/react.d.ts.map +1 -0
  17. package/dist/client/react.js +80 -0
  18. package/dist/client/react.js.map +1 -0
  19. package/dist/client/vanilla.d.ts +61 -0
  20. package/dist/client/vanilla.d.ts.map +1 -0
  21. package/dist/client/vanilla.js +136 -0
  22. package/dist/client/vanilla.js.map +1 -0
  23. package/dist/client/vue.d.ts +39 -0
  24. package/dist/client/vue.d.ts.map +1 -0
  25. package/dist/client/vue.js +108 -0
  26. package/dist/client/vue.js.map +1 -0
  27. package/dist/client-DWjxKDnE.js +703 -0
  28. package/dist/client-DWjxKDnE.js.map +1 -0
  29. package/dist/client-XFdAy-IQ.d.ts +287 -0
  30. package/dist/client-XFdAy-IQ.d.ts.map +1 -0
  31. package/dist/integrations/astro.d.ts +18 -0
  32. package/dist/integrations/astro.d.ts.map +1 -0
  33. package/dist/integrations/astro.js +16 -0
  34. package/dist/integrations/astro.js.map +1 -0
  35. package/dist/integrations/next-js.d.ts +15 -0
  36. package/dist/integrations/next-js.d.ts.map +1 -0
  37. package/dist/integrations/next-js.js +17 -0
  38. package/dist/integrations/next-js.js.map +1 -0
  39. package/dist/integrations/react-ssr.d.ts +19 -0
  40. package/dist/integrations/react-ssr.d.ts.map +1 -0
  41. package/dist/integrations/react-ssr.js +38 -0
  42. package/dist/integrations/react-ssr.js.map +1 -0
  43. package/dist/integrations/svelte-kit.d.ts +21 -0
  44. package/dist/integrations/svelte-kit.d.ts.map +1 -0
  45. package/dist/integrations/svelte-kit.js +18 -0
  46. package/dist/integrations/svelte-kit.js.map +1 -0
  47. package/dist/mod.d.ts +3 -0
  48. package/dist/mod.js +177 -0
  49. package/dist/mod.js.map +1 -0
  50. package/dist/route-Bp6eByhz.js +331 -0
  51. package/dist/route-Bp6eByhz.js.map +1 -0
  52. package/dist/ssr-tJHqcNSw.js +48 -0
  53. package/dist/ssr-tJHqcNSw.js.map +1 -0
  54. package/package.json +127 -0
  55. package/src/api/api.test.ts +140 -0
  56. package/src/api/api.ts +106 -0
  57. package/src/api/error.ts +47 -0
  58. package/src/api/fragment.test.ts +509 -0
  59. package/src/api/fragment.ts +277 -0
  60. package/src/api/internal/path-runtime.test.ts +121 -0
  61. package/src/api/internal/path-type.test.ts +602 -0
  62. package/src/api/internal/path.ts +322 -0
  63. package/src/api/internal/response-stream.ts +118 -0
  64. package/src/api/internal/route.test.ts +56 -0
  65. package/src/api/internal/route.ts +9 -0
  66. package/src/api/request-input-context.test.ts +437 -0
  67. package/src/api/request-input-context.ts +201 -0
  68. package/src/api/request-middleware.test.ts +544 -0
  69. package/src/api/request-middleware.ts +126 -0
  70. package/src/api/request-output-context.test.ts +626 -0
  71. package/src/api/request-output-context.ts +175 -0
  72. package/src/api/route.test.ts +176 -0
  73. package/src/api/route.ts +152 -0
  74. package/src/client/client-builder.test.ts +264 -0
  75. package/src/client/client-error.test.ts +15 -0
  76. package/src/client/client-error.ts +141 -0
  77. package/src/client/client-types.test.ts +493 -0
  78. package/src/client/client.ssr.test.ts +173 -0
  79. package/src/client/client.svelte.test.ts +837 -0
  80. package/src/client/client.svelte.ts +278 -0
  81. package/src/client/client.test.ts +1690 -0
  82. package/src/client/client.ts +1035 -0
  83. package/src/client/component.test.svelte +21 -0
  84. package/src/client/internal/ndjson-streaming.test.ts +457 -0
  85. package/src/client/internal/ndjson-streaming.ts +248 -0
  86. package/src/client/react.test.ts +947 -0
  87. package/src/client/react.ts +241 -0
  88. package/src/client/vanilla.test.ts +867 -0
  89. package/src/client/vanilla.ts +265 -0
  90. package/src/client/vue.test.ts +754 -0
  91. package/src/client/vue.ts +242 -0
  92. package/src/http/http-status.ts +60 -0
  93. package/src/integrations/astro.ts +17 -0
  94. package/src/integrations/next-js.ts +31 -0
  95. package/src/integrations/react-ssr.ts +40 -0
  96. package/src/integrations/svelte-kit.ts +41 -0
  97. package/src/mod.ts +20 -0
  98. package/src/util/async.test.ts +85 -0
  99. package/src/util/async.ts +96 -0
  100. package/src/util/content-type.test.ts +136 -0
  101. package/src/util/content-type.ts +84 -0
  102. package/src/util/nanostores.test.ts +28 -0
  103. package/src/util/nanostores.ts +65 -0
  104. package/src/util/ssr.ts +75 -0
  105. package/src/util/types-util.ts +16 -0
  106. package/tsconfig.json +10 -0
  107. package/tsdown.config.ts +21 -0
  108. package/vitest.config.ts +10 -0
@@ -0,0 +1,837 @@
1
+ import { test, expect, describe, vi, beforeEach, afterEach, assert } from "vitest";
2
+ import { type FragnoPublicClientConfig } from "../mod";
3
+ import { createClientBuilder } from "./client";
4
+ import { render } from "@testing-library/svelte";
5
+ import { defineRoute } from "../api/route";
6
+ import { defineFragment } from "../api/fragment";
7
+ import { z } from "zod";
8
+ import { readableToAtom, useFragno } from "./client.svelte";
9
+ import { writable, readable, get, derived } from "svelte/store";
10
+ import { FragnoClientUnknownApiError } from "./client-error";
11
+ import TestComponent from "./component.test.svelte";
12
+ import { atom, computed } from "nanostores";
13
+ import { RequestOutputContext } from "../api/request-output-context";
14
+
15
+ function renderHook(
16
+ clientObj: Record<string, unknown>,
17
+ hookName: string,
18
+ args: Record<string, unknown> = {},
19
+ ) {
20
+ const { component } = render(TestComponent, {
21
+ props: { clientObj, hookName, args },
22
+ });
23
+
24
+ return {
25
+ loading: component["loading"],
26
+ data: component["data"],
27
+ error: component["error"],
28
+ };
29
+ }
30
+
31
+ function renderMutator(
32
+ clientObj: Record<string, unknown>,
33
+ hookName: string,
34
+ args: Record<string, unknown> = {},
35
+ ) {
36
+ const { component } = render(TestComponent, {
37
+ props: { clientObj, hookName, args },
38
+ });
39
+
40
+ return {
41
+ loading: component["loading"],
42
+ data: component["data"],
43
+ error: component["error"],
44
+ mutate: component["mutate"],
45
+ };
46
+ }
47
+
48
+ global.fetch = vi.fn();
49
+
50
+ describe("readableToAtom", () => {
51
+ test("should create an atom from a readable store", () => {
52
+ const store = readable(123, () => {});
53
+ const a = readableToAtom(store);
54
+ expect(a.get()).toBe(123);
55
+ });
56
+
57
+ test("should update the atom when the writable store changes", async () => {
58
+ const store = writable(123);
59
+ const a = readableToAtom(store);
60
+ expect(a.get()).toBe(123);
61
+ store.set(456);
62
+ expect(a.get()).toBe(456);
63
+ });
64
+
65
+ test("should work with derived stores", async () => {
66
+ const baseStore = writable(10);
67
+ const derivedStore = derived(baseStore, (value) => value * 2);
68
+
69
+ const a = readableToAtom(derivedStore);
70
+ expect(a.get()).toBe(20);
71
+
72
+ baseStore.set(5);
73
+ expect(a.get()).toBe(10);
74
+ });
75
+ });
76
+
77
+ describe("createSvelteHook", () => {
78
+ const clientConfig: FragnoPublicClientConfig = {
79
+ baseUrl: "http://localhost:3000",
80
+ };
81
+
82
+ beforeEach(() => {
83
+ vi.clearAllMocks();
84
+ (global.fetch as ReturnType<typeof vi.fn>).mockReset();
85
+ });
86
+
87
+ afterEach(() => {
88
+ vi.restoreAllMocks();
89
+ });
90
+
91
+ test("Hook should function", async () => {
92
+ const testFragmentDefinition = defineFragment("test-fragment");
93
+ const testRoutes = [
94
+ defineRoute({
95
+ method: "GET",
96
+ path: "/users",
97
+ outputSchema: z.array(z.object({ id: z.number(), name: z.string() })),
98
+ handler: async (_ctx, { json }) => json([{ id: 1, name: "John" }]),
99
+ }),
100
+ ] as const;
101
+
102
+ vi.mocked(global.fetch).mockImplementationOnce(
103
+ async () =>
104
+ ({
105
+ headers: new Headers(),
106
+ ok: true,
107
+ json: async () => [{ id: 1, name: "John" }],
108
+ }) as Response,
109
+ );
110
+
111
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
112
+ const clientObj = {
113
+ useUsers: client.createHook("/users"),
114
+ };
115
+
116
+ const hook = renderHook(clientObj, "useUsers");
117
+
118
+ await vi.waitFor(() => {
119
+ expect(get(hook.loading)).toBe(false);
120
+ });
121
+
122
+ expect(get(hook.data)).toEqual([{ id: 1, name: "John" }]);
123
+ expect(get(hook.error)).toBeUndefined();
124
+ });
125
+
126
+ test("Should support path parameters and update reactively when Svelte store changes", async () => {
127
+ const testFragmentDefinition = defineFragment("test-fragment");
128
+ type TestData = {
129
+ id: number;
130
+ name: string;
131
+ };
132
+ const testRoutes = [
133
+ defineRoute({
134
+ method: "GET",
135
+ path: "/users/:id",
136
+ outputSchema: z.object({ id: z.number(), name: z.string() }),
137
+ handler: async ({ pathParams }, { json }) =>
138
+ json({ id: Number(pathParams["id"]), name: "John" } satisfies TestData),
139
+ }),
140
+ ] as const;
141
+
142
+ // Mock fetch to extract the user ID from the URL and return a user object with that ID.
143
+ vi.mocked(global.fetch).mockImplementation(async (input) => {
144
+ assert(typeof input === "string");
145
+
146
+ // Regex to extract id value from a URL string, matching only on /users/:id
147
+ const [, id] = String(input).match(/\/users\/([^/]+)/) ?? [];
148
+
149
+ expect(id).toBeDefined();
150
+ expect(+id).not.toBeNaN();
151
+
152
+ return {
153
+ headers: new Headers(),
154
+ ok: true,
155
+ json: async () => ({ id: Number(id), name: "John" }),
156
+ } as Response;
157
+ });
158
+
159
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
160
+ const clientObj = {
161
+ useUser: client.createHook("/users/:id"),
162
+ };
163
+
164
+ const id = writable("123");
165
+
166
+ const hook = renderHook(clientObj, "useUser", { path: { id } });
167
+
168
+ await vi.waitFor(() => {
169
+ expect(get(hook.loading)).toBe(false);
170
+ });
171
+
172
+ expect(get(hook.data)).toEqual({ id: 123, name: "John" });
173
+ expect(get(hook.error)).toBeUndefined();
174
+
175
+ // Update the id value
176
+ id.set("456");
177
+
178
+ await vi.waitFor(() => {
179
+ expect((get(hook.data) as TestData | undefined)?.id).toBe(456);
180
+ });
181
+
182
+ expect(fetch).toHaveBeenCalledTimes(2);
183
+ });
184
+
185
+ test("Should support path parameters and update reactively when Nanostores Atom changes", async () => {
186
+ const testFragmentDefinition = defineFragment("test-fragment");
187
+ type TestData = { id: number; name: string };
188
+ const testRoutes = [
189
+ defineRoute({
190
+ method: "GET",
191
+ path: "/users/:id",
192
+ outputSchema: z.object({ id: z.number(), name: z.string() }),
193
+ handler: async ({ pathParams }, { json }) =>
194
+ json({ id: Number(pathParams["id"]), name: "John" } satisfies TestData),
195
+ }),
196
+ ] as const;
197
+
198
+ // Mock fetch to extract the user ID from the URL and return a user object with that ID.
199
+ vi.mocked(global.fetch).mockImplementation(async (input) => {
200
+ assert(typeof input === "string");
201
+
202
+ // Regex to extract id value from a URL string, matching only on /users/:id
203
+ const [, id] = String(input).match(/\/users\/([^/]+)/) ?? [];
204
+
205
+ expect(id).toBeDefined();
206
+ expect(+id).not.toBeNaN();
207
+
208
+ return {
209
+ headers: new Headers(),
210
+ ok: true,
211
+ json: async () => ({ id: Number(id), name: "John" }),
212
+ } as Response;
213
+ });
214
+
215
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
216
+ const clientObj = {
217
+ useUser: client.createHook("/users/:id"),
218
+ };
219
+
220
+ const id = atom("123");
221
+
222
+ const hook = renderHook(clientObj, "useUser", { path: { id } });
223
+
224
+ await vi.waitFor(() => {
225
+ expect(get(hook.loading)).toBe(false);
226
+ });
227
+
228
+ expect(get(hook.data)).toEqual({ id: 123, name: "John" });
229
+ expect(get(hook.error)).toBeUndefined();
230
+
231
+ // Update the id value
232
+ id.set("456");
233
+
234
+ await vi.waitFor(() => {
235
+ expect((get(hook.data) as TestData | undefined)?.id).toBe(456);
236
+ });
237
+
238
+ expect(fetch).toHaveBeenCalledTimes(2);
239
+ });
240
+
241
+ test("Should handle errors gracefully", async () => {
242
+ const testFragmentDefinition = defineFragment("test-fragment");
243
+ const testRoutes = [
244
+ defineRoute({
245
+ method: "GET",
246
+ path: "/users",
247
+ outputSchema: z.array(z.object({ id: z.number(), name: z.string() })),
248
+ handler: async (_ctx, { json }) => json([{ id: 1, name: "John" }]),
249
+ }),
250
+ ] as const;
251
+
252
+ vi.mocked(global.fetch).mockImplementationOnce(
253
+ async () =>
254
+ ({
255
+ headers: new Headers(),
256
+ ok: false,
257
+ status: 500,
258
+ statusText: "Internal Server Error",
259
+ json: async () => ({ message: "Server error" }),
260
+ }) as Response,
261
+ );
262
+
263
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
264
+ const clientObj = {
265
+ useUsers: client.createHook("/users"),
266
+ };
267
+
268
+ const hook = renderHook(clientObj, "useUsers");
269
+
270
+ await vi.waitFor(() => {
271
+ expect(get(hook.loading)).toBe(false);
272
+ });
273
+
274
+ expect(get(hook.data)).toBeUndefined();
275
+ expect(get(hook.error)).toBeDefined();
276
+ expect(get(hook.error)).toBeInstanceOf(FragnoClientUnknownApiError);
277
+ });
278
+
279
+ test("Should track loading states correctly", async () => {
280
+ const testFragmentDefinition = defineFragment("test-fragment");
281
+ const testRoutes = [
282
+ defineRoute({
283
+ method: "GET",
284
+ path: "/users",
285
+ outputSchema: z.array(z.object({ id: z.number(), name: z.string() })),
286
+ handler: async (_ctx, { json }) => json([{ id: 1, name: "John" }]),
287
+ }),
288
+ ] as const;
289
+
290
+ let resolvePromise: (value: Response) => void;
291
+ const fetchPromise = new Promise<Response>((resolve) => {
292
+ resolvePromise = resolve;
293
+ });
294
+
295
+ vi.mocked(global.fetch).mockImplementationOnce(() => fetchPromise);
296
+
297
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
298
+ const clientObj = {
299
+ useUsers: client.createHook("/users"),
300
+ };
301
+
302
+ const hook = renderHook(clientObj, "useUsers");
303
+
304
+ // Initially loading should be true
305
+ expect(get(hook.loading)).toBe(true);
306
+ expect(get(hook.data)).toBeUndefined();
307
+ expect(get(hook.error)).toBeUndefined();
308
+
309
+ // Resolve the fetch
310
+ resolvePromise!({
311
+ headers: new Headers(),
312
+ ok: true,
313
+ json: async () => [{ id: 1, name: "John" }],
314
+ } as Response);
315
+
316
+ await vi.waitFor(() => {
317
+ expect(get(hook.loading)).toBe(false);
318
+ });
319
+
320
+ expect(get(hook.data)).toEqual([{ id: 1, name: "John" }]);
321
+ expect(get(hook.error)).toBeUndefined();
322
+ });
323
+
324
+ test("Should handle query parameters", async () => {
325
+ const testFragmentDefinition = defineFragment("test-fragment");
326
+ const testRoutes = [
327
+ defineRoute({
328
+ method: "GET",
329
+ path: "/users",
330
+ outputSchema: z.array(z.object({ id: z.number(), name: z.string() })),
331
+ handler: async (_ctx, { json }) => json([{ id: 1, name: "John" }]),
332
+ }),
333
+ ] as const;
334
+
335
+ vi.mocked(global.fetch).mockImplementation(async (input) => {
336
+ assert(typeof input === "string");
337
+ expect(input).toContain("limit=10");
338
+ expect(input).toContain("page=1");
339
+
340
+ return {
341
+ headers: new Headers(),
342
+ ok: true,
343
+ json: async () => [{ id: 1, name: "John" }],
344
+ } as Response;
345
+ });
346
+
347
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
348
+ const clientObj = {
349
+ useUsers: client.createHook("/users"),
350
+ };
351
+
352
+ const limit = writable("10");
353
+ const page = writable("1");
354
+
355
+ const hook = renderHook(clientObj, "useUsers", { query: { limit, page } });
356
+
357
+ await vi.waitFor(() => {
358
+ expect(get(hook.loading)).toBe(false);
359
+ });
360
+
361
+ expect(get(hook.data)).toEqual([{ id: 1, name: "John" }]);
362
+ expect(get(hook.error)).toBeUndefined();
363
+
364
+ // Update query params
365
+ limit.set("20");
366
+ page.set("2");
367
+
368
+ await vi.waitFor(() => {
369
+ expect(vi.mocked(global.fetch).mock.calls.length).toBe(2);
370
+ });
371
+
372
+ // Verify the second call has updated params
373
+ const lastCall = vi.mocked(global.fetch).mock.calls[1];
374
+ expect(lastCall[0]).toContain("limit=20");
375
+ expect(lastCall[0]).toContain("page=2");
376
+ });
377
+
378
+ test("Should handle multiple hooks together", async () => {
379
+ const testFragmentDefinition = defineFragment("test-fragment");
380
+ const testRoutes = [
381
+ defineRoute({
382
+ method: "GET",
383
+ path: "/users",
384
+ outputSchema: z.array(z.object({ id: z.number(), name: z.string() })),
385
+ handler: async (_ctx, { json }) => json([{ id: 1, name: "John" }]),
386
+ }),
387
+ defineRoute({
388
+ method: "GET",
389
+ path: "/posts",
390
+ outputSchema: z.array(z.object({ id: z.number(), title: z.string() })),
391
+ handler: async (_ctx, { json }) => json([{ id: 1, title: "First Post" }]),
392
+ }),
393
+ ] as const;
394
+
395
+ vi.mocked(global.fetch).mockImplementation(async (input) => {
396
+ assert(typeof input === "string");
397
+
398
+ if (input.includes("/users")) {
399
+ return {
400
+ headers: new Headers(),
401
+ ok: true,
402
+ json: async () => [{ id: 1, name: "John" }],
403
+ } as Response;
404
+ } else if (input.includes("/posts")) {
405
+ return {
406
+ headers: new Headers(),
407
+ ok: true,
408
+ json: async () => [{ id: 1, title: "First Post" }],
409
+ } as Response;
410
+ }
411
+
412
+ throw new Error(`Unexpected URL: ${input}`);
413
+ });
414
+
415
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
416
+ const clientObj = {
417
+ useUsers: client.createHook("/users"),
418
+ usePosts: client.createHook("/posts"),
419
+ };
420
+
421
+ // Render two separate hooks
422
+ const usersHook = renderHook(clientObj, "useUsers");
423
+ const postsHook = renderHook(clientObj, "usePosts");
424
+
425
+ await vi.waitFor(() => {
426
+ expect(get(usersHook.loading)).toBe(false);
427
+ expect(get(postsHook.loading)).toBe(false);
428
+ });
429
+
430
+ expect(get(usersHook.data)).toEqual([{ id: 1, name: "John" }]);
431
+ expect(get(postsHook.data)).toEqual([{ id: 1, title: "First Post" }]);
432
+ });
433
+
434
+ test("Should handle mixed reactive parameters - writable path param, atom and writable query params, with reactive updates", async () => {
435
+ const testFragmentDefinition = defineFragment("test-fragment");
436
+ const testRoutes = [
437
+ defineRoute({
438
+ method: "GET",
439
+ path: "/users/:id/posts",
440
+ inputSchema: z.object({
441
+ limit: z.string().optional(),
442
+ category: z.string().optional(),
443
+ sort: z.string().optional(),
444
+ }),
445
+ outputSchema: z.array(
446
+ z.object({ id: z.number(), title: z.string(), category: z.string() }),
447
+ ),
448
+ handler: async (_ctx, { empty }) => empty(),
449
+ }),
450
+ ] as const;
451
+
452
+ type TestData = {
453
+ id: number;
454
+ title: string;
455
+ category: string;
456
+ };
457
+
458
+ // Mock fetch to verify URL construction and parameter passing
459
+ vi.mocked(global.fetch).mockImplementation(async (input) => {
460
+ assert(typeof input === "string");
461
+
462
+ // Extract user ID from path
463
+ const [, userId] = String(input).match(/\/users\/([^/]+)\/posts/) ?? [];
464
+ expect(userId).toBeDefined();
465
+ expect(+userId).not.toBeNaN();
466
+
467
+ // Parse query parameters
468
+ const url = new URL(input);
469
+ const limit = url.searchParams.get("limit") || "5";
470
+ const category = url.searchParams.get("category") || "general";
471
+ const sort = url.searchParams.get("sort") || "asc";
472
+ return {
473
+ headers: new Headers(),
474
+ ok: true,
475
+ json: async () =>
476
+ [
477
+ {
478
+ id: Number(userId) * 100,
479
+ title: `Post for user ${userId}`,
480
+ category: `${category}-${limit}-${sort}`,
481
+ },
482
+ ] satisfies TestData[],
483
+ } as Response;
484
+ });
485
+
486
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
487
+ const clientObj = {
488
+ useUserPosts: client.createHook("/users/:id/posts"),
489
+ };
490
+
491
+ // Set up reactive parameters
492
+ const userId = writable("1"); // Svelte writable for path parameter
493
+ const limit = atom("10"); // Nanostores atom for query parameter
494
+ const category = writable("tech"); // Svelte writable for query parameter
495
+ const sort = "desc"; // Normal string for query parameter
496
+
497
+ const hook = renderHook(clientObj, "useUserPosts", {
498
+ path: { id: userId },
499
+ query: { limit, category, sort },
500
+ });
501
+
502
+ // Wait for initial load
503
+ await vi.waitFor(() => {
504
+ expect(get(hook.loading)).toBe(false);
505
+ });
506
+
507
+ expect(get(hook.data)).toEqual([
508
+ { id: 100, title: "Post for user 1", category: "tech-10-desc" },
509
+ ]);
510
+ expect(get(hook.error)).toBeUndefined();
511
+ expect(fetch).toHaveBeenCalledTimes(1);
512
+
513
+ // Verify initial URL construction
514
+ const firstCall = vi.mocked(global.fetch).mock.calls[0][0] as string;
515
+ expect(firstCall).toContain("/users/1/posts");
516
+ expect(firstCall).toContain("limit=10");
517
+ expect(firstCall).toContain("category=tech");
518
+ expect(firstCall).toContain("sort=desc");
519
+
520
+ // Update the Svelte writable path parameter
521
+ userId.set("2");
522
+
523
+ await vi.waitFor(() => {
524
+ expect((get(hook.data) as TestData[])?.[0]?.id).toBe(200);
525
+ });
526
+
527
+ expect(fetch).toHaveBeenCalledTimes(2);
528
+
529
+ // Update the nanostores atom query parameter
530
+ limit.set("20");
531
+
532
+ await vi.waitFor(() => {
533
+ expect((get(hook.data) as TestData[])?.[0]?.category?.includes("20")).toBe(true);
534
+ });
535
+
536
+ expect(fetch).toHaveBeenCalledTimes(3);
537
+
538
+ // Update the Svelte writable query parameter
539
+ category.set("science");
540
+
541
+ await vi.waitFor(() => {
542
+ expect((get(hook.data) as TestData[])?.[0]?.category?.includes("science")).toBe(true);
543
+ });
544
+
545
+ expect(fetch).toHaveBeenCalledTimes(4);
546
+ });
547
+ });
548
+
549
+ describe("createSvelteMutator", () => {
550
+ const clientConfig: FragnoPublicClientConfig = {
551
+ baseUrl: "http://localhost:3000",
552
+ };
553
+
554
+ beforeEach(() => {
555
+ vi.clearAllMocks();
556
+ (global.fetch as ReturnType<typeof vi.fn>).mockReset();
557
+ });
558
+
559
+ afterEach(() => {
560
+ vi.restoreAllMocks();
561
+ });
562
+
563
+ test("Should handle mutator hooks", async () => {
564
+ const testFragmentDefinition = defineFragment("test-fragment");
565
+ const testRoutes = [
566
+ defineRoute({
567
+ method: "POST",
568
+ path: "/users",
569
+ inputSchema: z.object({ name: z.string(), email: z.string() }),
570
+ outputSchema: z.object({ id: z.number(), name: z.string(), email: z.string() }),
571
+ handler: async ({ input }, { json }) => {
572
+ const { name, email } = await input.valid();
573
+ return json({ id: 1, name, email });
574
+ },
575
+ }),
576
+ ] as const;
577
+
578
+ vi.mocked(global.fetch).mockImplementationOnce(
579
+ async () =>
580
+ ({
581
+ headers: new Headers(),
582
+ ok: true,
583
+ json: async () => ({ id: 1, name: "John Doe", email: "john@example.com" }),
584
+ }) as Response,
585
+ );
586
+
587
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
588
+ const clientObj = {
589
+ createUser: client.createMutator("POST", "/users"),
590
+ };
591
+
592
+ const mutator = renderMutator(clientObj, "createUser");
593
+
594
+ expect(get(mutator.loading)).toBe(false);
595
+ expect(get(mutator.data)).toBeUndefined();
596
+ expect(get(mutator.error)).toBeUndefined();
597
+
598
+ const result = await mutator.mutate({
599
+ body: { name: "John Doe", email: "john@example.com" },
600
+ });
601
+
602
+ expect(result).toEqual({ id: 1, name: "John Doe", email: "john@example.com" });
603
+ expect(get(mutator.data)).toEqual({ id: 1, name: "John Doe", email: "john@example.com" });
604
+ expect(get(mutator.error)).toBeUndefined();
605
+ });
606
+
607
+ test("Should handle mutator with path parameters", async () => {
608
+ const testFragmentDefinition = defineFragment("test-fragment");
609
+ const testRoutes = [
610
+ defineRoute({
611
+ method: "PUT",
612
+ path: "/users/:id",
613
+ inputSchema: z.object({ name: z.string() }),
614
+ outputSchema: z.object({ id: z.number(), name: z.string() }),
615
+ handler: async ({ pathParams, input }, { json }) => {
616
+ const { name } = await input.valid();
617
+ return json({ id: Number(pathParams["id"]), name });
618
+ },
619
+ }),
620
+ ] as const;
621
+
622
+ vi.mocked(global.fetch).mockImplementation(async (input) => {
623
+ assert(typeof input === "string");
624
+ const [, id] = String(input).match(/\/users\/([^/]+)/) ?? [];
625
+
626
+ return {
627
+ headers: new Headers(),
628
+ ok: true,
629
+ json: async () => ({ id: Number(id), name: "Updated Name" }),
630
+ } as Response;
631
+ });
632
+
633
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
634
+ const clientObj = {
635
+ updateUser: client.createMutator("PUT", "/users/:id"),
636
+ };
637
+
638
+ const mutator = renderMutator(clientObj, "updateUser");
639
+
640
+ const userId = writable("42");
641
+ const result = await mutator.mutate({
642
+ body: { name: "Updated Name" },
643
+ path: { id: userId },
644
+ });
645
+
646
+ expect(result).toEqual({ id: 42, name: "Updated Name" });
647
+ expect(get(mutator.data)).toEqual({ id: 42, name: "Updated Name" });
648
+ expect(get(mutator.error)).toBeUndefined();
649
+
650
+ // Update the writable and call again
651
+ userId.set("100");
652
+ const result2 = await mutator.mutate({
653
+ body: { name: "Another Name" },
654
+ path: { id: userId },
655
+ });
656
+
657
+ expect(result2).toEqual({ id: 100, name: "Updated Name" });
658
+ });
659
+ });
660
+
661
+ describe("useFragno", () => {
662
+ const clientConfig: FragnoPublicClientConfig = {
663
+ baseUrl: "http://localhost:3000",
664
+ };
665
+
666
+ const testFragmentDefinition = defineFragment("test-fragment");
667
+ const testRoutes = [
668
+ defineRoute({
669
+ method: "GET",
670
+ path: "/data",
671
+ outputSchema: z.string(),
672
+ handler: async (_ctx, { json }) => json("test data"),
673
+ }),
674
+ defineRoute({
675
+ method: "POST",
676
+ path: "/action",
677
+ inputSchema: z.object({ value: z.string() }),
678
+ outputSchema: z.object({ result: z.string() }),
679
+ handler: async (_ctx, { json }) => json({ result: "test value" }),
680
+ }),
681
+ ] as const;
682
+
683
+ test("should pass through non-hook values unchanged", () => {
684
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
685
+ const clientObj = {
686
+ useData: client.createHook("/data"),
687
+ usePostAction: client.createMutator("POST", "/action"),
688
+ someString: "hello world",
689
+ someNumber: 42,
690
+ someObject: { foo: "bar", nested: { value: true } },
691
+ someArray: [1, 2, 3],
692
+ someFunction: () => "test",
693
+ someNull: null,
694
+ someUndefined: undefined,
695
+ };
696
+
697
+ const result = useFragno(clientObj);
698
+
699
+ // Check that non-hook values are passed through unchanged
700
+ expect(result.someString).toBe("hello world");
701
+ expect(result.someNumber).toBe(42);
702
+ expect(result.someObject).toEqual({ foo: "bar", nested: { value: true } });
703
+ expect(result.someArray).toEqual([1, 2, 3]);
704
+ expect(result.someFunction()).toBe("test");
705
+ expect(result.someNull).toBeNull();
706
+ expect(result.someUndefined).toBeUndefined();
707
+
708
+ // Verify that hooks are still transformed
709
+ expect(typeof result.useData).toBe("function");
710
+ expect(typeof result.usePostAction).toBe("function");
711
+ });
712
+
713
+ test("Should support path parameters and update reactively when using Svelte runes", async () => {
714
+ const testFragmentDefinition = defineFragment("test-fragment");
715
+ type TestData = {
716
+ id: number;
717
+ name: string;
718
+ };
719
+ const testRoutes = [
720
+ defineRoute({
721
+ method: "GET",
722
+ path: "/users/:id",
723
+ outputSchema: z.object({ id: z.number(), name: z.string() }),
724
+ handler: async ({ pathParams }, { json }) =>
725
+ json({ id: Number(pathParams["id"]), name: "John" } satisfies TestData),
726
+ }),
727
+ ] as const;
728
+
729
+ // Mock fetch to extract the user ID from the URL and return a user object with that ID.
730
+ vi.mocked(global.fetch).mockImplementation(async (input) => {
731
+ assert(typeof input === "string");
732
+
733
+ // Regex to extract id value from a URL string, matching only on /users/:id
734
+ const [, id] = String(input).match(/\/users\/([^/]+)/) ?? [];
735
+
736
+ expect(id).toBeDefined();
737
+ expect(+id).not.toBeNaN();
738
+
739
+ return {
740
+ headers: new Headers(),
741
+ ok: true,
742
+ json: async () => ({ id: Number(id), name: "John" }),
743
+ } as Response;
744
+ });
745
+
746
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
747
+ const clientObj = {
748
+ useUser: client.createHook("/users/:id"),
749
+ };
750
+
751
+ let id = $state("123");
752
+
753
+ const hook = renderHook(clientObj, "useUser", { path: { id: () => id } });
754
+
755
+ await vi.waitFor(() => {
756
+ expect(get(hook.loading)).toBe(false);
757
+ });
758
+
759
+ expect(get(hook.data)).toEqual({ id: 123, name: "John" });
760
+ expect(get(hook.error)).toBeUndefined();
761
+
762
+ // Update the id value
763
+ id = "456";
764
+
765
+ await vi.waitFor(() => {
766
+ expect((get(hook.data) as TestData | undefined)?.id).toBe(456);
767
+ });
768
+
769
+ expect(fetch).toHaveBeenCalledTimes(2);
770
+ });
771
+ });
772
+
773
+ describe("createSvelteStore", () => {
774
+ const clientConfig: FragnoPublicClientConfig = {
775
+ baseUrl: "http://localhost:3000",
776
+ };
777
+
778
+ beforeEach(() => {
779
+ vi.clearAllMocks();
780
+ (global.fetch as ReturnType<typeof vi.fn>).mockReset();
781
+ });
782
+
783
+ afterEach(() => {
784
+ vi.restoreAllMocks();
785
+ });
786
+
787
+ test("streaming routes", async () => {
788
+ const streamFragmentDefinition = defineFragment("stream-fragment");
789
+ const streamRoutes = [
790
+ defineRoute({
791
+ method: "GET",
792
+ path: "/users-stream",
793
+ outputSchema: z.array(z.object({ id: z.number(), name: z.string() })),
794
+ handler: async () => {
795
+ throw new Error("Not implemented");
796
+ },
797
+ }),
798
+ ] as const;
799
+ const cb = createClientBuilder(streamFragmentDefinition, clientConfig, streamRoutes);
800
+ const usersStream = cb.createHook("/users-stream");
801
+
802
+ // Create a single shared store instance
803
+ const sharedStore = usersStream.store({});
804
+
805
+ const names = computed(sharedStore, ({ data }) => {
806
+ return (data ?? []).map((user) => user.name).join(", ");
807
+ });
808
+
809
+ const client = {
810
+ useUsersStream: cb.createStore(sharedStore),
811
+ useNames: cb.createStore(names),
812
+ };
813
+
814
+ vi.mocked(global.fetch).mockImplementation(async () => {
815
+ const ctx = new RequestOutputContext(streamRoutes[0].outputSchema);
816
+ return ctx.jsonStream(async (stream) => {
817
+ await stream.write({ id: 1, name: "John" });
818
+ await stream.sleep(0);
819
+ await stream.write({ id: 2, name: "Jane" });
820
+ await stream.sleep(0);
821
+ await stream.write({ id: 3, name: "Jim" });
822
+ });
823
+ });
824
+
825
+ const { useUsersStream, useNames } = useFragno(client);
826
+
827
+ await vi.waitFor(() => {
828
+ expect(useNames.get()).toEqual("John, Jane, Jim");
829
+ expect(useUsersStream.get().loading).toBe(false);
830
+ expect(useUsersStream.get().data).toEqual([
831
+ { id: 1, name: "John" },
832
+ { id: 2, name: "Jane" },
833
+ { id: 3, name: "Jim" },
834
+ ]);
835
+ });
836
+ });
837
+ });