@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,1690 @@
1
+ import { afterEach, assert, beforeEach, describe, expect, test, vi } from "vitest";
2
+ import { z } from "zod";
3
+ import { defineRoute } from "../api/route";
4
+ import { buildUrl, createClientBuilder, getCacheKey, isGetHook, isMutatorHook } from "./client";
5
+ import { useFragno } from "./vanilla";
6
+ import { createAsyncIteratorFromCallback, waitForAsyncIterator } from "../util/async";
7
+ import type { FragnoPublicClientConfig } from "../mod";
8
+ import { atom, computed, effect } from "nanostores";
9
+ import { defineFragment } from "../api/fragment";
10
+ import { RequestOutputContext } from "../api/request-output-context";
11
+ import { FragnoClientUnknownApiError } from "./client-error";
12
+
13
+ // Mock fetch globally
14
+ global.fetch = vi.fn();
15
+
16
+ describe("buildUrl", () => {
17
+ test("should build URL with no parameters", () => {
18
+ const result = buildUrl(
19
+ { baseUrl: "http://localhost:3000", mountRoute: "/api", path: "/users" },
20
+ {},
21
+ );
22
+ expect(result).toBe("http://localhost:3000/api/users");
23
+ });
24
+
25
+ test("should build URL with path parameters", () => {
26
+ const result = buildUrl(
27
+ { baseUrl: "http://localhost:3000", mountRoute: "/api", path: "/users/:id" },
28
+ { pathParams: { id: "123" } },
29
+ );
30
+ expect(result).toBe("http://localhost:3000/api/users/123");
31
+ });
32
+
33
+ test("should build URL with query parameters", () => {
34
+ const result = buildUrl(
35
+ { baseUrl: "http://localhost:3000", mountRoute: "/api", path: "/users" },
36
+ { queryParams: { sort: "name", order: "asc" } },
37
+ );
38
+ expect(result).toBe("http://localhost:3000/api/users?sort=name&order=asc");
39
+ });
40
+
41
+ test("should build URL with both path and query parameters", () => {
42
+ const result = buildUrl(
43
+ { baseUrl: "http://localhost:3000", mountRoute: "/api", path: "/users/:id/posts" },
44
+ { pathParams: { id: "123" }, queryParams: { limit: "10" } },
45
+ );
46
+ expect(result).toBe("http://localhost:3000/api/users/123/posts?limit=10");
47
+ });
48
+
49
+ test("should handle empty baseUrl", () => {
50
+ const result = buildUrl({ baseUrl: "", mountRoute: "/api", path: "/users" }, {});
51
+ expect(result).toBe("/api/users");
52
+ });
53
+
54
+ test("should handle empty mountRoute", () => {
55
+ const result = buildUrl(
56
+ { baseUrl: "http://localhost:3000", mountRoute: "", path: "/users" },
57
+ {},
58
+ );
59
+ expect(result).toBe("http://localhost:3000/users");
60
+ });
61
+
62
+ test("should handle undefined baseUrl", () => {
63
+ const result = buildUrl({ mountRoute: "/api", path: "/users" }, {});
64
+ expect(result).toBe("/api/users");
65
+ });
66
+ });
67
+
68
+ describe("getCacheKey", () => {
69
+ test("should return path only when no parameters", () => {
70
+ const result = getCacheKey("GET", "/users");
71
+ expect(result).toEqual(["GET", "/users"]);
72
+ });
73
+
74
+ test("should include path parameters in order", () => {
75
+ const result = getCacheKey("GET", "/users/:id/posts/:postId", {
76
+ pathParams: { id: "123", postId: "456" },
77
+ });
78
+ expect(result).toEqual(["GET", "/users/:id/posts/:postId", "123", "456"]);
79
+ });
80
+
81
+ test("should include query parameters in alphabetical order", () => {
82
+ const result = getCacheKey("GET", "/users", {
83
+ queryParams: { sort: "name", order: "asc" },
84
+ });
85
+ expect(result).toEqual(["GET", "/users", "asc", "name"]);
86
+ });
87
+
88
+ test("should handle missing path parameters", () => {
89
+ const result = getCacheKey("GET", "/users/:id/posts/:postId", {
90
+ pathParams: { id: "123" },
91
+ });
92
+ expect(result).toEqual(["GET", "/users/:id/posts/:postId", "123", "<missing>"]);
93
+ });
94
+
95
+ test("should handle both path and query parameters", () => {
96
+ const result = getCacheKey("GET", "/users/:id", {
97
+ pathParams: { id: "123" },
98
+ queryParams: { sort: "name" },
99
+ });
100
+ expect(result).toEqual(["GET", "/users/:id", "123", "name"]);
101
+ });
102
+
103
+ test("should handle empty params object", () => {
104
+ const result = getCacheKey("GET", "/users", {});
105
+ expect(result).toEqual(["GET", "/users"]);
106
+ });
107
+
108
+ test("should handle undefined params", () => {
109
+ const result = getCacheKey("GET", "/users");
110
+ expect(result).toEqual(["GET", "/users"]);
111
+ });
112
+ });
113
+
114
+ describe("invalidation", () => {
115
+ const testFragment = defineFragment("test-fragment");
116
+ const testRoutes = [
117
+ defineRoute({
118
+ method: "GET",
119
+ path: "/users",
120
+ outputSchema: z.array(z.object({ id: z.number(), name: z.string() })),
121
+ handler: async (_ctx, { json }) => json([{ id: 1, name: "John" }]),
122
+ }),
123
+ defineRoute({
124
+ method: "POST",
125
+ path: "/users",
126
+ inputSchema: z.object({ name: z.string() }),
127
+ outputSchema: z.object({ id: z.number(), name: z.string() }),
128
+ handler: async (_ctx, { json }) => json({ id: 2, name: "Jane" }),
129
+ }),
130
+ defineRoute({
131
+ method: "GET",
132
+ path: "/users/:id",
133
+ outputSchema: z.object({ id: z.number(), name: z.string() }),
134
+ handler: async ({ pathParams }, { json }) =>
135
+ json({ id: Number(pathParams["id"]), name: "John" }),
136
+ }),
137
+ ] as const;
138
+
139
+ beforeEach(() => {
140
+ vi.clearAllMocks();
141
+ (global.fetch as ReturnType<typeof vi.fn>).mockReset();
142
+ });
143
+
144
+ afterEach(() => {
145
+ vi.restoreAllMocks();
146
+ });
147
+
148
+ test("should automatically refetch when an item is invalidated", async () => {
149
+ let callCount = 0;
150
+ (global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async () => {
151
+ callCount++;
152
+ return {
153
+ headers: new Headers(),
154
+ ok: true,
155
+ json: async () => [{ id: callCount, name: `John${callCount}` }],
156
+ };
157
+ });
158
+
159
+ const client = createClientBuilder(
160
+ testFragment,
161
+ { baseUrl: "http://localhost:3000" },
162
+ testRoutes,
163
+ );
164
+ const clientObj = {
165
+ useUsers: client.createHook("/users"),
166
+ useMutateUsers: client.createMutator("POST", "/users"),
167
+ };
168
+
169
+ const { useUsers, useMutateUsers } = useFragno(clientObj);
170
+ const userStore = useUsers();
171
+
172
+ const stateAfterInitialLoad = await waitForAsyncIterator(
173
+ userStore,
174
+ (state) => state.loading === false,
175
+ );
176
+ expect(stateAfterInitialLoad).toEqual({
177
+ loading: false,
178
+ data: [{ id: 1, name: "John1" }],
179
+ });
180
+ expect(fetch).toHaveBeenCalledTimes(1);
181
+
182
+ // The second fetch call is the mutation.
183
+ await useMutateUsers().mutate({
184
+ body: { name: "John" },
185
+ });
186
+
187
+ const stateAfterRefetch = await waitForAsyncIterator(
188
+ userStore,
189
+ (state) => state.loading === false,
190
+ );
191
+ expect(stateAfterRefetch).toEqual({
192
+ loading: false,
193
+ data: [{ id: 3, name: "John3" }],
194
+ });
195
+
196
+ expect(fetch).toHaveBeenCalledTimes(3);
197
+ });
198
+
199
+ test("should refetch when an item is invalidated", async () => {
200
+ let callCount = 0;
201
+ (global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async () => {
202
+ callCount++;
203
+ return {
204
+ headers: new Headers(),
205
+ ok: true,
206
+ json: async () => [{ id: callCount, name: `John${callCount}` }],
207
+ };
208
+ });
209
+
210
+ const client = createClientBuilder(
211
+ testFragment,
212
+ { baseUrl: "http://localhost:3000" },
213
+ testRoutes,
214
+ );
215
+ let invalidateCalled = false;
216
+ const clientObj = {
217
+ useUsers: client.createHook("/users"),
218
+ modifyUsersManual: client.createMutator("POST", "/users", (invalidate) => {
219
+ invalidateCalled = true;
220
+ return invalidate("GET", "/users", {});
221
+ }),
222
+ };
223
+
224
+ const { useUsers, modifyUsersManual } = useFragno(clientObj);
225
+ const userStore = useUsers();
226
+
227
+ const stateAfterInitialLoad = await waitForAsyncIterator(
228
+ userStore,
229
+ (state) => state.loading === false,
230
+ );
231
+
232
+ expect(stateAfterInitialLoad).toEqual({
233
+ loading: false,
234
+ data: [{ id: 1, name: "John1" }],
235
+ });
236
+ expect(fetch).toHaveBeenCalledTimes(1);
237
+
238
+ // The second fetch call is the mutation.
239
+ await modifyUsersManual().mutate({
240
+ body: { name: "John" },
241
+ });
242
+ expect(invalidateCalled).toBe(true);
243
+
244
+ const stateAfterRefetch = await waitForAsyncIterator(
245
+ userStore,
246
+ (state) => state.loading === false,
247
+ );
248
+ expect(stateAfterRefetch).toEqual({
249
+ loading: false,
250
+ data: [{ id: 3, name: "John3" }],
251
+ });
252
+
253
+ expect(fetch).toHaveBeenCalledTimes(3);
254
+ });
255
+ });
256
+
257
+ describe("hook parameter reactivity", () => {
258
+ const clientConfig: FragnoPublicClientConfig = {
259
+ baseUrl: "http://localhost:3000",
260
+ };
261
+
262
+ beforeEach(() => {
263
+ vi.clearAllMocks();
264
+ (global.fetch as ReturnType<typeof vi.fn>).mockReset();
265
+ });
266
+
267
+ afterEach(() => {
268
+ vi.restoreAllMocks();
269
+ });
270
+
271
+ test("should react to path parameters", async () => {
272
+ const testFragment = defineFragment("test-fragment");
273
+ const testRoutes = [
274
+ defineRoute({
275
+ method: "GET",
276
+ path: "/users/:id",
277
+ outputSchema: z.array(z.object({ id: z.number(), name: z.string() })),
278
+ handler: async (_ctx, { json }) => json([{ id: 1, name: "John" }]),
279
+ }),
280
+ ] as const;
281
+
282
+ vi.mocked(global.fetch).mockImplementation(async (input) => {
283
+ assert(typeof input === "string");
284
+
285
+ // Regex to extract id value from a URL string, matching only on /users/:id
286
+ const [, id] = String(input).match(/\/users\/([^/]+)/) ?? [];
287
+
288
+ expect(id).toBeDefined();
289
+ expect(+id).not.toBeNaN();
290
+
291
+ return {
292
+ headers: new Headers(),
293
+ ok: true,
294
+ json: async () => ({ id: Number(id), name: "John" }),
295
+ } as Response;
296
+ });
297
+
298
+ const cb = createClientBuilder(testFragment, clientConfig, testRoutes);
299
+ const useUsers = cb.createHook("/users/:id");
300
+
301
+ const idAtom = atom("123");
302
+ const store = useUsers.store({ path: { id: idAtom } });
303
+
304
+ const itt = createAsyncIteratorFromCallback(store.listen);
305
+
306
+ {
307
+ const { value } = await itt.next();
308
+ expect(value).toEqual({
309
+ loading: true,
310
+ promise: expect.any(Promise),
311
+ data: undefined,
312
+ error: undefined,
313
+ });
314
+ }
315
+
316
+ {
317
+ const { value } = await itt.next();
318
+ expect(value).toEqual({
319
+ loading: false,
320
+ data: { id: 123, name: "John" },
321
+ });
322
+ }
323
+
324
+ idAtom.set("456");
325
+
326
+ {
327
+ const { value } = await itt.next();
328
+ expect(value).toEqual({
329
+ loading: true,
330
+ promise: expect.any(Promise),
331
+ data: undefined,
332
+ error: undefined,
333
+ });
334
+ }
335
+
336
+ {
337
+ const { value } = await itt.next();
338
+ expect(value).toEqual({
339
+ loading: false,
340
+ data: { id: 456, name: "John" },
341
+ });
342
+ }
343
+ });
344
+
345
+ test("should react to query parameters", async () => {
346
+ const testFragment = defineFragment("test-fragment");
347
+ const testRoutes = [
348
+ defineRoute({
349
+ method: "GET",
350
+ path: "/users",
351
+ outputSchema: z.array(z.object({ id: z.number(), name: z.string(), role: z.string() })),
352
+ handler: async (_ctx, { json }) => json([{ id: 1, name: "John", role: "admin" }]),
353
+ }),
354
+ ] as const;
355
+
356
+ vi.mocked(global.fetch).mockImplementation(async (input) => {
357
+ assert(typeof input === "string");
358
+
359
+ const url = new URL(input);
360
+ const role = url.searchParams.get("role");
361
+ const limit = url.searchParams.get("limit");
362
+
363
+ expect(role).toBeDefined();
364
+
365
+ return {
366
+ headers: new Headers(),
367
+ ok: true,
368
+ json: async () => [
369
+ { id: 1, name: "John", role: role! },
370
+ ...(limit === "2" ? [{ id: 2, name: "Jane", role: role! }] : []),
371
+ ],
372
+ } as Response;
373
+ });
374
+
375
+ const cb = createClientBuilder(testFragment, clientConfig, testRoutes);
376
+ const useUsers = cb.createHook("/users");
377
+
378
+ const roleAtom = atom("admin");
379
+ const limitAtom = atom("1");
380
+ const store = useUsers.store({ query: { role: roleAtom, limit: limitAtom } });
381
+
382
+ const itt = createAsyncIteratorFromCallback(store.listen);
383
+
384
+ {
385
+ const { value } = await itt.next();
386
+ expect(value).toEqual({
387
+ loading: true,
388
+ promise: expect.any(Promise),
389
+ data: undefined,
390
+ error: undefined,
391
+ });
392
+ }
393
+
394
+ {
395
+ const { value } = await itt.next();
396
+ expect(value).toEqual({
397
+ loading: false,
398
+ data: [{ id: 1, name: "John", role: "admin" }],
399
+ });
400
+ }
401
+
402
+ // Change role
403
+ roleAtom.set("user");
404
+
405
+ {
406
+ const { value } = await itt.next();
407
+ expect(value).toEqual({
408
+ loading: true,
409
+ promise: expect.any(Promise),
410
+ data: undefined,
411
+ error: undefined,
412
+ });
413
+ }
414
+
415
+ {
416
+ const { value } = await itt.next();
417
+ expect(value).toEqual({
418
+ loading: false,
419
+ data: [{ id: 1, name: "John", role: "user" }],
420
+ });
421
+ }
422
+
423
+ // Change limit
424
+ limitAtom.set("2");
425
+
426
+ {
427
+ const { value } = await itt.next();
428
+ expect(value).toEqual({
429
+ loading: true,
430
+ promise: expect.any(Promise),
431
+ data: undefined,
432
+ error: undefined,
433
+ });
434
+ }
435
+
436
+ {
437
+ const { value } = await itt.next();
438
+ expect(value).toEqual({
439
+ loading: false,
440
+ data: [
441
+ { id: 1, name: "John", role: "user" },
442
+ { id: 2, name: "Jane", role: "user" },
443
+ ],
444
+ });
445
+ }
446
+ });
447
+
448
+ test("should react to combined path and query parameters", async () => {
449
+ const testFragment = defineFragment("test-fragment");
450
+ const testRoutes = [
451
+ defineRoute({
452
+ method: "GET",
453
+ path: "/users/:id/posts",
454
+ outputSchema: z.array(z.object({ id: z.number(), title: z.string(), userId: z.number() })),
455
+ handler: async (_ctx, { json }) => json([{ id: 1, title: "Post", userId: 1 }]),
456
+ }),
457
+ ] as const;
458
+
459
+ vi.mocked(global.fetch).mockImplementation(async (input) => {
460
+ assert(typeof input === "string");
461
+
462
+ const url = new URL(input);
463
+ const [, userId] = url.pathname.match(/\/users\/([^/]+)\/posts/) ?? [];
464
+ const status = url.searchParams.get("status");
465
+ const limit = url.searchParams.get("limit");
466
+
467
+ expect(userId).toBeDefined();
468
+ expect(status).toBeDefined();
469
+
470
+ const numPosts = limit === "2" ? 2 : 1;
471
+ const posts = Array.from({ length: numPosts }, (_, i) => ({
472
+ id: i + 1,
473
+ title: `${status} Post ${i + 1}`,
474
+ userId: Number(userId),
475
+ }));
476
+
477
+ return {
478
+ headers: new Headers(),
479
+ ok: true,
480
+ json: async () => posts,
481
+ } as Response;
482
+ });
483
+
484
+ const cb = createClientBuilder(testFragment, clientConfig, testRoutes);
485
+ const usePosts = cb.createHook("/users/:id/posts");
486
+
487
+ const userIdAtom = atom("123");
488
+ const statusAtom = atom("published");
489
+ const store = usePosts.store({
490
+ path: { id: userIdAtom },
491
+ query: { status: statusAtom, limit: "1" },
492
+ });
493
+
494
+ const itt = createAsyncIteratorFromCallback(store.listen);
495
+
496
+ {
497
+ const { value } = await itt.next();
498
+ expect(value).toEqual({
499
+ loading: true,
500
+ promise: expect.any(Promise),
501
+ data: undefined,
502
+ error: undefined,
503
+ });
504
+ }
505
+
506
+ {
507
+ const { value } = await itt.next();
508
+ expect(value).toEqual({
509
+ loading: false,
510
+ data: [{ id: 1, title: "published Post 1", userId: 123 }],
511
+ });
512
+ }
513
+
514
+ // Change path parameter
515
+ userIdAtom.set("456");
516
+
517
+ {
518
+ const { value } = await itt.next();
519
+ expect(value).toEqual({
520
+ loading: true,
521
+ promise: expect.any(Promise),
522
+ data: undefined,
523
+ error: undefined,
524
+ });
525
+ }
526
+
527
+ {
528
+ const { value } = await itt.next();
529
+ expect(value).toEqual({
530
+ loading: false,
531
+ data: [{ id: 1, title: "published Post 1", userId: 456 }],
532
+ });
533
+ }
534
+
535
+ // Change query parameter
536
+ statusAtom.set("draft");
537
+
538
+ {
539
+ const { value } = await itt.next();
540
+ expect(value).toEqual({
541
+ loading: true,
542
+ promise: expect.any(Promise),
543
+ data: undefined,
544
+ error: undefined,
545
+ });
546
+ }
547
+
548
+ {
549
+ const { value } = await itt.next();
550
+ expect(value).toEqual({
551
+ loading: false,
552
+ data: [{ id: 1, title: "draft Post 1", userId: 456 }],
553
+ });
554
+ }
555
+ });
556
+
557
+ test("should handle mixed atoms and non-atoms in parameters", async () => {
558
+ const testFragment = defineFragment("test-fragment");
559
+ const testRoutes = [
560
+ defineRoute({
561
+ method: "GET",
562
+ path: "/users/:id/posts",
563
+ outputSchema: z.array(
564
+ z.object({ id: z.number(), title: z.string(), category: z.string() }),
565
+ ),
566
+ handler: async (_ctx, { json }) => json([{ id: 1, title: "Post", category: "tech" }]),
567
+ }),
568
+ ] as const;
569
+
570
+ let fetchCallCount = 0;
571
+ vi.mocked(global.fetch).mockImplementation(async (input) => {
572
+ fetchCallCount++;
573
+ assert(typeof input === "string");
574
+
575
+ const url = new URL(input);
576
+ const [, userId] = url.pathname.match(/\/users\/([^/]+)\/posts/) ?? [];
577
+ const category = url.searchParams.get("category");
578
+ const sort = url.searchParams.get("sort");
579
+
580
+ expect(userId).toBeDefined();
581
+ expect(category).toBeDefined();
582
+ expect(sort).toBe("desc");
583
+
584
+ return {
585
+ headers: new Headers(),
586
+ ok: true,
587
+ json: async () => [
588
+ {
589
+ id: fetchCallCount,
590
+ title: `Post ${fetchCallCount}`,
591
+ category: category!,
592
+ },
593
+ ],
594
+ } as Response;
595
+ });
596
+
597
+ const cb = createClientBuilder(testFragment, clientConfig, testRoutes);
598
+ const usePosts = cb.createHook("/users/:id/posts");
599
+
600
+ const userIdAtom = atom("123");
601
+ const categoryAtom = atom("tech");
602
+ // sort is a non-atom (static value)
603
+ const store = usePosts.store({
604
+ path: { id: userIdAtom },
605
+ query: { category: categoryAtom, sort: "desc" },
606
+ });
607
+
608
+ const itt = createAsyncIteratorFromCallback(store.listen);
609
+
610
+ {
611
+ const { value } = await itt.next();
612
+ expect(value).toEqual({
613
+ loading: true,
614
+ promise: expect.any(Promise),
615
+ data: undefined,
616
+ error: undefined,
617
+ });
618
+ }
619
+
620
+ {
621
+ const { value } = await itt.next();
622
+ expect(value).toEqual({
623
+ loading: false,
624
+ data: [{ id: 1, title: "Post 1", category: "tech" }],
625
+ });
626
+ }
627
+
628
+ expect(fetchCallCount).toBe(1);
629
+
630
+ // Change atom parameter - should trigger refetch
631
+ categoryAtom.set("science");
632
+
633
+ {
634
+ const { value } = await itt.next();
635
+ expect(value).toEqual({
636
+ loading: true,
637
+ promise: expect.any(Promise),
638
+ data: undefined,
639
+ error: undefined,
640
+ });
641
+ }
642
+
643
+ {
644
+ const { value } = await itt.next();
645
+ expect(value).toEqual({
646
+ loading: false,
647
+ data: [{ id: 2, title: "Post 2", category: "science" }],
648
+ });
649
+ }
650
+
651
+ expect(fetchCallCount).toBe(2);
652
+
653
+ // Change path atom parameter - should trigger refetch
654
+ userIdAtom.set("456");
655
+
656
+ {
657
+ const { value } = await itt.next();
658
+ expect(value).toEqual({
659
+ loading: true,
660
+ promise: expect.any(Promise),
661
+ data: undefined,
662
+ error: undefined,
663
+ });
664
+ }
665
+
666
+ {
667
+ const { value } = await itt.next();
668
+ expect(value).toEqual({
669
+ loading: false,
670
+ data: [{ id: 3, title: "Post 3", category: "science" }],
671
+ });
672
+ }
673
+
674
+ expect(fetchCallCount).toBe(3);
675
+ });
676
+
677
+ test("should not refetch when non-atom parameters remain unchanged", async () => {
678
+ const testFragment = defineFragment("test-fragment");
679
+ const testRoutes = [
680
+ defineRoute({
681
+ method: "GET",
682
+ path: "/users/:id",
683
+ outputSchema: z.object({ id: z.number(), name: z.string() }),
684
+ handler: async (_ctx, { json }) => json({ id: 1, name: "John" }),
685
+ }),
686
+ ] as const;
687
+
688
+ let fetchCallCount = 0;
689
+ vi.mocked(global.fetch).mockImplementation(async (input) => {
690
+ fetchCallCount++;
691
+ assert(typeof input === "string");
692
+
693
+ const [, id] = String(input).match(/\/users\/([^/]+)/) ?? [];
694
+ expect(id).toBeDefined();
695
+
696
+ return {
697
+ headers: new Headers(),
698
+ ok: true,
699
+ json: async () => ({ id: Number(id), name: `John${fetchCallCount}` }),
700
+ } as Response;
701
+ });
702
+
703
+ const cb = createClientBuilder(testFragment, clientConfig, testRoutes);
704
+ const useUser = cb.createHook("/users/:id");
705
+
706
+ const reactiveIdAtom = atom("123");
707
+ // Create store with mixed params: one atom, one static
708
+ const store = useUser.store({ path: { id: reactiveIdAtom } });
709
+
710
+ const itt = createAsyncIteratorFromCallback(store.listen);
711
+
712
+ // Initial load
713
+ {
714
+ const { value } = await itt.next();
715
+ expect(value).toEqual({
716
+ loading: true,
717
+ promise: expect.any(Promise),
718
+ data: undefined,
719
+ error: undefined,
720
+ });
721
+ }
722
+
723
+ {
724
+ const { value } = await itt.next();
725
+ expect(value).toEqual({
726
+ loading: false,
727
+ data: { id: 123, name: "John1" },
728
+ });
729
+ }
730
+
731
+ expect(fetchCallCount).toBe(1);
732
+
733
+ // Create a second store with the same static parameter values
734
+ // This should not trigger additional fetches since the cache key is the same
735
+ const store2 = useUser.store({ path: { id: "123" } });
736
+ const itt2 = createAsyncIteratorFromCallback(store2.listen);
737
+
738
+ // Should get cached result immediately
739
+ {
740
+ const { value } = await itt2.next();
741
+ expect(value).toEqual({
742
+ loading: false,
743
+ data: { id: 123, name: "John1" },
744
+ });
745
+ }
746
+
747
+ // No additional fetch should have occurred
748
+ expect(fetchCallCount).toBe(1);
749
+
750
+ // Now change the reactive atom - should trigger new fetch
751
+ reactiveIdAtom.set("456");
752
+
753
+ // Note: the behaviour in the next two steps is pretty weird, as first we keep the old data
754
+ // but then we go to loading anyway.
755
+ {
756
+ const { value } = await itt.next();
757
+ expect(value).toEqual({
758
+ loading: false,
759
+ data: { id: 123, name: "John1" },
760
+ });
761
+ }
762
+
763
+ {
764
+ const { value } = await itt.next();
765
+ expect(value).toEqual({
766
+ loading: true,
767
+ promise: expect.any(Promise),
768
+ data: undefined,
769
+ error: undefined,
770
+ });
771
+ }
772
+
773
+ {
774
+ const { value } = await itt.next();
775
+ expect(value).toEqual({
776
+ loading: false,
777
+ data: { id: 456, name: "John2" },
778
+ });
779
+ }
780
+
781
+ expect(fetchCallCount).toBe(2);
782
+ });
783
+
784
+ test("should handle multiple reactive query parameters independently", async () => {
785
+ const testFragment = defineFragment("test-fragment");
786
+ const testRoutes = [
787
+ defineRoute({
788
+ method: "GET",
789
+ path: "/posts",
790
+ outputSchema: z.array(
791
+ z.object({
792
+ id: z.number(),
793
+ title: z.string(),
794
+ category: z.string(),
795
+ status: z.string(),
796
+ }),
797
+ ),
798
+ queryParameters: ["category", "status", "author"],
799
+ handler: async (_ctx, { json }) =>
800
+ json([{ id: 1, title: "Post", category: "tech", status: "published" }]),
801
+ }),
802
+ ] as const;
803
+
804
+ let fetchCallCount = 0;
805
+ vi.mocked(global.fetch).mockImplementation(async (input) => {
806
+ fetchCallCount++;
807
+ assert(typeof input === "string");
808
+
809
+ const url = new URL(input);
810
+ const category = url.searchParams.get("category") || "tech";
811
+ const status = url.searchParams.get("status") || "published";
812
+ const author = url.searchParams.get("author") || "john";
813
+
814
+ return {
815
+ headers: new Headers(),
816
+ ok: true,
817
+ json: async () => [
818
+ {
819
+ id: fetchCallCount,
820
+ title: `${category} ${status} Post by ${author}`,
821
+ category,
822
+ status,
823
+ },
824
+ ],
825
+ } as Response;
826
+ });
827
+
828
+ const cb = createClientBuilder(testFragment, clientConfig, testRoutes);
829
+ const usePosts = cb.createHook("/posts");
830
+
831
+ const categoryAtom = atom("tech");
832
+ const statusAtom = atom("published");
833
+ const authorAtom = atom("john");
834
+
835
+ const store = usePosts.store({
836
+ query: {
837
+ category: categoryAtom,
838
+ status: statusAtom,
839
+ author: authorAtom,
840
+ },
841
+ });
842
+
843
+ const itt = createAsyncIteratorFromCallback(store.listen);
844
+
845
+ // Initial load
846
+ {
847
+ const { value } = await itt.next();
848
+ expect(value).toEqual({
849
+ loading: true,
850
+ promise: expect.any(Promise),
851
+ data: undefined,
852
+ error: undefined,
853
+ });
854
+ }
855
+
856
+ {
857
+ const { value } = await itt.next();
858
+ expect(value).toEqual({
859
+ loading: false,
860
+ data: [
861
+ { id: 1, title: "tech published Post by john", category: "tech", status: "published" },
862
+ ],
863
+ });
864
+ }
865
+
866
+ expect(fetchCallCount).toBe(1);
867
+
868
+ // Change first atom
869
+ categoryAtom.set("science");
870
+
871
+ {
872
+ const { value } = await itt.next();
873
+ expect(value).toEqual({
874
+ loading: true,
875
+ promise: expect.any(Promise),
876
+ data: undefined,
877
+ error: undefined,
878
+ });
879
+ }
880
+
881
+ {
882
+ const { value } = await itt.next();
883
+ expect(value).toEqual({
884
+ loading: false,
885
+ data: [
886
+ {
887
+ id: 2,
888
+ title: "science published Post by john",
889
+ category: "science",
890
+ status: "published",
891
+ },
892
+ ],
893
+ });
894
+ }
895
+
896
+ expect(fetchCallCount).toBe(2);
897
+
898
+ // Change second atom
899
+ statusAtom.set("draft");
900
+
901
+ {
902
+ const { value } = await itt.next();
903
+ expect(value).toEqual({
904
+ loading: true,
905
+ promise: expect.any(Promise),
906
+ data: undefined,
907
+ error: undefined,
908
+ });
909
+ }
910
+
911
+ {
912
+ const { value } = await itt.next();
913
+ expect(value).toEqual({
914
+ loading: false,
915
+ data: [
916
+ { id: 3, title: "science draft Post by john", category: "science", status: "draft" },
917
+ ],
918
+ });
919
+ }
920
+
921
+ expect(fetchCallCount).toBe(3);
922
+
923
+ // Change third atom
924
+ authorAtom.set("jane");
925
+
926
+ {
927
+ const { value } = await itt.next();
928
+ expect(value).toEqual({
929
+ loading: true,
930
+ promise: expect.any(Promise),
931
+ data: undefined,
932
+ error: undefined,
933
+ });
934
+ }
935
+
936
+ {
937
+ const { value } = await itt.next();
938
+ expect(value).toEqual({
939
+ loading: false,
940
+ data: [
941
+ { id: 4, title: "science draft Post by jane", category: "science", status: "draft" },
942
+ ],
943
+ });
944
+ }
945
+
946
+ expect(fetchCallCount).toBe(4);
947
+ });
948
+ });
949
+
950
+ describe("createHook - streaming", () => {
951
+ const clientConfig: FragnoPublicClientConfig = {
952
+ baseUrl: "http://localhost:3000",
953
+ };
954
+
955
+ beforeEach(() => {
956
+ vi.clearAllMocks();
957
+ (global.fetch as ReturnType<typeof vi.fn>).mockReset();
958
+ });
959
+
960
+ afterEach(() => {
961
+ vi.restoreAllMocks();
962
+ });
963
+
964
+ test("Should be able to stream data and receive updates in store (store.listen)", async () => {
965
+ const streamFragmentDefinition = defineFragment("stream-fragment");
966
+ const streamRoutes = [
967
+ defineRoute({
968
+ method: "GET",
969
+ path: "/users-stream",
970
+ outputSchema: z.array(z.object({ id: z.number(), name: z.string() })),
971
+ handler: async () => {
972
+ throw new Error("Not implemented");
973
+ },
974
+ }),
975
+ ] as const;
976
+ const client = createClientBuilder(streamFragmentDefinition, clientConfig, streamRoutes);
977
+ const clientObj = {
978
+ useUsersStream: client.createHook("/users-stream"),
979
+ };
980
+
981
+ vi.mocked(global.fetch).mockImplementation(async () => {
982
+ const ctx = new RequestOutputContext(streamRoutes[0].outputSchema);
983
+ return ctx.jsonStream(async (stream) => {
984
+ await stream.write({ id: 1, name: "John" });
985
+ await stream.sleep(1);
986
+ await stream.write({ id: 2, name: "Jane" });
987
+ await stream.sleep(1);
988
+ await stream.write({ id: 3, name: "Jim" });
989
+ });
990
+ });
991
+
992
+ const { useUsersStream } = clientObj;
993
+ const userStore = useUsersStream.store({});
994
+
995
+ const itt = createAsyncIteratorFromCallback(userStore.listen);
996
+
997
+ {
998
+ const { value } = await itt.next();
999
+ assert(value);
1000
+ expect(value.loading).toBe(true);
1001
+ expect(value.data).toBeUndefined();
1002
+ expect(value.error).toBeUndefined();
1003
+ }
1004
+
1005
+ {
1006
+ const { value } = await itt.next();
1007
+ assert(value);
1008
+ expect(value.loading).toBe(false);
1009
+ expect(value.data).toEqual([{ id: 1, name: "John" }]);
1010
+ expect(value.error).toBeUndefined();
1011
+ }
1012
+
1013
+ {
1014
+ const { value } = await itt.next();
1015
+ assert(value);
1016
+ expect(value.loading).toBe(false);
1017
+ expect(value.data).toEqual([
1018
+ { id: 1, name: "John" },
1019
+ { id: 2, name: "Jane" },
1020
+ ]);
1021
+ expect(value.error).toBeUndefined();
1022
+ }
1023
+
1024
+ {
1025
+ const { value } = await itt.next();
1026
+ assert(value);
1027
+ expect(value.loading).toBe(false);
1028
+ expect(value.data).toEqual([
1029
+ { id: 1, name: "John" },
1030
+ { id: 2, name: "Jane" },
1031
+ { id: 3, name: "Jim" },
1032
+ ]);
1033
+ expect(value.error).toBeUndefined();
1034
+ }
1035
+ });
1036
+
1037
+ test("throws FragnoClientUnknownApiError when the stream is not valid JSON", async () => {
1038
+ const streamErrorFragmentDefinition = defineFragment("stream-error-fragment");
1039
+ const streamErrorRoutes = [
1040
+ defineRoute({
1041
+ method: "GET",
1042
+ path: "/users-stream-error",
1043
+ outputSchema: z.array(z.object({ id: z.number(), name: z.string() })),
1044
+ handler: async () => {
1045
+ throw new Error("Not implemented");
1046
+ },
1047
+ }),
1048
+ ] as const;
1049
+
1050
+ vi.mocked(global.fetch).mockImplementation(async () => {
1051
+ const ctx = new RequestOutputContext(streamErrorRoutes[0].outputSchema);
1052
+ return ctx.jsonStream(async (stream) => {
1053
+ await stream.writeRaw("this is not json lol");
1054
+ });
1055
+ });
1056
+
1057
+ const client = createClientBuilder(
1058
+ streamErrorFragmentDefinition,
1059
+ clientConfig,
1060
+ streamErrorRoutes,
1061
+ );
1062
+ const clientObj = {
1063
+ useUsersStreamError: client.createHook("/users-stream-error"),
1064
+ };
1065
+
1066
+ const { useUsersStreamError } = clientObj;
1067
+ const userStore = useUsersStreamError.store({});
1068
+
1069
+ const { error } = await waitForAsyncIterator(
1070
+ createAsyncIteratorFromCallback(userStore.listen),
1071
+ (value) => value.loading === false && value.error !== undefined,
1072
+ );
1073
+
1074
+ expect(error).toBeInstanceOf(FragnoClientUnknownApiError);
1075
+ });
1076
+
1077
+ test("throws FragnoClientUnknownApiError when the stream is new lines only", async () => {
1078
+ const streamErrorFragmentDefinition = defineFragment("stream-error-fragment");
1079
+ const streamErrorRoutes = [
1080
+ defineRoute({
1081
+ method: "GET",
1082
+ path: "/users-stream-error",
1083
+ outputSchema: z.array(z.object({ id: z.number(), name: z.string() })),
1084
+ handler: async () => {
1085
+ throw new Error("Not implemented");
1086
+ },
1087
+ }),
1088
+ ] as const;
1089
+
1090
+ vi.mocked(global.fetch).mockImplementation(async () => {
1091
+ const ctx = new RequestOutputContext(streamErrorRoutes[0].outputSchema);
1092
+ return ctx.jsonStream(async (stream) => {
1093
+ await stream.writeRaw("\n\n");
1094
+ });
1095
+ });
1096
+
1097
+ const client = createClientBuilder(
1098
+ streamErrorFragmentDefinition,
1099
+ clientConfig,
1100
+ streamErrorRoutes,
1101
+ );
1102
+ const clientObj = {
1103
+ useUsersStreamError: client.createHook("/users-stream-error"),
1104
+ };
1105
+
1106
+ const { useUsersStreamError } = clientObj;
1107
+ const userStore = useUsersStreamError.store({});
1108
+
1109
+ const { error } = await waitForAsyncIterator(
1110
+ createAsyncIteratorFromCallback(userStore.listen),
1111
+ (value) => value.loading === false && value.error !== undefined,
1112
+ );
1113
+
1114
+ expect(error).toBeInstanceOf(FragnoClientUnknownApiError);
1115
+ });
1116
+
1117
+ test("throws FragnoClientUnknownApiError with cause SyntaxError when the stream is not valid JSON (multiple empty lines)", async () => {
1118
+ const streamErrorFragmentDefinition = defineFragment("stream-error-fragment");
1119
+ const streamErrorRoutes = [
1120
+ defineRoute({
1121
+ method: "GET",
1122
+ path: "/users-stream-error",
1123
+ outputSchema: z.array(z.object({ id: z.number(), name: z.string() })),
1124
+ handler: async () => {
1125
+ throw new Error("Not implemented");
1126
+ },
1127
+ }),
1128
+ ] as const;
1129
+
1130
+ vi.mocked(global.fetch).mockImplementation(async () => {
1131
+ const ctx = new RequestOutputContext(streamErrorRoutes[0].outputSchema);
1132
+ return ctx.jsonStream(async (stream) => {
1133
+ await stream.writeRaw("this is not json lol\n\n");
1134
+ });
1135
+ });
1136
+
1137
+ const client = createClientBuilder(
1138
+ streamErrorFragmentDefinition,
1139
+ clientConfig,
1140
+ streamErrorRoutes,
1141
+ );
1142
+ const clientObj = {
1143
+ useUsersStreamError: client.createHook("/users-stream-error"),
1144
+ };
1145
+
1146
+ const { useUsersStreamError } = clientObj;
1147
+ const userStore = useUsersStreamError.store({});
1148
+
1149
+ const { error } = await waitForAsyncIterator(
1150
+ createAsyncIteratorFromCallback(userStore.listen),
1151
+ (value) => value.loading === false && value.error !== undefined,
1152
+ );
1153
+
1154
+ expect(error).toBeInstanceOf(FragnoClientUnknownApiError);
1155
+ assert(error!.cause instanceof SyntaxError); // JSON parse failure gives a SyntaxError
1156
+ expect(error!.cause.message).toMatch(/Unexpected (token|identifier)/);
1157
+ });
1158
+ });
1159
+
1160
+ describe("createMutator", () => {
1161
+ const clientConfig: FragnoPublicClientConfig = {
1162
+ baseUrl: "http://localhost:3000",
1163
+ };
1164
+
1165
+ beforeEach(() => {
1166
+ vi.clearAllMocks();
1167
+ (global.fetch as ReturnType<typeof vi.fn>).mockReset();
1168
+ });
1169
+
1170
+ afterEach(() => {
1171
+ vi.restoreAllMocks();
1172
+ });
1173
+
1174
+ test("body is optional when no inputSchema in route", async () => {
1175
+ const testFragment = defineFragment("test-fragment");
1176
+ const testRoutes = [
1177
+ defineRoute({
1178
+ method: "DELETE",
1179
+ path: "/users/:id",
1180
+ handler: async (_ctx, { empty }) => empty(),
1181
+ }),
1182
+ ] as const;
1183
+
1184
+ vi.mocked(global.fetch).mockImplementation(async () => {
1185
+ return new Response(null, { status: 201 });
1186
+ });
1187
+
1188
+ const cb = createClientBuilder(testFragment, clientConfig, testRoutes);
1189
+ const deleteUser = cb.createMutator("DELETE", "/users/:id");
1190
+
1191
+ const result = await deleteUser.mutateQuery({
1192
+ path: { id: "123" },
1193
+ });
1194
+
1195
+ expect(result).toBeUndefined();
1196
+ });
1197
+ });
1198
+
1199
+ describe("createMutator - streaming", () => {
1200
+ const clientConfig: FragnoPublicClientConfig = {
1201
+ baseUrl: "http://localhost:3000",
1202
+ };
1203
+
1204
+ beforeEach(() => {
1205
+ vi.clearAllMocks();
1206
+ (global.fetch as ReturnType<typeof vi.fn>).mockReset();
1207
+ });
1208
+
1209
+ afterEach(() => {
1210
+ vi.restoreAllMocks();
1211
+ });
1212
+
1213
+ test("should support streaming responses for mutations", async () => {
1214
+ const mutationStreamFragmentDefinition = defineFragment("mutation-stream-fragment");
1215
+ const mutationStreamRoutes = [
1216
+ defineRoute({
1217
+ method: "POST",
1218
+ path: "/process-items",
1219
+ inputSchema: z.object({ items: z.array(z.string()) }),
1220
+ outputSchema: z.array(z.object({ item: z.string(), status: z.string() })),
1221
+ handler: async ({ input }, { jsonStream }) => {
1222
+ const data = await input.valid();
1223
+ const { items } = data!;
1224
+ return jsonStream(async (stream) => {
1225
+ for (const item of items) {
1226
+ await stream.write({ item, status: "processed" });
1227
+ await stream.sleep(1);
1228
+ }
1229
+ });
1230
+ },
1231
+ }),
1232
+ ] as const;
1233
+
1234
+ // Mock the fetch response for streaming with proper ReadableStream
1235
+ vi.mocked(global.fetch).mockImplementation(async () => {
1236
+ const encoder = new TextEncoder();
1237
+
1238
+ const stream = new ReadableStream({
1239
+ start(controller) {
1240
+ // Enqueue all chunks at once
1241
+ controller.enqueue(encoder.encode('{"item":"item1","status":"processed"}\n'));
1242
+ controller.enqueue(encoder.encode('{"item":"item2","status":"processed"}\n'));
1243
+ controller.enqueue(encoder.encode('{"item":"item3","status":"processed"}\n'));
1244
+ controller.close();
1245
+ },
1246
+ });
1247
+
1248
+ // Create a proper Response object with the stream
1249
+ const response = new Response(stream, {
1250
+ status: 200,
1251
+ headers: new Headers({
1252
+ "transfer-encoding": "chunked",
1253
+ "content-type": "application/x-ndjson",
1254
+ }),
1255
+ });
1256
+
1257
+ return response;
1258
+ });
1259
+
1260
+ const client = createClientBuilder(
1261
+ mutationStreamFragmentDefinition,
1262
+ clientConfig,
1263
+ mutationStreamRoutes,
1264
+ );
1265
+ const mutator = client.createMutator("POST", "/process-items");
1266
+
1267
+ const result = await mutator.mutateQuery({
1268
+ body: { items: ["item1", "item2", "item3"] },
1269
+ });
1270
+
1271
+ // Streaming mutations return all items
1272
+ expect(result).toEqual([
1273
+ { item: "item1", status: "processed" },
1274
+ { item: "item2", status: "processed" },
1275
+ { item: "item3", status: "processed" },
1276
+ ]);
1277
+ });
1278
+
1279
+ test("Should be able to mutate data and receive updates in store (store.subscribe)", async () => {
1280
+ const streamFragmentDefinition = defineFragment("stream-fragment");
1281
+ const streamRoutes = [
1282
+ defineRoute({
1283
+ method: "POST",
1284
+ path: "/users-stream",
1285
+ inputSchema: z.object({ items: z.array(z.string()) }),
1286
+ outputSchema: z.array(z.object({ id: z.number(), name: z.string() })),
1287
+ handler: async () => {
1288
+ throw new Error("Not implemented");
1289
+ },
1290
+ }),
1291
+ ] as const;
1292
+ const client = createClientBuilder(streamFragmentDefinition, clientConfig, streamRoutes);
1293
+ const useUsersMutateStream = client.createMutator("POST", "/users-stream");
1294
+
1295
+ vi.mocked(global.fetch).mockImplementation(async () => {
1296
+ const ctx = new RequestOutputContext(streamRoutes[0].outputSchema);
1297
+ return ctx.jsonStream(async (stream) => {
1298
+ await stream.write({ id: 1, name: "John" });
1299
+ await stream.sleep(0);
1300
+ await stream.write({ id: 2, name: "Jane" });
1301
+ await stream.sleep(0);
1302
+ await stream.write({ id: 3, name: "Jim" });
1303
+ });
1304
+ });
1305
+
1306
+ const { mutatorStore } = useUsersMutateStream;
1307
+ const itt = createAsyncIteratorFromCallback(mutatorStore.subscribe);
1308
+
1309
+ {
1310
+ const { value } = await itt.next();
1311
+ expect(value).toEqual({
1312
+ loading: false,
1313
+ data: undefined,
1314
+ error: undefined,
1315
+ mutate: expect.any(Function),
1316
+ });
1317
+ }
1318
+
1319
+ const firstItem = await mutatorStore.mutate({ body: { items: ["item1", "item2", "item3"] } });
1320
+ expect(firstItem).toEqual([{ id: 1, name: "John" }]);
1321
+
1322
+ {
1323
+ const { value } = await itt.next();
1324
+ expect(value).toEqual({
1325
+ loading: true,
1326
+ data: undefined,
1327
+ error: undefined,
1328
+ mutate: expect.any(Function),
1329
+ });
1330
+ }
1331
+
1332
+ {
1333
+ const { value } = await itt.next();
1334
+ assert(value);
1335
+ expect(value).toEqual({
1336
+ loading: true,
1337
+ data: [{ id: 1, name: "John" }],
1338
+ error: undefined,
1339
+ mutate: expect.any(Function),
1340
+ });
1341
+ }
1342
+
1343
+ {
1344
+ const { value } = await itt.next();
1345
+ assert(value);
1346
+ expect(value).toEqual({
1347
+ loading: false,
1348
+ data: [{ id: 1, name: "John" }],
1349
+ error: undefined,
1350
+ mutate: expect.any(Function),
1351
+ });
1352
+ }
1353
+
1354
+ {
1355
+ const { value } = await itt.next();
1356
+ assert(value);
1357
+ expect(value).toEqual({
1358
+ loading: false,
1359
+ data: [
1360
+ { id: 1, name: "John" },
1361
+ { id: 2, name: "Jane" },
1362
+ ],
1363
+ error: undefined,
1364
+ mutate: expect.any(Function),
1365
+ });
1366
+ }
1367
+
1368
+ {
1369
+ const { value } = await itt.next();
1370
+ assert(value);
1371
+ expect(value).toEqual({
1372
+ loading: false,
1373
+ data: [
1374
+ { id: 1, name: "John" },
1375
+ { id: 2, name: "Jane" },
1376
+ { id: 3, name: "Jim" },
1377
+ ],
1378
+ error: undefined,
1379
+ mutate: expect.any(Function),
1380
+ });
1381
+ }
1382
+ });
1383
+
1384
+ test("Should be able to mutate data and receive updates in store (store.listen)", async () => {
1385
+ const streamFragmentDefinition = defineFragment("stream-fragment");
1386
+ const streamRoutes = [
1387
+ defineRoute({
1388
+ method: "POST",
1389
+ path: "/users-stream",
1390
+ inputSchema: z.object({ items: z.array(z.string()) }),
1391
+ outputSchema: z.array(z.object({ id: z.number(), name: z.string() })),
1392
+ handler: async () => {
1393
+ throw new Error("Not implemented");
1394
+ },
1395
+ }),
1396
+ ] as const;
1397
+ const client = createClientBuilder(streamFragmentDefinition, clientConfig, streamRoutes);
1398
+ const useUsersMutateStream = client.createMutator("POST", "/users-stream");
1399
+
1400
+ vi.mocked(global.fetch).mockImplementation(async () => {
1401
+ const ctx = new RequestOutputContext(streamRoutes[0].outputSchema);
1402
+ return ctx.jsonStream(async (stream) => {
1403
+ await stream.write({ id: 1, name: "John" });
1404
+ await stream.sleep(0);
1405
+ await stream.write({ id: 2, name: "Jane" });
1406
+ await stream.sleep(0);
1407
+ await stream.write({ id: 3, name: "Jim" });
1408
+ });
1409
+ });
1410
+
1411
+ const { mutatorStore } = useUsersMutateStream;
1412
+ const itt = createAsyncIteratorFromCallback(mutatorStore.listen);
1413
+
1414
+ const firstItem = await mutatorStore.mutate({ body: { items: ["item1", "item2", "item3"] } });
1415
+ expect(firstItem).toEqual([{ id: 1, name: "John" }]);
1416
+
1417
+ {
1418
+ const { value } = await itt.next();
1419
+ assert(value);
1420
+ expect(value).toEqual({
1421
+ loading: true,
1422
+ data: undefined,
1423
+ error: undefined,
1424
+ mutate: expect.any(Function),
1425
+ });
1426
+ }
1427
+
1428
+ {
1429
+ const { value } = await itt.next();
1430
+ assert(value);
1431
+ expect(value).toEqual({
1432
+ loading: true,
1433
+ data: [{ id: 1, name: "John" }],
1434
+ error: undefined,
1435
+ mutate: expect.any(Function),
1436
+ });
1437
+ }
1438
+
1439
+ {
1440
+ const { value } = await itt.next();
1441
+ assert(value);
1442
+ expect(value).toEqual({
1443
+ loading: false,
1444
+ data: [{ id: 1, name: "John" }],
1445
+ error: undefined,
1446
+ mutate: expect.any(Function),
1447
+ });
1448
+ }
1449
+
1450
+ {
1451
+ const { value } = await itt.next();
1452
+ assert(value);
1453
+ expect(value).toEqual({
1454
+ loading: false,
1455
+ data: [
1456
+ { id: 1, name: "John" },
1457
+ { id: 2, name: "Jane" },
1458
+ ],
1459
+ error: undefined,
1460
+ mutate: expect.any(Function),
1461
+ });
1462
+ }
1463
+
1464
+ {
1465
+ const { value } = await itt.next();
1466
+ assert(value);
1467
+ expect(value).toEqual({
1468
+ loading: false,
1469
+ data: [
1470
+ { id: 1, name: "John" },
1471
+ { id: 2, name: "Jane" },
1472
+ { id: 3, name: "Jim" },
1473
+ ],
1474
+ error: undefined,
1475
+ mutate: expect.any(Function),
1476
+ });
1477
+ }
1478
+ });
1479
+ });
1480
+
1481
+ describe("computed", () => {
1482
+ const clientConfig: FragnoPublicClientConfig = {
1483
+ baseUrl: "http://localhost:3000",
1484
+ };
1485
+
1486
+ beforeEach(() => {
1487
+ vi.clearAllMocks();
1488
+ (global.fetch as ReturnType<typeof vi.fn>).mockReset();
1489
+ });
1490
+
1491
+ afterEach(() => {
1492
+ vi.restoreAllMocks();
1493
+ });
1494
+
1495
+ test("Derived from streaming route", async () => {
1496
+ const streamFragmentDefinition = defineFragment("stream-fragment");
1497
+ const streamRoutes = [
1498
+ defineRoute({
1499
+ method: "GET",
1500
+ path: "/users-stream",
1501
+ outputSchema: z.array(z.object({ id: z.number(), name: z.string() })),
1502
+ handler: async () => {
1503
+ throw new Error("Not implemented");
1504
+ },
1505
+ }),
1506
+ ] as const;
1507
+ const client = createClientBuilder(streamFragmentDefinition, clientConfig, streamRoutes);
1508
+ const useUsersStream = client.createHook("/users-stream");
1509
+
1510
+ vi.mocked(global.fetch).mockImplementation(async () => {
1511
+ const ctx = new RequestOutputContext(streamRoutes[0].outputSchema);
1512
+ return ctx.jsonStream(async (stream) => {
1513
+ await stream.write({ id: 1, name: "John" });
1514
+ await stream.sleep(1);
1515
+ await stream.write({ id: 2, name: "Jane" });
1516
+ await stream.sleep(1);
1517
+ await stream.write({ id: 3, name: "Jim" });
1518
+ });
1519
+ });
1520
+
1521
+ const userStore = useUsersStream.store({});
1522
+
1523
+ const names = computed(userStore, ({ data }) => data?.map((user) => user.name).join(", "));
1524
+ const itt = createAsyncIteratorFromCallback(names.listen);
1525
+
1526
+ {
1527
+ const { value } = await itt.next();
1528
+ expect(value).toBe("John");
1529
+ }
1530
+
1531
+ {
1532
+ const { value } = await itt.next();
1533
+ expect(value).toBe("John, Jane");
1534
+ }
1535
+
1536
+ {
1537
+ const { value } = await itt.next();
1538
+ expect(value).toBe("John, Jane, Jim");
1539
+ }
1540
+ });
1541
+
1542
+ test("Derived from streaming route with atom usage", async () => {
1543
+ const streamFragmentDefinition = defineFragment("stream-fragment");
1544
+ const streamRoutes = [
1545
+ defineRoute({
1546
+ method: "GET",
1547
+ path: "/users-stream",
1548
+ outputSchema: z.array(
1549
+ z.object({ num: z.number(), status: z.enum(["continue", "half-way", "done"]) }),
1550
+ ),
1551
+ handler: async () => {
1552
+ throw new Error("Not implemented");
1553
+ },
1554
+ }),
1555
+ ] as const;
1556
+ const client = createClientBuilder(streamFragmentDefinition, clientConfig, streamRoutes);
1557
+ const useUsersStream = client.createHook("/users-stream");
1558
+
1559
+ vi.mocked(global.fetch).mockImplementation(async () => {
1560
+ const ctx = new RequestOutputContext(streamRoutes[0].outputSchema);
1561
+ return ctx.jsonStream(async (stream) => {
1562
+ await stream.write({ num: 8, status: "continue" });
1563
+ await stream.sleep(1);
1564
+ await stream.write({ num: 17, status: "half-way" });
1565
+ await stream.sleep(1);
1566
+ await stream.write({ num: 3, status: "done" });
1567
+ });
1568
+ });
1569
+
1570
+ const userStore = useUsersStream.store({});
1571
+
1572
+ const product = computed(
1573
+ userStore,
1574
+ ({ data }) => data?.map((user) => user.num).reduce((acc, num) => acc * num, 1) ?? 1,
1575
+ );
1576
+ const highestNum = atom(0);
1577
+ effect([userStore], ({ data }) => {
1578
+ if (!Array.isArray(data) || data.length === 0) {
1579
+ return;
1580
+ }
1581
+
1582
+ const latest = data[data.length - 1];
1583
+ highestNum.set(Math.max(highestNum.get(), latest.num));
1584
+ });
1585
+
1586
+ const productItt = createAsyncIteratorFromCallback(product.listen);
1587
+ const highestNumItt = createAsyncIteratorFromCallback(highestNum.listen);
1588
+
1589
+ {
1590
+ const { value } = await productItt.next();
1591
+ expect(value).toBe(8);
1592
+ }
1593
+
1594
+ {
1595
+ const { value } = await productItt.next();
1596
+ expect(value).toBe(136);
1597
+ }
1598
+
1599
+ {
1600
+ const { value } = await productItt.next();
1601
+ expect(value).toBe(408);
1602
+ }
1603
+
1604
+ {
1605
+ const { value } = await highestNumItt.next();
1606
+ expect(value).toBe(8);
1607
+ }
1608
+
1609
+ {
1610
+ const { value } = await highestNumItt.next();
1611
+ expect(value).toBe(17);
1612
+ }
1613
+
1614
+ // No last value on the highestNum iterator as it will stay '17' and thus no store update is
1615
+ // pushed.
1616
+ });
1617
+ });
1618
+
1619
+ describe("type guards", () => {
1620
+ const testFragment = defineFragment("test-fragment");
1621
+ const testRoutes = [
1622
+ defineRoute({
1623
+ method: "GET",
1624
+ path: "/users",
1625
+ outputSchema: z.array(z.object({ id: z.number(), name: z.string() })),
1626
+ handler: async (_ctx, { json }) => json([{ id: 1, name: "John" }]),
1627
+ }),
1628
+ defineRoute({
1629
+ method: "POST",
1630
+ path: "/users",
1631
+ inputSchema: z.object({ name: z.string() }),
1632
+ outputSchema: z.object({ id: z.number(), name: z.string() }),
1633
+ handler: async (_ctx, { json }) => json({ id: 2, name: "Jane" }),
1634
+ }),
1635
+ ] as const;
1636
+
1637
+ test("isGetHook should correctly identify GET hooks using symbols", () => {
1638
+ const client = createClientBuilder(testFragment, {}, testRoutes);
1639
+ const getHook = client.createHook("/users");
1640
+ const mutatorHook = client.createMutator("POST", "/users");
1641
+
1642
+ expect(isGetHook(getHook)).toBe(true);
1643
+ // Test that it correctly identifies non-GET hooks
1644
+ expect(isGetHook(mutatorHook)).toBe(false);
1645
+ });
1646
+
1647
+ test("isMutatorHook should correctly identify mutator hooks using symbols", () => {
1648
+ const client = createClientBuilder(testFragment, {}, testRoutes);
1649
+ const getHook = client.createHook("/users");
1650
+ const mutatorHook = client.createMutator("POST", "/users");
1651
+
1652
+ expect(isMutatorHook(mutatorHook)).toBe(true);
1653
+ // Test that it correctly identifies non-mutator hooks
1654
+ expect(isMutatorHook(getHook)).toBe(false);
1655
+ });
1656
+
1657
+ test("type guards should work correctly with symbol checking", () => {
1658
+ const client = createClientBuilder(testFragment, {}, testRoutes);
1659
+ const getHook = client.createHook("/users");
1660
+ const mutatorHook = client.createMutator("POST", "/users");
1661
+
1662
+ // Test that the hooks have the expected methods/properties
1663
+ expect("store" in getHook).toBe(true);
1664
+ expect("query" in getHook).toBe(true);
1665
+ expect("mutateQuery" in mutatorHook).toBe(true);
1666
+ expect("mutatorStore" in mutatorHook).toBe(true);
1667
+
1668
+ // The type guards should work based on symbols
1669
+ expect(isGetHook(getHook)).toBe(true);
1670
+ expect(isMutatorHook(mutatorHook)).toBe(true);
1671
+ });
1672
+
1673
+ test("type guards should work correctly with object checking", () => {
1674
+ expect(isGetHook(1)).toBe(false);
1675
+ expect(isMutatorHook("absence of hook")).toBe(false);
1676
+ expect(isGetHook({})).toBe(false);
1677
+ expect(isMutatorHook({})).toBe(false);
1678
+ expect(isGetHook(null)).toBe(false);
1679
+ expect(isMutatorHook(null)).toBe(false);
1680
+ expect(isGetHook(undefined)).toBe(false);
1681
+ expect(isMutatorHook(undefined)).toBe(false);
1682
+ expect(isGetHook(true)).toBe(false);
1683
+ expect(isMutatorHook(true)).toBe(false);
1684
+ expect(isGetHook(false)).toBe(false);
1685
+ expect(isMutatorHook(false)).toBe(false);
1686
+ expect(isGetHook(Symbol("fragno-get-hook"))).toBe(false);
1687
+ expect(isMutatorHook(Symbol("fragno-mutator-hook"))).toBe(false);
1688
+ expect(isGetHook(Symbol("fragno-get-hook"))).toBe(false);
1689
+ });
1690
+ });