@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,867 @@
1
+ import { test, expect, describe, vi, beforeEach, afterEach } from "vitest";
2
+ import { atom } from "nanostores";
3
+ import { z } from "zod";
4
+ import { createClientBuilder } from "./client";
5
+ import { useFragno } from "./vanilla";
6
+ import { defineRoute } from "../api/route";
7
+ import { defineFragment } from "../api/fragment";
8
+ import type { FragnoPublicClientConfig } from "../mod";
9
+ import { FragnoClientFetchNetworkError } from "./client-error";
10
+ import { waitForAsyncIterator } from "../util/async";
11
+
12
+ // Mock fetch globally
13
+ global.fetch = vi.fn();
14
+
15
+ describe("createVanillaListeners", () => {
16
+ const testFragmentDefinition = defineFragment("test-fragment");
17
+ const testRoutes = [
18
+ defineRoute({
19
+ method: "GET",
20
+ path: "/users",
21
+ outputSchema: z.array(z.object({ id: z.number(), name: z.string() })),
22
+ handler: async (_ctx, { json }) => json([{ id: 1, name: "John" }]),
23
+ }),
24
+ defineRoute({
25
+ method: "GET",
26
+ path: "/users/:id",
27
+ outputSchema: z.object({ id: z.number(), name: z.string() }),
28
+ handler: async ({ pathParams }, { json }) =>
29
+ json({ id: Number(pathParams["id"]), name: "John" }),
30
+ }),
31
+ defineRoute({
32
+ method: "GET",
33
+ path: "/search",
34
+ outputSchema: z.array(z.string()),
35
+ handler: async (_ctx, { json }) => json(["result1", "result2"]),
36
+ }),
37
+ ] as const;
38
+
39
+ const clientConfig: FragnoPublicClientConfig = {
40
+ baseUrl: "http://localhost:3000",
41
+ };
42
+
43
+ beforeEach(() => {
44
+ vi.clearAllMocks();
45
+ (global.fetch as ReturnType<typeof vi.fn>).mockReset();
46
+ });
47
+
48
+ afterEach(() => {
49
+ vi.restoreAllMocks();
50
+ });
51
+
52
+ test("should create vanilla listeners for a simple GET route", async () => {
53
+ (global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
54
+ headers: new Headers(),
55
+ ok: true,
56
+ json: async () => [{ id: 1, name: "John" }],
57
+ });
58
+
59
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
60
+ const clientObj = {
61
+ users: client.createHook("/users"),
62
+ };
63
+
64
+ const { users } = useFragno(clientObj);
65
+ const userStore = users();
66
+
67
+ // Subscribe to trigger the fetch - this will keep the store active
68
+ const unsubscribe = userStore.subscribe(() => {});
69
+
70
+ // Wait for data to load
71
+ await vi.waitFor(() => {
72
+ const state = userStore.get();
73
+ expect(state.loading).toBe(false);
74
+ expect(state.error).toBeUndefined();
75
+ expect(state.data).toBeDefined();
76
+ });
77
+
78
+ const state = userStore.get();
79
+ expect(state.data).toEqual([{ id: 1, name: "John" }]);
80
+ expect(state.error).toBeUndefined();
81
+
82
+ unsubscribe();
83
+ });
84
+
85
+ test("should support listen and subscribe methods", async () => {
86
+ (global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
87
+ headers: new Headers(),
88
+ ok: true,
89
+ json: async () => [{ id: 1, name: "John" }],
90
+ });
91
+
92
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
93
+ const clientObj = {
94
+ users: client.createHook("/users"),
95
+ };
96
+
97
+ const { users } = useFragno(clientObj);
98
+ const userStore = users();
99
+
100
+ const listenCallback = vi.fn();
101
+ const subscribeCallback = vi.fn();
102
+
103
+ userStore.listen(listenCallback);
104
+ userStore.subscribe(subscribeCallback);
105
+
106
+ // Wait for initial load
107
+ await vi.waitFor(() => {
108
+ const state = userStore.get();
109
+ expect(state.loading).toBe(false);
110
+ });
111
+
112
+ expect(listenCallback).toHaveBeenCalled();
113
+ expect(subscribeCallback).toHaveBeenCalled();
114
+ });
115
+
116
+ test("should support refetch functionality", async () => {
117
+ let callCount = 0;
118
+ (global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async () => {
119
+ callCount++;
120
+ return {
121
+ headers: new Headers(),
122
+ ok: true,
123
+ json: async () => [{ id: callCount, name: `John${callCount}` }],
124
+ };
125
+ });
126
+
127
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
128
+ const clientObj = {
129
+ useUsers: client.createHook("/users"),
130
+ };
131
+
132
+ const { useUsers } = useFragno(clientObj);
133
+ const userStore = useUsers();
134
+
135
+ const stateAfterInitialLoad = await waitForAsyncIterator(
136
+ userStore,
137
+ (state) => state.loading === false,
138
+ );
139
+ expect(stateAfterInitialLoad).toEqual({
140
+ loading: false,
141
+ data: [{ id: 1, name: "John1" }],
142
+ });
143
+
144
+ // Refetch data
145
+ userStore.refetch();
146
+
147
+ const stateAfterRefetch = await waitForAsyncIterator(
148
+ userStore,
149
+ (state) => state.loading === false,
150
+ );
151
+ expect(stateAfterRefetch).toEqual({
152
+ loading: false,
153
+ data: [{ id: 2, name: "John2" }],
154
+ });
155
+
156
+ expect(fetch).toHaveBeenCalledTimes(2);
157
+ });
158
+
159
+ test("should create vanilla listeners with path parameters", async () => {
160
+ (global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
161
+ headers: new Headers(),
162
+ ok: true,
163
+ json: async () => ({ id: 123, name: "John" }),
164
+ });
165
+
166
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
167
+ const clientObj = {
168
+ user: client.createHook("/users/:id"),
169
+ };
170
+
171
+ const { user } = useFragno(clientObj);
172
+ const userStore = user({ path: { id: "123" } });
173
+
174
+ // Subscribe to trigger the fetch - this will keep the store active
175
+ const unsubscribe = userStore.subscribe(() => {});
176
+
177
+ await vi.waitFor(() => {
178
+ const state = userStore.get();
179
+ expect(state.loading).toBe(false);
180
+ expect(state.data).toBeDefined();
181
+ });
182
+
183
+ expect(userStore.get().data).toEqual({ id: 123, name: "John" });
184
+ expect(fetch).toHaveBeenCalledWith("http://localhost:3000/api/test-fragment/users/123");
185
+
186
+ unsubscribe();
187
+ });
188
+
189
+ test("should create vanilla listeners with query parameters", async () => {
190
+ (global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
191
+ headers: new Headers(),
192
+ ok: true,
193
+ json: async () => ["result1", "result2"],
194
+ });
195
+
196
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
197
+ const clientObj = {
198
+ search: client.createHook("/search"),
199
+ };
200
+
201
+ const { search } = useFragno(clientObj);
202
+ const searchStore = search({ query: { q: "test" } });
203
+
204
+ // Subscribe to trigger the fetch - this will keep the store active
205
+ const unsubscribe = searchStore.subscribe(() => {});
206
+
207
+ await vi.waitFor(() => {
208
+ const state = searchStore.get();
209
+ expect(state.loading).toBe(false);
210
+ expect(state.data).toBeDefined();
211
+ });
212
+
213
+ expect(searchStore.get().data).toEqual(["result1", "result2"]);
214
+ expect(fetch).toHaveBeenCalledWith("http://localhost:3000/api/test-fragment/search?q=test");
215
+
216
+ unsubscribe();
217
+ });
218
+
219
+ test("should support async iteration over hook state changes", async () => {
220
+ (global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
221
+ headers: new Headers(),
222
+ ok: true,
223
+ json: async () => [{ id: 1, name: "John" }],
224
+ });
225
+
226
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
227
+ const clientObj = {
228
+ users: client.createHook("/users"),
229
+ };
230
+
231
+ const { users } = useFragno(clientObj);
232
+ const userStore = users();
233
+
234
+ // Subscribe to trigger the fetch - this will keep the store active
235
+ const unsubscribe = userStore.subscribe(() => {});
236
+
237
+ // Use waitForAsyncIterator to wait for the loaded state
238
+ const finalState = await waitForAsyncIterator(
239
+ userStore,
240
+ (state) => state.loading === false && state.data !== undefined,
241
+ { timeout: 3000 },
242
+ );
243
+
244
+ // Verify final state
245
+ expect(finalState).toEqual({
246
+ loading: false,
247
+ error: undefined,
248
+ data: [{ id: 1, name: "John" }],
249
+ });
250
+
251
+ unsubscribe();
252
+ });
253
+
254
+ test("should support reactive path parameters with atoms", async () => {
255
+ const idAtom = atom("1");
256
+
257
+ (global.fetch as ReturnType<typeof vi.fn>)
258
+ .mockResolvedValueOnce({
259
+ headers: new Headers(),
260
+ ok: true,
261
+ json: async () => ({ id: 1, name: "John" }),
262
+ })
263
+ .mockResolvedValueOnce({
264
+ headers: new Headers(),
265
+ ok: true,
266
+ json: async () => ({ id: 2, name: "Jane" }),
267
+ });
268
+
269
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
270
+ const clientObj = {
271
+ user: client.createHook("/users/:id"),
272
+ };
273
+
274
+ const { user } = useFragno(clientObj);
275
+ const userStore = user({ path: { id: idAtom } });
276
+
277
+ // Subscribe to trigger the fetch - this will keep the store active
278
+ const unsubscribe = userStore.subscribe(() => {});
279
+
280
+ await vi.waitFor(() => {
281
+ const state = userStore.get();
282
+ expect(state.loading).toBe(false);
283
+ expect(state.data).toBeDefined();
284
+ });
285
+
286
+ expect(userStore.get().data).toEqual({ id: 1, name: "John" });
287
+
288
+ // Update the atom value
289
+ idAtom.set("2");
290
+
291
+ await vi.waitFor(() => {
292
+ const state = userStore.get();
293
+ expect(state.data).toEqual({ id: 2, name: "Jane" });
294
+ });
295
+
296
+ expect(fetch).toHaveBeenCalledTimes(2);
297
+
298
+ unsubscribe();
299
+ });
300
+
301
+ test("errors then success", async () => {
302
+ (global.fetch as ReturnType<typeof vi.fn>)
303
+ .mockRejectedValueOnce("Network error")
304
+ .mockResolvedValueOnce({
305
+ headers: new Headers(),
306
+ ok: true,
307
+ json: async () => ({ id: 1, name: "John" }),
308
+ });
309
+
310
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
311
+ const clientObj = {
312
+ user: client.createHook("/users/:id", {
313
+ onErrorRetry: () => 1, // Wait only 1ms before retrying
314
+ }),
315
+ };
316
+
317
+ const { user } = useFragno(clientObj);
318
+ const userStore = user({ path: { id: "123" } });
319
+
320
+ const asyncIterator = userStore[Symbol.asyncIterator]();
321
+
322
+ // Initial state: store is loading
323
+ {
324
+ const { value } = await asyncIterator.next();
325
+ expect(value).toBeDefined();
326
+ const { data, error, loading } = value!;
327
+ expect(data).toBeUndefined();
328
+ expect(error).toBeUndefined();
329
+ expect(loading).toBe(true);
330
+ }
331
+
332
+ // First "result": loading is false, error is now set.
333
+ {
334
+ const { value } = await asyncIterator.next();
335
+ expect(value).toBeDefined();
336
+ const { data, error, loading } = value!;
337
+ expect(data).toBeUndefined();
338
+ expect(error).toBeInstanceOf(FragnoClientFetchNetworkError);
339
+ expect(loading).toBe(false);
340
+ }
341
+
342
+ // Retry initiated: loading is true
343
+ {
344
+ const { value } = await asyncIterator.next();
345
+ expect(value).toBeDefined();
346
+ const { data, error, loading } = value!;
347
+ expect(data).toBeUndefined();
348
+ expect(error).toBeUndefined();
349
+ expect(loading).toBe(true);
350
+ }
351
+
352
+ // Retry succeeded: data is now available.
353
+ {
354
+ const { value } = await asyncIterator.next();
355
+ expect(value).toBeDefined();
356
+ const { data, error, loading } = value!;
357
+ expect(data).toEqual({ id: 1, name: "John" });
358
+ expect(error).toBeUndefined();
359
+ expect(loading).toBe(false);
360
+ }
361
+ const { done } = await asyncIterator.return();
362
+ expect(done).toBe(true);
363
+ });
364
+ });
365
+
366
+ describe("createVanillaMutator", () => {
367
+ const testFragmentDefinition = defineFragment("test-fragment-mutator");
368
+ const testRoutes = [
369
+ defineRoute({
370
+ method: "POST",
371
+ path: "/users",
372
+ inputSchema: z.object({ name: z.string(), email: z.string() }),
373
+ outputSchema: z.object({ id: z.number(), name: z.string(), email: z.string() }),
374
+ handler: async (_ctx, { json }) => json({ id: 1, name: "", email: "" }),
375
+ }),
376
+ defineRoute({
377
+ method: "PUT",
378
+ path: "/users/:id",
379
+ inputSchema: z.object({ name: z.string() }),
380
+ outputSchema: z.object({ id: z.number(), name: z.string() }),
381
+ handler: async ({ pathParams }, { json }) => json({ id: Number(pathParams["id"]), name: "" }),
382
+ }),
383
+ defineRoute({
384
+ method: "DELETE",
385
+ path: "/users/:id",
386
+ inputSchema: z.object({}),
387
+ outputSchema: z.object({ success: z.boolean() }),
388
+ handler: async (_ctx, { json }) => json({ success: true }),
389
+ }),
390
+ ] as const;
391
+
392
+ const clientConfig: FragnoPublicClientConfig = {
393
+ baseUrl: "http://localhost:3000",
394
+ };
395
+
396
+ beforeEach(() => {
397
+ vi.clearAllMocks();
398
+ (global.fetch as ReturnType<typeof vi.fn>).mockReset();
399
+ });
400
+
401
+ afterEach(() => {
402
+ vi.restoreAllMocks();
403
+ });
404
+
405
+ test("should create a vanilla mutator for POST route", async () => {
406
+ (global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
407
+ headers: new Headers(),
408
+ ok: true,
409
+ json: async () => ({ id: 1, name: "John", email: "john@example.com" }),
410
+ });
411
+
412
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
413
+ const clientObj = {
414
+ createUser: client.createMutator("POST", "/users"),
415
+ };
416
+
417
+ const { createUser } = useFragno(clientObj);
418
+ const mutator = createUser();
419
+
420
+ const result = await mutator.mutate({
421
+ body: { name: "John", email: "john@example.com" },
422
+ });
423
+
424
+ expect(result).toEqual({ id: 1, name: "John", email: "john@example.com" });
425
+ expect(fetch).toHaveBeenCalledWith(
426
+ expect.stringContaining("/users"),
427
+ expect.objectContaining({
428
+ method: "POST",
429
+ body: JSON.stringify({ name: "John", email: "john@example.com" }),
430
+ }),
431
+ );
432
+ });
433
+
434
+ test("should track loading state during mutation", async () => {
435
+ let resolvePromise: (value: unknown) => void;
436
+ const fetchPromise = new Promise((resolve) => {
437
+ resolvePromise = resolve;
438
+ });
439
+
440
+ (global.fetch as ReturnType<typeof vi.fn>).mockReturnValueOnce(fetchPromise);
441
+
442
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
443
+ const clientObj = {
444
+ createUser: client.createMutator("POST", "/users"),
445
+ };
446
+
447
+ const { createUser } = useFragno(clientObj);
448
+ const mutator = createUser();
449
+
450
+ const stateChanges: Array<{ loading?: boolean; data?: unknown }> = [];
451
+ mutator.subscribe((state) => {
452
+ stateChanges.push({ loading: state.loading, data: state.data });
453
+ });
454
+
455
+ // Initial state
456
+ expect(mutator.get()).toEqual({ loading: false, error: undefined, data: undefined });
457
+
458
+ // Start mutation
459
+ const mutatePromise = mutator.mutate({
460
+ body: { name: "John", email: "john@example.com" },
461
+ });
462
+
463
+ // Check loading state
464
+ await vi.waitFor(() => {
465
+ expect(mutator.get().loading).toBe(true);
466
+ });
467
+
468
+ // Resolve the fetch
469
+ resolvePromise!({
470
+ headers: new Headers(),
471
+ ok: true,
472
+ json: async () => ({ id: 1, name: "John", email: "john@example.com" }),
473
+ });
474
+
475
+ const result = await mutatePromise;
476
+
477
+ // Check final state
478
+ expect(mutator.get()).toEqual({
479
+ loading: false,
480
+ error: undefined,
481
+ data: { id: 1, name: "John", email: "john@example.com" },
482
+ });
483
+
484
+ expect(result).toEqual({ id: 1, name: "John", email: "john@example.com" });
485
+ });
486
+
487
+ test("should create a vanilla mutator for PUT route with path params", async () => {
488
+ (global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
489
+ headers: new Headers(),
490
+ ok: true,
491
+ json: async () => ({ id: 123, name: "Jane" }),
492
+ });
493
+
494
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
495
+ const clientObj = {
496
+ updateUser: client.createMutator("PUT", "/users/:id"),
497
+ };
498
+
499
+ const { updateUser } = useFragno(clientObj);
500
+ const mutator = updateUser();
501
+
502
+ const result = await mutator.mutate({
503
+ body: { name: "Jane" },
504
+ path: { id: "123" },
505
+ });
506
+
507
+ expect(result).toEqual({ id: 123, name: "Jane" });
508
+ expect(fetch).toHaveBeenCalledWith(
509
+ expect.stringContaining("/users/123"),
510
+ expect.objectContaining({
511
+ method: "PUT",
512
+ body: JSON.stringify({ name: "Jane" }),
513
+ }),
514
+ );
515
+ });
516
+
517
+ test("should create a vanilla mutator for DELETE route", async () => {
518
+ (global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
519
+ headers: new Headers(),
520
+ ok: true,
521
+ json: async () => ({ success: true }),
522
+ });
523
+
524
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
525
+ const clientObj = {
526
+ deleteUser: client.createMutator("DELETE", "/users/:id"),
527
+ };
528
+
529
+ const { deleteUser } = useFragno(clientObj);
530
+ const mutator = deleteUser();
531
+
532
+ const result = await mutator.mutate({
533
+ body: {},
534
+ path: { id: "123" },
535
+ });
536
+
537
+ expect(result).toEqual({ success: true });
538
+ expect(fetch).toHaveBeenCalledWith(
539
+ expect.stringContaining("/users/123"),
540
+ expect.objectContaining({
541
+ method: "DELETE",
542
+ }),
543
+ );
544
+ });
545
+
546
+ test("should support subscribe method for mutator state changes", async () => {
547
+ (global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
548
+ headers: new Headers(),
549
+ ok: true,
550
+ json: async () => ({ id: 1, name: "John", email: "john@example.com" }),
551
+ });
552
+
553
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
554
+ const clientObj = {
555
+ createUser: client.createMutator("POST", "/users"),
556
+ };
557
+
558
+ const { createUser } = useFragno(clientObj);
559
+ const mutator = createUser();
560
+
561
+ const stateCallback = vi.fn();
562
+ mutator.subscribe(stateCallback);
563
+
564
+ await mutator.mutate({
565
+ body: { name: "John", email: "john@example.com" },
566
+ });
567
+
568
+ // Should be called for loading state changes
569
+ expect(stateCallback).toHaveBeenCalled();
570
+ expect(stateCallback).toHaveBeenCalledWith({
571
+ loading: true,
572
+ error: undefined,
573
+ data: undefined,
574
+ });
575
+
576
+ expect(stateCallback).toHaveBeenCalledWith({
577
+ loading: false,
578
+ error: undefined,
579
+ data: { id: 1, name: "John", email: "john@example.com" },
580
+ });
581
+ });
582
+
583
+ test("should support async iteration over mutator state changes", async () => {
584
+ (global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
585
+ headers: new Headers(),
586
+ ok: true,
587
+ json: async () => ({ id: 1, name: "John", email: "john@example.com" }),
588
+ });
589
+
590
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
591
+ const clientObj = {
592
+ createUser: client.createMutator("POST", "/users"),
593
+ };
594
+
595
+ const { createUser } = useFragno(clientObj);
596
+ const mutator = createUser();
597
+
598
+ // Start the mutation
599
+ const mutatePromise = mutator.mutate({
600
+ body: { name: "John", email: "john@example.com" },
601
+ });
602
+
603
+ // Use waitForAsyncIterator to wait for the completed state
604
+ const finalState = await waitForAsyncIterator(
605
+ mutator,
606
+ (state) => state.loading === false && state.data !== undefined,
607
+ { timeout: 3000 },
608
+ );
609
+
610
+ // Verify final state
611
+ expect(finalState).toEqual({
612
+ loading: false,
613
+ error: undefined,
614
+ data: { id: 1, name: "John", email: "john@example.com" },
615
+ });
616
+
617
+ // Ensure mutation completes
618
+ const result = await mutatePromise;
619
+ expect(result).toEqual({ id: 1, name: "John", email: "john@example.com" });
620
+ });
621
+ });
622
+
623
+ describe("useFragno", () => {
624
+ const testFragmentDefinition = defineFragment("test-fragment-useFragno");
625
+ const testRoutes = [
626
+ defineRoute({
627
+ method: "GET",
628
+ path: "/data",
629
+ outputSchema: z.string(),
630
+ handler: async (_ctx, { json }) => json("test data"),
631
+ }),
632
+ defineRoute({
633
+ method: "POST",
634
+ path: "/action",
635
+ inputSchema: z.object({ value: z.string() }),
636
+ outputSchema: z.object({ result: z.string() }),
637
+ handler: async (_ctx, { json }) => json({ result: "test value" }),
638
+ }),
639
+ ] as const;
640
+
641
+ const clientConfig: FragnoPublicClientConfig = {
642
+ baseUrl: "http://localhost:3000",
643
+ };
644
+
645
+ beforeEach(() => {
646
+ vi.clearAllMocks();
647
+ (global.fetch as ReturnType<typeof vi.fn>).mockReset();
648
+ });
649
+
650
+ afterEach(() => {
651
+ vi.restoreAllMocks();
652
+ });
653
+
654
+ test("should transform a mixed object of hooks and mutators", async () => {
655
+ (global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
656
+ headers: new Headers(),
657
+ ok: true,
658
+ json: async () => "test data",
659
+ });
660
+
661
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
662
+ const clientObj = {
663
+ data: client.createHook("/data"),
664
+ postAction: client.createMutator("POST", "/action"),
665
+ };
666
+
667
+ const { data, postAction } = useFragno(clientObj);
668
+
669
+ // Test the hook
670
+ const dataStore = data();
671
+
672
+ // Subscribe to trigger the fetch - this will keep the store active
673
+ const unsubscribe = dataStore.subscribe(() => {});
674
+
675
+ await vi.waitFor(() => {
676
+ const state = dataStore.get();
677
+ expect(state.loading).toBe(false);
678
+ expect(state.error).toBeUndefined();
679
+ expect(state.data).toBeDefined();
680
+ });
681
+
682
+ expect(dataStore.get().data).toBe("test data");
683
+
684
+ // Test the mutator
685
+ (global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
686
+ headers: new Headers(),
687
+ ok: true,
688
+ json: async () => ({ result: "test value" }),
689
+ });
690
+
691
+ const mutator = postAction();
692
+ const mutatorResult = await mutator.mutate({
693
+ body: { value: "test value" },
694
+ });
695
+
696
+ expect(mutatorResult).toEqual({ result: "test value" });
697
+
698
+ unsubscribe();
699
+ });
700
+
701
+ test("should pass through non-hook values unchanged", () => {
702
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
703
+ const clientObj = {
704
+ useData: client.createHook("/data"),
705
+ someString: "hello world",
706
+ someNumber: 42,
707
+ someObject: { foo: "bar", nested: { value: true } },
708
+ someArray: [1, 2, 3],
709
+ someFunction: () => "test",
710
+ someNull: null,
711
+ someUndefined: undefined,
712
+ };
713
+
714
+ const result = useFragno(clientObj);
715
+
716
+ // Check that non-hook values are passed through unchanged
717
+ expect(result.someString).toBe("hello world");
718
+ expect(result.someNumber).toBe(42);
719
+ expect(result.someObject).toEqual({ foo: "bar", nested: { value: true } });
720
+ expect(result.someArray).toEqual([1, 2, 3]);
721
+ expect(result.someFunction()).toBe("test");
722
+ expect(result.someNull).toBeNull();
723
+ expect(result.someUndefined).toBeUndefined();
724
+
725
+ // Verify that the hook is still transformed
726
+ expect(typeof result.useData).toBe("function");
727
+ });
728
+
729
+ test("multiple GET hooks for the same path should share the same store", async () => {
730
+ (global.fetch as ReturnType<typeof vi.fn>)
731
+ .mockResolvedValueOnce({
732
+ headers: new Headers(),
733
+ ok: true,
734
+ json: async () => "data1",
735
+ })
736
+ .mockResolvedValueOnce({
737
+ headers: new Headers(),
738
+ ok: true,
739
+ json: async () => "data2",
740
+ });
741
+
742
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
743
+ const clientObj = {
744
+ data1: client.createHook("/data"),
745
+ data2: client.createHook("/data"),
746
+ };
747
+
748
+ const { data1, data2 } = useFragno(clientObj);
749
+
750
+ const store1 = data1();
751
+ const store2 = data2();
752
+
753
+ // Subscribe to trigger the fetches - this will keep the stores active
754
+ const unsubscribe1 = store1.subscribe(() => {});
755
+ const unsubscribe2 = store2.subscribe(() => {});
756
+
757
+ await vi.waitFor(() => {
758
+ expect(store1.get().loading).toBe(false);
759
+ expect(store2.get().loading).toBe(false);
760
+ expect(store1.get().data).toBeDefined();
761
+ expect(store2.get().data).toBeDefined();
762
+ });
763
+
764
+ expect(fetch).toHaveBeenCalledTimes(1);
765
+
766
+ expect(store1.get().data).toBe("data1");
767
+ expect(store2.get().data).toBe("data1");
768
+
769
+ unsubscribe1();
770
+ unsubscribe2();
771
+ });
772
+ });
773
+
774
+ describe("error handling", () => {
775
+ const testFragmentDefinition = defineFragment("test-fragment-errors");
776
+ const testRoutes = [
777
+ defineRoute({
778
+ method: "GET",
779
+ path: "/users",
780
+ outputSchema: z.array(z.object({ id: z.number(), name: z.string() })),
781
+ handler: async (_ctx, { json }) => json([{ id: 1, name: "John" }]),
782
+ }),
783
+ defineRoute({
784
+ method: "POST",
785
+ path: "/users",
786
+ inputSchema: z.object({ name: z.string(), email: z.string() }),
787
+ outputSchema: z.object({ id: z.number(), name: z.string(), email: z.string() }),
788
+ handler: async (_ctx, { json }) => json({ id: 1, name: "", email: "" }),
789
+ }),
790
+ ] as const;
791
+
792
+ const clientConfig: FragnoPublicClientConfig = {
793
+ baseUrl: "http://localhost:3000",
794
+ };
795
+
796
+ beforeEach(() => {
797
+ vi.clearAllMocks();
798
+ (global.fetch as ReturnType<typeof vi.fn>).mockReset();
799
+ });
800
+
801
+ afterEach(() => {
802
+ vi.restoreAllMocks();
803
+ });
804
+
805
+ test("should handle GET hook errors gracefully", async () => {
806
+ (global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("Network error"));
807
+
808
+ const builder = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
809
+ const clientObj = {
810
+ users: builder.createHook("/users", {
811
+ onErrorRetry: null,
812
+ }),
813
+ };
814
+
815
+ const { users } = useFragno(clientObj);
816
+ const userStore = users();
817
+
818
+ const asyncIterator = userStore[Symbol.asyncIterator]();
819
+
820
+ {
821
+ const { value } = await asyncIterator.next();
822
+ expect(value).toBeDefined();
823
+ const { data, error, loading } = value!;
824
+ expect(data).toBeUndefined();
825
+ expect(error).toBeUndefined();
826
+ expect(loading).toBe(true);
827
+ }
828
+
829
+ {
830
+ const { value } = await asyncIterator.next();
831
+ expect(value).toBeDefined();
832
+ const { data, error, loading } = value!;
833
+ expect(data).toBeUndefined();
834
+ expect(error).toBeInstanceOf(FragnoClientFetchNetworkError);
835
+ expect(loading).toBe(false);
836
+ }
837
+
838
+ await asyncIterator.return();
839
+ });
840
+
841
+ test("should handle mutator errors gracefully", async () => {
842
+ (global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("Server error"));
843
+
844
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
845
+ const clientObj = {
846
+ createUser: client.createMutator("POST", "/users"),
847
+ };
848
+
849
+ const { createUser } = useFragno(clientObj);
850
+ const mutator = createUser();
851
+
852
+ mutator.mutate({
853
+ body: { name: "John", email: "john@example.com" },
854
+ });
855
+
856
+ const finalState = await waitForAsyncIterator(
857
+ mutator,
858
+ (state) => state.error !== undefined && state.loading === false,
859
+ );
860
+
861
+ expect(finalState).toEqual({
862
+ loading: false,
863
+ error: expect.any(FragnoClientFetchNetworkError),
864
+ data: undefined,
865
+ });
866
+ });
867
+ });