@fragno-dev/core 0.1.7 → 0.1.9

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 (183) hide show
  1. package/.turbo/turbo-build.log +131 -64
  2. package/CHANGELOG.md +19 -0
  3. package/dist/api/api.d.ts +38 -2
  4. package/dist/api/api.d.ts.map +1 -0
  5. package/dist/api/api.js +9 -2
  6. package/dist/api/api.js.map +1 -0
  7. package/dist/api/bind-services.d.ts +6 -0
  8. package/dist/api/bind-services.d.ts.map +1 -0
  9. package/dist/api/bind-services.js +20 -0
  10. package/dist/api/bind-services.js.map +1 -0
  11. package/dist/api/error.d.ts +26 -0
  12. package/dist/api/error.d.ts.map +1 -0
  13. package/dist/{api-DngJDcmO.js → api/error.js} +2 -8
  14. package/dist/api/error.js.map +1 -0
  15. package/dist/api/fragment-definition-builder.d.ts +313 -0
  16. package/dist/api/fragment-definition-builder.d.ts.map +1 -0
  17. package/dist/api/fragment-definition-builder.js +326 -0
  18. package/dist/api/fragment-definition-builder.js.map +1 -0
  19. package/dist/api/fragment-instantiator.d.ts +216 -0
  20. package/dist/api/fragment-instantiator.d.ts.map +1 -0
  21. package/dist/api/fragment-instantiator.js +487 -0
  22. package/dist/api/fragment-instantiator.js.map +1 -0
  23. package/dist/api/fragno-response.d.ts +30 -0
  24. package/dist/api/fragno-response.d.ts.map +1 -0
  25. package/dist/api/fragno-response.js +73 -0
  26. package/dist/api/fragno-response.js.map +1 -0
  27. package/dist/api/internal/path.d.ts +50 -0
  28. package/dist/api/internal/path.d.ts.map +1 -0
  29. package/dist/api/internal/path.js +76 -0
  30. package/dist/api/internal/path.js.map +1 -0
  31. package/dist/api/internal/response-stream.d.ts +43 -0
  32. package/dist/api/internal/response-stream.d.ts.map +1 -0
  33. package/dist/api/internal/response-stream.js +81 -0
  34. package/dist/api/internal/response-stream.js.map +1 -0
  35. package/dist/api/internal/route.js +10 -0
  36. package/dist/api/internal/route.js.map +1 -0
  37. package/dist/api/mutable-request-state.d.ts +82 -0
  38. package/dist/api/mutable-request-state.d.ts.map +1 -0
  39. package/dist/api/mutable-request-state.js +97 -0
  40. package/dist/api/mutable-request-state.js.map +1 -0
  41. package/dist/api/request-context-storage.d.ts +42 -0
  42. package/dist/api/request-context-storage.d.ts.map +1 -0
  43. package/dist/api/request-context-storage.js +43 -0
  44. package/dist/api/request-context-storage.js.map +1 -0
  45. package/dist/api/request-input-context.d.ts +89 -0
  46. package/dist/api/request-input-context.d.ts.map +1 -0
  47. package/dist/api/request-input-context.js +118 -0
  48. package/dist/api/request-input-context.js.map +1 -0
  49. package/dist/api/request-middleware.d.ts +50 -0
  50. package/dist/api/request-middleware.d.ts.map +1 -0
  51. package/dist/api/request-middleware.js +83 -0
  52. package/dist/api/request-middleware.js.map +1 -0
  53. package/dist/api/request-output-context.d.ts +41 -0
  54. package/dist/api/request-output-context.d.ts.map +1 -0
  55. package/dist/api/request-output-context.js +119 -0
  56. package/dist/api/request-output-context.js.map +1 -0
  57. package/dist/api/route-handler-input-options.d.ts +21 -0
  58. package/dist/api/route-handler-input-options.d.ts.map +1 -0
  59. package/dist/api/route.d.ts +54 -3
  60. package/dist/api/route.d.ts.map +1 -0
  61. package/dist/api/route.js +29 -2
  62. package/dist/api/route.js.map +1 -0
  63. package/dist/api/shared-types.d.ts +47 -0
  64. package/dist/api/shared-types.d.ts.map +1 -0
  65. package/dist/api/shared-types.js +1 -0
  66. package/dist/client/client-error.d.ts +60 -0
  67. package/dist/client/client-error.d.ts.map +1 -0
  68. package/dist/client/client-error.js +92 -0
  69. package/dist/client/client-error.js.map +1 -0
  70. package/dist/client/client.d.ts +210 -4
  71. package/dist/client/client.d.ts.map +1 -0
  72. package/dist/client/client.js +397 -6
  73. package/dist/client/client.js.map +1 -0
  74. package/dist/client/client.svelte.d.ts +5 -3
  75. package/dist/client/client.svelte.d.ts.map +1 -1
  76. package/dist/client/client.svelte.js +1 -5
  77. package/dist/client/client.svelte.js.map +1 -1
  78. package/dist/client/internal/fetcher-merge.js +36 -0
  79. package/dist/client/internal/fetcher-merge.js.map +1 -0
  80. package/dist/client/internal/ndjson-streaming.js +139 -0
  81. package/dist/client/internal/ndjson-streaming.js.map +1 -0
  82. package/dist/client/react.d.ts +5 -3
  83. package/dist/client/react.d.ts.map +1 -1
  84. package/dist/client/react.js +3 -5
  85. package/dist/client/react.js.map +1 -1
  86. package/dist/client/solid.d.ts +5 -3
  87. package/dist/client/solid.d.ts.map +1 -1
  88. package/dist/client/solid.js +2 -5
  89. package/dist/client/solid.js.map +1 -1
  90. package/dist/client/vanilla.d.ts +5 -3
  91. package/dist/client/vanilla.d.ts.map +1 -1
  92. package/dist/client/vanilla.js +2 -43
  93. package/dist/client/vanilla.js.map +1 -1
  94. package/dist/client/vue.d.ts +5 -3
  95. package/dist/client/vue.d.ts.map +1 -1
  96. package/dist/client/vue.js +1 -5
  97. package/dist/client/vue.js.map +1 -1
  98. package/dist/http/http-status.d.ts +26 -0
  99. package/dist/http/http-status.d.ts.map +1 -0
  100. package/dist/integrations/react-ssr.js +1 -1
  101. package/dist/internal/symbols.d.ts +9 -0
  102. package/dist/internal/symbols.d.ts.map +1 -0
  103. package/dist/internal/symbols.js +10 -0
  104. package/dist/internal/symbols.js.map +1 -0
  105. package/dist/mod-client.d.ts +36 -0
  106. package/dist/mod-client.d.ts.map +1 -0
  107. package/dist/mod-client.js +21 -0
  108. package/dist/mod-client.js.map +1 -0
  109. package/dist/mod.d.ts +7 -4
  110. package/dist/mod.js +4 -6
  111. package/dist/request/request.d.ts +4 -0
  112. package/dist/request/request.js +5 -0
  113. package/dist/test/test.d.ts +62 -35
  114. package/dist/test/test.d.ts.map +1 -1
  115. package/dist/test/test.js +75 -40
  116. package/dist/test/test.js.map +1 -1
  117. package/dist/util/async.js +40 -0
  118. package/dist/util/async.js.map +1 -0
  119. package/dist/util/content-type.js +49 -0
  120. package/dist/util/content-type.js.map +1 -0
  121. package/dist/util/nanostores.js +31 -0
  122. package/dist/util/nanostores.js.map +1 -0
  123. package/dist/{ssr-BByDVfFD.js → util/ssr.js} +2 -2
  124. package/dist/util/ssr.js.map +1 -0
  125. package/dist/util/types-util.d.ts +8 -0
  126. package/dist/util/types-util.d.ts.map +1 -0
  127. package/package.json +19 -12
  128. package/src/api/api.ts +41 -6
  129. package/src/api/bind-services.ts +42 -0
  130. package/src/api/fragment-definition-builder.extend.test.ts +810 -0
  131. package/src/api/fragment-definition-builder.test.ts +499 -0
  132. package/src/api/fragment-definition-builder.ts +1088 -0
  133. package/src/api/fragment-instantiator.test.ts +1488 -0
  134. package/src/api/fragment-instantiator.ts +1053 -0
  135. package/src/api/fragment-services.test.ts +727 -0
  136. package/src/api/request-context-storage.ts +64 -0
  137. package/src/api/request-middleware.test.ts +301 -225
  138. package/src/api/route.test.ts +87 -1
  139. package/src/api/route.ts +345 -24
  140. package/src/api/shared-types.ts +43 -0
  141. package/src/client/client-builder.test.ts +23 -23
  142. package/src/client/client.ssr.test.ts +3 -3
  143. package/src/client/client.svelte.test.ts +15 -15
  144. package/src/client/client.test.ts +22 -22
  145. package/src/client/client.ts +72 -12
  146. package/src/client/internal/fetcher-merge.ts +1 -1
  147. package/src/client/react.test.ts +2 -2
  148. package/src/client/solid.test.ts +2 -2
  149. package/src/client/vanilla.test.ts +2 -2
  150. package/src/client/vue.test.ts +2 -2
  151. package/src/internal/symbols.ts +5 -0
  152. package/src/mod-client.ts +59 -0
  153. package/src/mod.ts +26 -9
  154. package/src/request/request.ts +8 -0
  155. package/src/test/test.test.ts +200 -381
  156. package/src/test/test.ts +190 -117
  157. package/tsdown.config.ts +8 -5
  158. package/dist/api/fragment-builder.d.ts +0 -4
  159. package/dist/api/fragment-builder.js +0 -3
  160. package/dist/api/fragment-instantiation.d.ts +0 -4
  161. package/dist/api/fragment-instantiation.js +0 -6
  162. package/dist/api-BWN97TOr.d.ts +0 -377
  163. package/dist/api-BWN97TOr.d.ts.map +0 -1
  164. package/dist/api-DngJDcmO.js.map +0 -1
  165. package/dist/client-C5LsYHEI.js +0 -782
  166. package/dist/client-C5LsYHEI.js.map +0 -1
  167. package/dist/fragment-builder-DOnCVBqc.js +0 -47
  168. package/dist/fragment-builder-DOnCVBqc.js.map +0 -1
  169. package/dist/fragment-builder-MGr68GNb.d.ts +0 -409
  170. package/dist/fragment-builder-MGr68GNb.d.ts.map +0 -1
  171. package/dist/fragment-instantiation-C4wvwl6V.js +0 -446
  172. package/dist/fragment-instantiation-C4wvwl6V.js.map +0 -1
  173. package/dist/request-output-context-CdIjwmEN.js +0 -320
  174. package/dist/request-output-context-CdIjwmEN.js.map +0 -1
  175. package/dist/route-Bl9Zr1Yv.d.ts +0 -26
  176. package/dist/route-Bl9Zr1Yv.d.ts.map +0 -1
  177. package/dist/route-C5Uryylh.js +0 -21
  178. package/dist/route-C5Uryylh.js.map +0 -1
  179. package/dist/ssr-BByDVfFD.js.map +0 -1
  180. package/src/api/fragment-builder.ts +0 -80
  181. package/src/api/fragment-instantiation.test.ts +0 -460
  182. package/src/api/fragment-instantiation.ts +0 -499
  183. package/src/api/fragment.test.ts +0 -537
@@ -0,0 +1,1488 @@
1
+ import { describe, it, expect, vi, expectTypeOf } from "vitest";
2
+ import { defineFragment } from "./fragment-definition-builder";
3
+ import { instantiate, FragnoInstantiatedFragment } from "./fragment-instantiator";
4
+ import { defineRoute, defineRoutes, type AnyFragmentDefinition } from "./route";
5
+ import type { FragnoPublicConfig } from "./shared-types";
6
+ import type { RequestThisContext } from "./api";
7
+ import { z } from "zod";
8
+
9
+ describe("fragment-instantiator", () => {
10
+ describe("basic instantiation", () => {
11
+ it("should instantiate a fragment with config and routes", () => {
12
+ interface Config {
13
+ apiKey: string;
14
+ }
15
+
16
+ const definition = defineFragment<Config>("test-fragment").build();
17
+
18
+ const route = defineRoute({
19
+ method: "GET",
20
+ path: "/hello",
21
+ handler: async (_input, { json }) => {
22
+ return json({ message: "hello" });
23
+ },
24
+ });
25
+
26
+ const fragment = instantiate(definition)
27
+ .withConfig({ apiKey: "test-key" })
28
+ .withRoutes([route])
29
+ .withOptions({ mountRoute: "/api" })
30
+ .build();
31
+
32
+ expect(fragment).toBeInstanceOf(FragnoInstantiatedFragment);
33
+ expect(fragment.name).toBe("test-fragment");
34
+ expect(fragment.routes).toHaveLength(1);
35
+ expect(fragment.mountRoute).toBe("/api");
36
+ });
37
+
38
+ it("should instantiate without config or routes", () => {
39
+ const definition = defineFragment("minimal-fragment").build();
40
+
41
+ const fragment = instantiate(definition).build();
42
+
43
+ expect(fragment).toBeInstanceOf(FragnoInstantiatedFragment);
44
+ expect(fragment.name).toBe("minimal-fragment");
45
+ expect(fragment.routes).toHaveLength(0);
46
+ });
47
+ });
48
+
49
+ describe("dependencies", () => {
50
+ it("should call dependencies callback and expose deps", () => {
51
+ interface Config {
52
+ apiKey: string;
53
+ }
54
+
55
+ interface Deps {
56
+ client: { apiKey: string };
57
+ }
58
+
59
+ const definition = defineFragment<Config, FragnoPublicConfig>(" test-fragment")
60
+ .withDependencies(
61
+ ({ config }): Deps => ({
62
+ client: { apiKey: config.apiKey },
63
+ }),
64
+ )
65
+ .build();
66
+
67
+ const fragment = instantiate(definition)
68
+ .withConfig({ apiKey: "test-key" })
69
+ .withOptions({})
70
+ .build();
71
+
72
+ expect(fragment.$internal.deps).toEqual({
73
+ client: { apiKey: "test-key" },
74
+ });
75
+ });
76
+
77
+ it("should provide options to dependencies callback", () => {
78
+ interface Config {
79
+ value: string;
80
+ }
81
+
82
+ interface Options extends FragnoPublicConfig {
83
+ customOption: string;
84
+ }
85
+
86
+ const definition = defineFragment<Config, Options>("test-fragment")
87
+ .withDependencies(({ config, options }) => ({
88
+ combined: `${config.value}-${options.customOption}`,
89
+ }))
90
+ .build();
91
+
92
+ const fragment = instantiate(definition)
93
+ .withConfig({ value: "config" })
94
+ .withOptions({ customOption: "option" })
95
+ .build();
96
+
97
+ expect(fragment.$internal.deps).toEqual({
98
+ combined: "config-option",
99
+ });
100
+ });
101
+ });
102
+
103
+ describe("base services", () => {
104
+ it("should call baseServices callback and expose services", () => {
105
+ interface Config {
106
+ prefix: string;
107
+ }
108
+
109
+ const definition = defineFragment<Config>("test-fragment")
110
+ .withDependencies(({ config }) => ({
111
+ prefix: config.prefix,
112
+ }))
113
+ .providesBaseService(({ deps }) => ({
114
+ greet: (name: string) => `${deps.prefix} ${name}`,
115
+ }))
116
+ .build();
117
+
118
+ const fragment = instantiate(definition)
119
+ .withConfig({ prefix: "Hello" })
120
+ .withOptions({})
121
+ .build();
122
+
123
+ expect(fragment.services.greet("World")).toBe("Hello World");
124
+ });
125
+
126
+ it("should provide config, options, deps to baseServices", () => {
127
+ interface Config {
128
+ value: string;
129
+ }
130
+
131
+ const definition = defineFragment<Config>("test-fragment")
132
+ .withDependencies(({ config }) => ({
133
+ dep: config.value,
134
+ }))
135
+ .providesBaseService(({ config, deps }) => ({
136
+ getValue: () => `${config.value}-${deps.dep}`,
137
+ }))
138
+ .build();
139
+
140
+ const fragment = instantiate(definition)
141
+ .withConfig({ value: "test" })
142
+ .withOptions({})
143
+ .build();
144
+
145
+ expect(fragment.services.getValue()).toBe("test-test");
146
+ });
147
+ });
148
+
149
+ describe("named services", () => {
150
+ it("should call namedServices factories and expose services", () => {
151
+ const definition = defineFragment("test-fragment")
152
+ .providesService("mathService", () => ({
153
+ add: (a: number, b: number) => a + b,
154
+ multiply: (a: number, b: number) => a * b,
155
+ }))
156
+ .build();
157
+
158
+ const fragment = instantiate(definition).withOptions({}).build();
159
+
160
+ expect(fragment.services.mathService.add(2, 3)).toBe(5);
161
+ expect(fragment.services.mathService.multiply(4, 5)).toBe(20);
162
+ });
163
+
164
+ it("should provide context to named service factories", () => {
165
+ interface Config {
166
+ multiplier: number;
167
+ }
168
+
169
+ const definition = defineFragment<Config>("test-fragment")
170
+ .withDependencies(({ config }) => ({
171
+ multiplier: config.multiplier,
172
+ }))
173
+ .providesService("mathService", ({ deps }) => ({
174
+ scale: (value: number) => value * deps.multiplier,
175
+ }))
176
+ .build();
177
+
178
+ const fragment = instantiate(definition)
179
+ .withConfig({ multiplier: 10 })
180
+ .withOptions({})
181
+ .build();
182
+
183
+ expect(fragment.services.mathService.scale(5)).toBe(50);
184
+ });
185
+
186
+ it("should merge base services and named services", () => {
187
+ const definition = defineFragment("test-fragment")
188
+ .providesBaseService(() => ({
189
+ baseMethod: () => "base",
190
+ }))
191
+ .providesService("namedService", () => ({
192
+ namedMethod: () => "named",
193
+ }))
194
+ .build();
195
+
196
+ const fragment = instantiate(definition).withOptions({}).build();
197
+
198
+ expect(fragment.services.baseMethod()).toBe("base");
199
+ expect(fragment.services.namedService.namedMethod()).toBe("named");
200
+ });
201
+ });
202
+
203
+ describe("service dependencies", () => {
204
+ it("should validate required service dependencies", () => {
205
+ interface EmailService {
206
+ send: (to: string) => Promise<void>;
207
+ }
208
+
209
+ const definition = defineFragment("test-fragment")
210
+ .usesService<"emailService", EmailService>("emailService")
211
+ .build();
212
+
213
+ expect(() => {
214
+ instantiate(definition).withOptions({}).build();
215
+ }).toThrow("Fragment 'test-fragment' requires service 'emailService'");
216
+ });
217
+
218
+ it("should accept provided service dependencies", () => {
219
+ interface EmailService {
220
+ send: (to: string) => Promise<void>;
221
+ }
222
+
223
+ const emailService: EmailService = {
224
+ send: async (to: string) => {
225
+ console.log(`Sending email to ${to}`);
226
+ },
227
+ };
228
+
229
+ const definition = defineFragment("test-fragment")
230
+ .usesService<"emailService", EmailService>("emailService")
231
+ .providesBaseService(({ serviceDeps }) => ({
232
+ sendWelcomeEmail: async (to: string) => {
233
+ await serviceDeps.emailService.send(to);
234
+ },
235
+ }))
236
+ .build();
237
+
238
+ const fragment = instantiate(definition)
239
+ .withOptions({})
240
+ .withServices({ emailService })
241
+ .build();
242
+
243
+ expect(fragment.services.sendWelcomeEmail).toBeDefined();
244
+ });
245
+
246
+ it("should provide serviceDeps to base services", () => {
247
+ interface AuthService {
248
+ getCurrentUser: () => { id: string; name: string };
249
+ }
250
+
251
+ const authService: AuthService = {
252
+ getCurrentUser: () => ({ id: "123", name: "John" }),
253
+ };
254
+
255
+ const definition = defineFragment("test-fragment")
256
+ .usesService<"authService", AuthService>("authService")
257
+ .providesBaseService(({ serviceDeps }) => ({
258
+ getUserName: () => serviceDeps.authService.getCurrentUser().name,
259
+ }))
260
+ .build();
261
+
262
+ const fragment = instantiate(definition)
263
+ .withOptions({})
264
+ .withServices({ authService })
265
+ .build();
266
+
267
+ expect(fragment.services.getUserName()).toBe("John");
268
+ });
269
+
270
+ it("should provide serviceDeps to named services", () => {
271
+ interface AuthService {
272
+ getCurrentUser: () => { id: string };
273
+ }
274
+
275
+ const authService: AuthService = {
276
+ getCurrentUser: () => ({ id: "123" }),
277
+ };
278
+
279
+ const definition = defineFragment("test-fragment")
280
+ .usesService<"authService", AuthService>("authService")
281
+ .providesService("userService", ({ serviceDeps }) => ({
282
+ getUserId: () => serviceDeps.authService.getCurrentUser().id,
283
+ }))
284
+ .build();
285
+
286
+ const fragment = instantiate(definition)
287
+ .withOptions({})
288
+ .withServices({ authService })
289
+ .build();
290
+
291
+ expect(fragment.services.userService.getUserId()).toBe("123");
292
+ });
293
+
294
+ it("should combine uses, provides, and base services", () => {
295
+ interface AuthService {
296
+ getCurrentUser: () => { id: string };
297
+ }
298
+
299
+ const authService: AuthService = {
300
+ getCurrentUser: () => ({ id: "123" }),
301
+ };
302
+
303
+ const definition = defineFragment("test-fragment")
304
+ .usesService<"authService", AuthService>("authService")
305
+ .providesService("userService", ({ serviceDeps }) => ({
306
+ getUserId: () => serviceDeps.authService.getCurrentUser().id,
307
+ }))
308
+ .providesBaseService(({ serviceDeps }) => ({
309
+ getNextUserId: () => serviceDeps.authService.getCurrentUser().id + 1,
310
+ }))
311
+ .build();
312
+
313
+ const fragment = instantiate(definition)
314
+ .withOptions({})
315
+ .withServices({ authService })
316
+ .build();
317
+
318
+ const services = fragment.services;
319
+ expectTypeOf(services).toMatchObjectType<{
320
+ getNextUserId: () => string;
321
+ userService: { getUserId: () => string };
322
+ }>();
323
+ });
324
+ });
325
+
326
+ describe("handler execution", () => {
327
+ it("should execute route handlers via handler method", async () => {
328
+ const definition = defineFragment("test-fragment").build();
329
+
330
+ const route = defineRoute({
331
+ method: "GET",
332
+ path: "/hello",
333
+ handler: async (_input, { json }) => {
334
+ return json({ message: "Hello World" });
335
+ },
336
+ });
337
+
338
+ const fragment = instantiate(definition)
339
+ .withRoutes([route])
340
+ .withOptions({ mountRoute: "/api" })
341
+ .build();
342
+
343
+ const request = new Request("http://localhost/api/hello");
344
+ const response = await fragment.handler(request);
345
+
346
+ expect(response.status).toBe(200);
347
+ const data = await response.json();
348
+ expect(data).toEqual({ message: "Hello World" });
349
+ });
350
+
351
+ it("should return 404 for unknown routes", async () => {
352
+ const definition = defineFragment("test-fragment").build();
353
+
354
+ const fragment = instantiate(definition)
355
+ .withRoutes([])
356
+ .withOptions({ mountRoute: "/api" })
357
+ .build();
358
+
359
+ const request = new Request("http://localhost/api/unknown");
360
+ const response = await fragment.handler(request);
361
+
362
+ expect(response.status).toBe(404);
363
+ const data = await response.json();
364
+ expect(data.code).toBe("ROUTE_NOT_FOUND");
365
+ });
366
+
367
+ it("should return 404 for wrong mount route", async () => {
368
+ const definition = defineFragment("test-fragment").build();
369
+
370
+ const route = defineRoute({
371
+ method: "GET",
372
+ path: "/hello",
373
+ handler: async (_input, { json }) => {
374
+ return json({ message: "Hello" });
375
+ },
376
+ });
377
+
378
+ const fragment = instantiate(definition)
379
+ .withRoutes([route])
380
+ .withOptions({ mountRoute: "/api" })
381
+ .build();
382
+
383
+ const request = new Request("http://localhost/wrong/hello");
384
+ const response = await fragment.handler(request);
385
+
386
+ expect(response.status).toBe(404);
387
+ const data = await response.json();
388
+ expect(data.code).toBe("ROUTE_NOT_FOUND");
389
+ });
390
+
391
+ it("should handle route with path params", async () => {
392
+ const definition = defineFragment("test-fragment").build();
393
+
394
+ const route = defineRoute({
395
+ method: "GET",
396
+ path: "/users/:id",
397
+ handler: async (input, { json }) => {
398
+ return json({ userId: input.pathParams.id });
399
+ },
400
+ });
401
+
402
+ const fragment = instantiate(definition)
403
+ .withRoutes([route])
404
+ .withOptions({ mountRoute: "/api" })
405
+ .build();
406
+
407
+ const request = new Request("http://localhost/api/users/123");
408
+ const response = await fragment.handler(request);
409
+
410
+ expect(response.status).toBe(200);
411
+ const data = await response.json();
412
+ expect(data).toEqual({ userId: "123" });
413
+ });
414
+
415
+ it("should handle POST requests with body", async () => {
416
+ const definition = defineFragment("test-fragment").build();
417
+
418
+ const inputSchema = z.object({
419
+ test: z.string(),
420
+ });
421
+
422
+ const route = defineRoute({
423
+ method: "POST",
424
+ path: "/data",
425
+ inputSchema,
426
+ handler: async ({ input }, { json }) => {
427
+ const body = await input.valid();
428
+ return json({ received: body });
429
+ },
430
+ });
431
+
432
+ const fragment = instantiate(definition)
433
+ .withRoutes([route])
434
+ .withOptions({ mountRoute: "/api" })
435
+ .build();
436
+
437
+ const request = new Request("http://localhost/api/data", {
438
+ method: "POST",
439
+ headers: { "Content-Type": "application/json" },
440
+ body: JSON.stringify({ test: "data" }),
441
+ });
442
+
443
+ const response = await fragment.handler(request);
444
+
445
+ expect(response.status).toBe(200);
446
+ const data = await response.json();
447
+ expect(data).toEqual({ received: { test: "data" } });
448
+ });
449
+ });
450
+
451
+ describe("callRoute", () => {
452
+ it("should call route directly with callRoute", async () => {
453
+ const definition = defineFragment("test-fragment").build();
454
+
455
+ const route = defineRoute({
456
+ method: "GET",
457
+ path: "/hello",
458
+ handler: async (_input, { json }) => {
459
+ return json({ message: "Hello from callRoute" });
460
+ },
461
+ });
462
+
463
+ const fragment = instantiate(definition).withRoutes([route]).withOptions({}).build();
464
+
465
+ const response = await fragment.callRoute("GET", "/hello");
466
+
467
+ expect(response.type).toBe("json");
468
+ if (response.type === "json") {
469
+ expect(response.data).toEqual({ message: "Hello from callRoute" });
470
+ }
471
+ });
472
+
473
+ it("should pass path params to callRoute", async () => {
474
+ const definition = defineFragment("test-fragment").build();
475
+
476
+ const route = defineRoute({
477
+ method: "GET",
478
+ path: "/users/:id",
479
+ handler: async (input, { json }) => {
480
+ return json({ userId: input.pathParams.id });
481
+ },
482
+ });
483
+
484
+ const fragment = instantiate(definition).withRoutes([route]).withOptions({}).build();
485
+
486
+ const response = await fragment.callRoute("GET", "/users/:id", {
487
+ pathParams: { id: "456" },
488
+ });
489
+
490
+ expect(response.type).toBe("json");
491
+ if (response.type === "json") {
492
+ expect(response.data).toEqual({ userId: "456" });
493
+ }
494
+ });
495
+
496
+ it("should pass body to callRoute", async () => {
497
+ const definition = defineFragment("test-fragment").build();
498
+
499
+ const inputSchema = z.object({
500
+ test: z.string(),
501
+ });
502
+
503
+ const route = defineRoute({
504
+ method: "POST",
505
+ path: "/data",
506
+ inputSchema,
507
+ handler: async ({ input }, { json }) => {
508
+ const body = await input.valid();
509
+ return json({ received: body });
510
+ },
511
+ });
512
+
513
+ const fragment = instantiate(definition).withRoutes([route]).withOptions({}).build();
514
+
515
+ const response = await fragment.callRoute("POST", "/data", {
516
+ body: { test: "data" },
517
+ });
518
+
519
+ expect(response.type).toBe("json");
520
+ if (response.type === "json") {
521
+ expect(response.data).toEqual({ received: { test: "data" } });
522
+ }
523
+ });
524
+
525
+ it("should return error for unknown route in callRoute", async () => {
526
+ const definition = defineFragment("test-fragment").build();
527
+
528
+ const route = defineRoute({
529
+ method: "GET",
530
+ path: "/exists",
531
+ handler: async (_input, { json }) => {
532
+ return json({ message: "exists" });
533
+ },
534
+ });
535
+
536
+ const fragment = instantiate(definition).withRoutes([route]).withOptions({}).build();
537
+
538
+ // @ts-expect-error - /unknown is not a valid route
539
+ const response = await fragment.callRouteRaw("GET", "/unknown");
540
+
541
+ expect(response.status).toBe(404);
542
+ const data = await response.json();
543
+ expect(data.code).toBe("ROUTE_NOT_FOUND");
544
+ });
545
+ });
546
+
547
+ describe("callRouteRaw", () => {
548
+ it("should call route and return raw Response", async () => {
549
+ const definition = defineFragment("test-fragment").build();
550
+
551
+ const route = defineRoute({
552
+ method: "GET",
553
+ path: "/hello",
554
+ handler: async (_input, { json }) => {
555
+ return json({ message: "Raw response" });
556
+ },
557
+ });
558
+
559
+ const fragment = instantiate(definition).withRoutes([route]).withOptions({}).build();
560
+
561
+ const response = await fragment.callRouteRaw("GET", "/hello");
562
+
563
+ expect(response).toBeInstanceOf(Response);
564
+ expect(response.status).toBe(200);
565
+ const data = await response.json();
566
+ expect(data).toEqual({ message: "Raw response" });
567
+ });
568
+ });
569
+
570
+ describe("middleware", () => {
571
+ it("should execute middleware before handler", async () => {
572
+ const definition = defineFragment("test-fragment").build();
573
+
574
+ const route = defineRoute({
575
+ method: "GET",
576
+ path: "/hello",
577
+ handler: async (_input, { json }) => {
578
+ return json({ message: "Hello" });
579
+ },
580
+ });
581
+
582
+ const fragment = instantiate(definition)
583
+ .withRoutes([route])
584
+ .withOptions({ mountRoute: "/api" })
585
+ .build();
586
+
587
+ const middlewareSpy = vi.fn();
588
+
589
+ fragment.withMiddleware(async (input, _output) => {
590
+ middlewareSpy(input.path);
591
+ // Don't return anything to allow request to continue
592
+ return undefined;
593
+ });
594
+
595
+ const request = new Request("http://localhost/api/hello");
596
+ await fragment.handler(request);
597
+
598
+ expect(middlewareSpy).toHaveBeenCalledWith("/hello");
599
+ });
600
+
601
+ it("should short-circuit with middleware response", async () => {
602
+ const definition = defineFragment("test-fragment").build();
603
+
604
+ const route = defineRoute({
605
+ method: "GET",
606
+ path: "/hello",
607
+ handler: async (_input, { json }) => {
608
+ return json({ message: "From handler" });
609
+ },
610
+ });
611
+
612
+ const fragment = instantiate(definition)
613
+ .withRoutes([route])
614
+ .withOptions({ mountRoute: "/api" })
615
+ .build();
616
+
617
+ fragment.withMiddleware(async () => {
618
+ return Response.json({ message: "From middleware" });
619
+ });
620
+
621
+ const request = new Request("http://localhost/api/hello");
622
+ const response = await fragment.handler(request);
623
+
624
+ const data = await response.json();
625
+ expect(data).toEqual({ message: "From middleware" });
626
+ });
627
+
628
+ it("should throw error when setting middleware twice", () => {
629
+ const definition = defineFragment("test-fragment").build();
630
+
631
+ const fragment = instantiate(definition).withOptions({}).build();
632
+
633
+ fragment.withMiddleware(async () => {
634
+ return undefined;
635
+ });
636
+
637
+ expect(() => {
638
+ fragment.withMiddleware(async () => {
639
+ return undefined;
640
+ });
641
+ }).toThrow("Middleware already set");
642
+ });
643
+ });
644
+
645
+ describe("handlersFor", () => {
646
+ it("should generate astro handlers", () => {
647
+ const definition = defineFragment("test-fragment").build();
648
+
649
+ const fragment = instantiate(definition).withRoutes([]).withOptions({}).build();
650
+
651
+ const handlers = fragment.handlersFor("astro");
652
+
653
+ expect(handlers).toHaveProperty("ALL");
654
+ expect(typeof handlers.ALL).toBe("function");
655
+ });
656
+
657
+ it("should generate react-router handlers", () => {
658
+ const definition = defineFragment("test-fragment").build();
659
+
660
+ const fragment = instantiate(definition).withRoutes([]).withOptions({}).build();
661
+
662
+ const handlers = fragment.handlersFor("react-router");
663
+
664
+ expect(handlers).toHaveProperty("loader");
665
+ expect(handlers).toHaveProperty("action");
666
+ expect(typeof handlers.loader).toBe("function");
667
+ expect(typeof handlers.action).toBe("function");
668
+ });
669
+
670
+ it("should generate next-js handlers", () => {
671
+ const definition = defineFragment("test-fragment").build();
672
+
673
+ const fragment = instantiate(definition).withRoutes([]).withOptions({}).build();
674
+
675
+ const handlers = fragment.handlersFor("next-js");
676
+
677
+ expect(handlers).toHaveProperty("GET");
678
+ expect(handlers).toHaveProperty("POST");
679
+ expect(handlers).toHaveProperty("PUT");
680
+ expect(handlers).toHaveProperty("DELETE");
681
+ expect(handlers).toHaveProperty("PATCH");
682
+ });
683
+
684
+ it("should generate svelte-kit handlers", () => {
685
+ const definition = defineFragment("test-fragment").build();
686
+
687
+ const fragment = instantiate(definition).withRoutes([]).withOptions({}).build();
688
+
689
+ const handlers = fragment.handlersFor("svelte-kit");
690
+
691
+ expect(handlers).toHaveProperty("GET");
692
+ expect(handlers).toHaveProperty("POST");
693
+ });
694
+
695
+ it("should generate solid-start handlers", () => {
696
+ const definition = defineFragment("test-fragment").build();
697
+
698
+ const fragment = instantiate(definition).withRoutes([]).withOptions({}).build();
699
+
700
+ const handlers = fragment.handlersFor("solid-start");
701
+
702
+ expect(handlers).toHaveProperty("GET");
703
+ expect(handlers).toHaveProperty("POST");
704
+ });
705
+
706
+ it("should generate tanstack-start handlers", () => {
707
+ const definition = defineFragment("test-fragment").build();
708
+
709
+ const fragment = instantiate(definition).withRoutes([]).withOptions({}).build();
710
+
711
+ const handlers = fragment.handlersFor("tanstack-start");
712
+
713
+ expect(handlers).toHaveProperty("GET");
714
+ expect(handlers).toHaveProperty("POST");
715
+ });
716
+ });
717
+
718
+ describe("request context", () => {
719
+ it("should use custom thisContext from createThisContext", async () => {
720
+ interface CustomThisContext extends RequestThisContext {
721
+ customMethod: () => string;
722
+ }
723
+
724
+ const definition = defineFragment("test-fragment").build();
725
+
726
+ // Manually add createThisContext to definition
727
+ const definitionWithContext = {
728
+ ...definition,
729
+ createThisContext: () => {
730
+ const ctx = {
731
+ customMethod: () => "custom value",
732
+ };
733
+ return { serviceContext: ctx, handlerContext: ctx };
734
+ },
735
+ } satisfies AnyFragmentDefinition;
736
+
737
+ const route = defineRoute({
738
+ method: "GET",
739
+ path: "/test",
740
+ handler: async function (this: CustomThisContext, _input, { json }) {
741
+ return json({ value: this.customMethod() });
742
+ },
743
+ });
744
+
745
+ const fragment = instantiate(definitionWithContext)
746
+ .withRoutes([route])
747
+ .withOptions({ mountRoute: "/api" })
748
+ .build();
749
+
750
+ const request = new Request("http://localhost/api/test");
751
+ const response = await fragment.handler(request);
752
+
753
+ const data = await response.json();
754
+ expect(data).toEqual({ value: "custom value" });
755
+ });
756
+
757
+ it("should create fresh context per request", async () => {
758
+ const contextCreationSpy = vi.fn();
759
+
760
+ const definition = defineFragment("test-fragment")
761
+ .withRequestStorage(() => ({ counter: 0 }))
762
+ .withThisContext(({ storage }) => {
763
+ const ctx = {
764
+ get requestId() {
765
+ contextCreationSpy();
766
+ return { text: "default", counter: ++storage.getStore().counter };
767
+ },
768
+ };
769
+ return { serviceContext: ctx, handlerContext: ctx };
770
+ })
771
+ .build();
772
+
773
+ const routes = defineRoutes(definition).create(({ defineRoute }) => {
774
+ return [
775
+ defineRoute({
776
+ method: "GET",
777
+ path: "/test",
778
+ handler: async function (_input, { json }) {
779
+ return json({ requestId: this.requestId });
780
+ },
781
+ }),
782
+ ];
783
+ });
784
+
785
+ const fragment = instantiate(definition)
786
+ .withRoutes([routes])
787
+ .withOptions({ mountRoute: "/api" })
788
+ .build();
789
+
790
+ const request = new Request("http://localhost/api/test");
791
+ const response = await fragment.handler(request);
792
+ expect(contextCreationSpy).toHaveBeenCalled();
793
+ const data = await response.json();
794
+ expect(data).toEqual({ requestId: { text: "default", counter: 1 } });
795
+
796
+ const response2 = await fragment.handler(request);
797
+ const data2 = await response2.json();
798
+ expect(data2).toEqual({ requestId: { text: "default", counter: 1 } });
799
+
800
+ expect(contextCreationSpy).toHaveBeenCalledTimes(2);
801
+ });
802
+ });
803
+
804
+ describe("defineService with custom this context", () => {
805
+ it("withThisContext types", () => {
806
+ const definition = defineFragment("test-fragment")
807
+ .withDependencies(() => ({ apiKey: "key", number: 5 }))
808
+ .withThisContext(() => {
809
+ const ctx = {
810
+ x: 3,
811
+ y: "hello",
812
+ get someNumber() {
813
+ return 5;
814
+ },
815
+ };
816
+ return { serviceContext: ctx, handlerContext: ctx };
817
+ })
818
+ .providesBaseService(({ deps, defineService }) =>
819
+ defineService({
820
+ method1: function () {
821
+ expectTypeOf(this).toMatchObjectType<{
822
+ x: number;
823
+ y: string;
824
+ readonly someNumber: number;
825
+ }>();
826
+
827
+ return `${deps.apiKey}-${this.someNumber}`;
828
+ },
829
+ }),
830
+ )
831
+ .build();
832
+
833
+ // Type check: definition should have correct context factory
834
+ expect(definition.createThisContext).toBeDefined();
835
+ });
836
+
837
+ it("withRequestStorage allows mutating stored data", async () => {
838
+ const definition = defineFragment("test-fragment")
839
+ .withDependencies(() => ({ startingCounter: 5 }))
840
+ .withRequestStorage(({ deps }) => ({ counter: deps.startingCounter }))
841
+ .withThisContext(({ storage }) => {
842
+ const ctx = {
843
+ // Getter to access current counter value from storage
844
+ get counter() {
845
+ return storage.getStore().counter;
846
+ },
847
+ // Method to increment the counter in storage
848
+ incrementCounter() {
849
+ storage.getStore().counter++;
850
+ },
851
+ };
852
+ return { serviceContext: ctx, handlerContext: ctx };
853
+ })
854
+ .providesBaseService(({ defineService }) =>
855
+ defineService({
856
+ // Service that reads the counter
857
+ getCounter: function () {
858
+ return this.counter;
859
+ },
860
+ // Service that increments the stored counter
861
+ incrementCounter: function () {
862
+ this.incrementCounter();
863
+ return this.counter;
864
+ },
865
+ }),
866
+ )
867
+ .build();
868
+
869
+ const routes = defineRoutes(definition).create(({ services, defineRoute }) => [
870
+ defineRoute({
871
+ method: "GET",
872
+ path: "/test-counter",
873
+ handler: async function (_input, { json }) {
874
+ // Type check: this should have counter property and incrementCounter method
875
+ expectTypeOf(this).toMatchObjectType<{
876
+ readonly counter: number;
877
+ incrementCounter: () => void;
878
+ }>();
879
+
880
+ // Read initial counter (starts at 0)
881
+ const initial = services.getCounter();
882
+
883
+ // Increment through service - this mutates the storage
884
+ const afterFirst = services.incrementCounter();
885
+ const afterSecond = services.incrementCounter();
886
+
887
+ // Direct access via this also shows the mutated value
888
+ const thisCounter = this.counter;
889
+
890
+ // Increment via this context directly
891
+ this.incrementCounter();
892
+ const afterDirect = this.counter;
893
+
894
+ return json({
895
+ initial,
896
+ afterFirst,
897
+ afterSecond,
898
+ thisCounter,
899
+ afterDirect,
900
+ });
901
+ },
902
+ }),
903
+ ]);
904
+
905
+ const fragment = instantiate(definition)
906
+ .withRoutes([routes])
907
+ .withOptions({ mountRoute: "/api" })
908
+ .build();
909
+
910
+ const response = await fragment.handler(new Request("http://localhost/api/test-counter"));
911
+ const data = await response.json();
912
+
913
+ // The counter increments are persisted in storage throughout the request
914
+ expect(data).toEqual({
915
+ initial: 5,
916
+ afterFirst: 6,
917
+ afterSecond: 7,
918
+ thisCounter: 7,
919
+ afterDirect: 8,
920
+ });
921
+
922
+ expect(response.status).toBe(200);
923
+ });
924
+
925
+ it("should allow services to use custom this context at runtime", async () => {
926
+ const definition = defineFragment("test-fragment")
927
+ .withDependencies(() => ({ apiKey: "key", number: 5 }))
928
+ .withThisContext(({ deps }) => {
929
+ const ctx = {
930
+ get someNumber() {
931
+ return deps.number;
932
+ },
933
+ };
934
+ return { serviceContext: ctx, handlerContext: ctx };
935
+ })
936
+ .providesBaseService(({ deps, defineService }) =>
937
+ defineService({
938
+ method1: function () {
939
+ expectTypeOf(this).toMatchObjectType<{
940
+ readonly someNumber: number;
941
+ }>();
942
+
943
+ return `${deps.apiKey}-${this.someNumber}`;
944
+ },
945
+ }),
946
+ )
947
+ .build();
948
+
949
+ const fragment = instantiate(definition).withOptions({}).build();
950
+
951
+ expect(fragment.services.method1).toBeDefined();
952
+
953
+ // Calling the service method outside of a request context will not work properly
954
+ const result1 = fragment.services.method1();
955
+ expect(result1).toBe("key-5");
956
+
957
+ // Now let's use defineRoutes to access services properly in routes
958
+ const routesFactory = defineRoutes(definition).create(({ services, defineRoute }) => {
959
+ return [
960
+ defineRoute({
961
+ method: "GET",
962
+ path: "/test-service",
963
+ handler: async function (_input, { json }) {
964
+ expectTypeOf(this).toMatchObjectType<{
965
+ readonly someNumber: number;
966
+ }>();
967
+
968
+ expect(this).toMatchObject({
969
+ someNumber: 5,
970
+ });
971
+
972
+ // Services are available from the route factory context!
973
+ const serviceResult = services.method1();
974
+ return json({ result: serviceResult + "-" + (this.someNumber + 1) });
975
+ },
976
+ }),
977
+ ];
978
+ });
979
+
980
+ const fragmentWithRoute = instantiate(definition)
981
+ .withRoutes([routesFactory])
982
+ .withOptions({ mountRoute: "/api" })
983
+ .build();
984
+
985
+ const response = await fragmentWithRoute.handler(
986
+ new Request("http://localhost/api/test-service"),
987
+ );
988
+
989
+ expect(response.status).toBe(200);
990
+ const data = await response.json();
991
+ expect(data).toEqual({ result: "key-5-6" });
992
+ });
993
+
994
+ it("should be able to call services with bound context using inContext", async () => {
995
+ const definition = defineFragment("test-fragment")
996
+ .withDependencies(() => ({ apiKey: "key" }))
997
+ .withThisContext(() => {
998
+ const ctx = {
999
+ myThisNumber: 0,
1000
+ myThisString: "hello",
1001
+ };
1002
+ return { serviceContext: ctx, handlerContext: ctx };
1003
+ })
1004
+ .providesBaseService(({ deps, defineService }) =>
1005
+ defineService({
1006
+ method1: function () {
1007
+ expectTypeOf(this).toMatchObjectType<{
1008
+ myThisNumber: number;
1009
+ myThisString: string;
1010
+ }>();
1011
+
1012
+ console.log("this", this);
1013
+
1014
+ this.myThisNumber++;
1015
+ return `${deps.apiKey}-${++this.myThisNumber}`;
1016
+ },
1017
+ }),
1018
+ )
1019
+ .build();
1020
+
1021
+ const fragment = instantiate(definition).withOptions({}).build();
1022
+
1023
+ expect(fragment.services.method1).toBeDefined();
1024
+ const result2 = fragment.inContext(() => fragment.services.method1());
1025
+ expect(result2).toBe("key-2");
1026
+ });
1027
+ });
1028
+
1029
+ describe("defineRoutes with services", () => {
1030
+ it("should provide base services in route factory context", () => {
1031
+ const definition = defineFragment("test-fragment")
1032
+ .providesBaseService(() => ({
1033
+ greet: (name: string) => `Hello, ${name}!`,
1034
+ }))
1035
+ .build();
1036
+
1037
+ const routes = defineRoutes(definition).create(({ services, defineRoute }) => {
1038
+ // Verify base service is accessible
1039
+ expectTypeOf(services).toMatchObjectType<{
1040
+ greet: (name: string) => string;
1041
+ }>();
1042
+
1043
+ return [
1044
+ defineRoute({
1045
+ method: "GET",
1046
+ path: "/greet",
1047
+ handler: async (_input, { json }) => {
1048
+ const message = services.greet("World");
1049
+ return json({ message });
1050
+ },
1051
+ }),
1052
+ ];
1053
+ });
1054
+
1055
+ expect(routes).toBeDefined();
1056
+ expect(typeof routes).toBe("function");
1057
+ });
1058
+
1059
+ it("should provide named services in route factory context", () => {
1060
+ const definition = defineFragment("test-fragment")
1061
+ .providesService("mathService", () => ({
1062
+ add: (a: number, b: number) => a + b,
1063
+ multiply: (a: number, b: number) => a * b,
1064
+ }))
1065
+ .build();
1066
+
1067
+ const routes = defineRoutes(definition).create(({ services, defineRoute }) => {
1068
+ // Verify named service is accessible
1069
+ expectTypeOf(services).toMatchObjectType<{
1070
+ mathService: {
1071
+ add: (a: number, b: number) => number;
1072
+ multiply: (a: number, b: number) => number;
1073
+ };
1074
+ }>();
1075
+
1076
+ return [
1077
+ defineRoute({
1078
+ method: "GET",
1079
+ path: "/math",
1080
+ handler: async (_input, { json }) => {
1081
+ const sum = services.mathService.add(2, 3);
1082
+ const product = services.mathService.multiply(4, 5);
1083
+ return json({ sum, product });
1084
+ },
1085
+ }),
1086
+ ];
1087
+ });
1088
+
1089
+ expect(routes).toBeDefined();
1090
+ });
1091
+
1092
+ it("should provide both base and named services in route factory context", () => {
1093
+ const definition = defineFragment("test-fragment")
1094
+ .providesBaseService(() => ({
1095
+ baseMethod: () => "base",
1096
+ }))
1097
+ .providesService("namedService", () => ({
1098
+ namedMethod: () => "named",
1099
+ }))
1100
+ .build();
1101
+
1102
+ const routes = defineRoutes(definition).create(({ services, defineRoute }) => {
1103
+ // Verify both base and named services are accessible
1104
+ expectTypeOf(services.baseMethod).toBeFunction();
1105
+ expectTypeOf(services.namedService).toBeObject();
1106
+ expectTypeOf(services.namedService.namedMethod).toBeFunction();
1107
+
1108
+ return [
1109
+ defineRoute({
1110
+ method: "GET",
1111
+ path: "/combined",
1112
+ handler: async (_input, { json }) => {
1113
+ const base = services.baseMethod();
1114
+ const named = services.namedService.namedMethod();
1115
+ return json({ base, named });
1116
+ },
1117
+ }),
1118
+ ];
1119
+ });
1120
+
1121
+ expect(routes).toBeDefined();
1122
+ });
1123
+
1124
+ it("should have services in route context match fragment.services at runtime", async () => {
1125
+ const definition = defineFragment("test-fragment")
1126
+ .providesBaseService(() => ({
1127
+ baseMethod: () => "base-value",
1128
+ }))
1129
+ .providesService("namedService", () => ({
1130
+ namedMethod: () => "named-value",
1131
+ }))
1132
+ .build();
1133
+
1134
+ const routes = defineRoutes(definition).create(({ services, defineRoute }) => [
1135
+ defineRoute({
1136
+ method: "GET",
1137
+ path: "/test",
1138
+ handler: async (_input, { json }) => {
1139
+ return json({
1140
+ base: services.baseMethod(),
1141
+ named: services.namedService.namedMethod(),
1142
+ });
1143
+ },
1144
+ }),
1145
+ ]);
1146
+
1147
+ const fragment = instantiate(definition)
1148
+ .withRoutes([routes])
1149
+ .withOptions({ mountRoute: "/api" })
1150
+ .build();
1151
+
1152
+ // Verify fragment.services has the same structure
1153
+ expect(fragment.services.baseMethod()).toBe("base-value");
1154
+ expect(fragment.services.namedService.namedMethod()).toBe("named-value");
1155
+
1156
+ // Verify route can access services with the same structure
1157
+ const response = await fragment.handler(new Request("http://localhost/api/test"));
1158
+ expect(response.status).toBe(200);
1159
+ const data = await response.json();
1160
+ expect(data).toEqual({
1161
+ base: "base-value",
1162
+ named: "named-value",
1163
+ });
1164
+ });
1165
+
1166
+ it("should allow multiple named services in route factory context", () => {
1167
+ const definition = defineFragment("test-fragment")
1168
+ .providesService("service1", () => ({
1169
+ method1: () => "value1",
1170
+ }))
1171
+ .providesService("service2", () => ({
1172
+ method2: () => "value2",
1173
+ }))
1174
+ .providesService("service3", () => ({
1175
+ method3: () => "value3",
1176
+ }))
1177
+ .build();
1178
+
1179
+ const routes = defineRoutes(definition).create(({ services, defineRoute }) => {
1180
+ // Verify all named services are accessible
1181
+ expectTypeOf(services.service1).toBeObject();
1182
+ expectTypeOf(services.service1.method1).toBeFunction();
1183
+ expectTypeOf(services.service2).toBeObject();
1184
+ expectTypeOf(services.service2.method2).toBeFunction();
1185
+ expectTypeOf(services.service3).toBeObject();
1186
+ expectTypeOf(services.service3.method3).toBeFunction();
1187
+
1188
+ return [
1189
+ defineRoute({
1190
+ method: "GET",
1191
+ path: "/multi",
1192
+ handler: async (_input, { json }) => {
1193
+ return json({
1194
+ v1: services.service1.method1(),
1195
+ v2: services.service2.method2(),
1196
+ v3: services.service3.method3(),
1197
+ });
1198
+ },
1199
+ }),
1200
+ ];
1201
+ });
1202
+
1203
+ expect(routes).toBeDefined();
1204
+ });
1205
+
1206
+ it("should provide services with dependencies in route factory context", () => {
1207
+ interface Config {
1208
+ prefix: string;
1209
+ }
1210
+
1211
+ const definition = defineFragment<Config>("test-fragment")
1212
+ .withDependencies(({ config }) => ({
1213
+ prefix: config.prefix,
1214
+ }))
1215
+ .providesBaseService(({ deps }) => ({
1216
+ greet: (name: string) => `${deps.prefix} ${name}`,
1217
+ }))
1218
+ .providesService("formatter", ({ deps }) => ({
1219
+ format: (text: string) => `[${deps.prefix}] ${text}`,
1220
+ }))
1221
+ .build();
1222
+
1223
+ const routes = defineRoutes(definition).create(({ services, defineRoute }) => {
1224
+ // Verify services are accessible
1225
+ expectTypeOf(services).toMatchObjectType<{
1226
+ greet: (name: string) => string;
1227
+ formatter: {
1228
+ format: (text: string) => string;
1229
+ };
1230
+ }>();
1231
+
1232
+ return [
1233
+ defineRoute({
1234
+ method: "GET",
1235
+ path: "/format",
1236
+ handler: async (_input, { json }) => {
1237
+ return json({
1238
+ greeting: services.greet("World"),
1239
+ formatted: services.formatter.format("Hello"),
1240
+ });
1241
+ },
1242
+ }),
1243
+ ];
1244
+ });
1245
+
1246
+ expect(routes).toBeDefined();
1247
+ });
1248
+ });
1249
+
1250
+ describe("linked fragments", () => {
1251
+ it("should instantiate linked fragments with the parent fragment", () => {
1252
+ interface Config {
1253
+ apiKey: string;
1254
+ }
1255
+
1256
+ // Create a linked fragment definition
1257
+ const linkedFragmentDef = defineFragment<Config>("linked-fragment")
1258
+ .providesService("linkedService", () => ({
1259
+ getValue: () => "from-linked",
1260
+ }))
1261
+ .build();
1262
+
1263
+ // Create main fragment with linked fragment
1264
+ const definition = defineFragment<Config>("main-fragment")
1265
+ .withLinkedFragment("internal", ({ config, options }) => {
1266
+ return instantiate(linkedFragmentDef).withConfig(config).withOptions(options).build();
1267
+ })
1268
+ .build();
1269
+
1270
+ const fragment = instantiate(definition)
1271
+ .withConfig({ apiKey: "test-key" })
1272
+ .withOptions({ mountRoute: "/api" })
1273
+ .build();
1274
+
1275
+ // Verify linked fragment exists
1276
+ expect(Object.keys(fragment.$internal.linkedFragments).length).toBe(1);
1277
+ expect("internal" in fragment.$internal.linkedFragments).toBe(true);
1278
+
1279
+ const linkedFragment = fragment.$internal.linkedFragments.internal;
1280
+ expect(linkedFragment).toBeDefined();
1281
+ expect(linkedFragment?.name).toBe("linked-fragment");
1282
+ });
1283
+
1284
+ it("should pass config and options to linked fragments", () => {
1285
+ interface Config {
1286
+ value: string;
1287
+ }
1288
+
1289
+ interface Options extends FragnoPublicConfig {
1290
+ customOption: string;
1291
+ }
1292
+
1293
+ const linkedFragmentDef = defineFragment<Config, Options>("linked-fragment")
1294
+ .withDependencies(({ config, options }) => ({
1295
+ combined: `${config.value}-${options.customOption}`,
1296
+ }))
1297
+ .build();
1298
+
1299
+ const definition = defineFragment<Config, Options>("main-fragment")
1300
+ .withLinkedFragment("internal", ({ config, options }) => {
1301
+ return instantiate(linkedFragmentDef).withConfig(config).withOptions(options).build();
1302
+ })
1303
+ .build();
1304
+
1305
+ const fragment = instantiate(definition)
1306
+ .withConfig({ value: "config" })
1307
+ .withOptions({ customOption: "option", mountRoute: "/api" } as Options)
1308
+ .build();
1309
+
1310
+ const linkedFragment = fragment.$internal.linkedFragments.internal;
1311
+ expect(linkedFragment?.$internal.deps).toEqual({
1312
+ combined: "config-option",
1313
+ });
1314
+ });
1315
+
1316
+ it("should allow linked fragments to provide services", () => {
1317
+ const linkedFragmentDef = defineFragment("linked-fragment")
1318
+ .providesService("settingsService", () => ({
1319
+ get: (key: string) => `value-for-${key}`,
1320
+ set: (key: string, value: string) => {
1321
+ console.log(`Setting ${key} = ${value}`);
1322
+ },
1323
+ }))
1324
+ .build();
1325
+
1326
+ const definition = defineFragment("main-fragment")
1327
+ .withLinkedFragment("internal", ({ config, options }) => {
1328
+ return instantiate(linkedFragmentDef).withConfig(config).withOptions(options).build();
1329
+ })
1330
+ .build();
1331
+
1332
+ const fragment = instantiate(definition).withOptions({}).build();
1333
+
1334
+ const linkedFragment = fragment.$internal.linkedFragments.internal;
1335
+ expect(linkedFragment?.services.settingsService).toBeDefined();
1336
+ expect(linkedFragment?.services.settingsService.get("test")).toBe("value-for-test");
1337
+ });
1338
+
1339
+ it("should support multiple linked fragments", () => {
1340
+ const linkedFragmentDef1 = defineFragment("linked-fragment-1")
1341
+ .providesService("service1", () => ({ method: () => "service1" }))
1342
+ .build();
1343
+
1344
+ const linkedFragmentDef2 = defineFragment("linked-fragment-2")
1345
+ .providesService("service2", () => ({ method: () => "service2" }))
1346
+ .build();
1347
+
1348
+ const definition = defineFragment("main-fragment")
1349
+ .withLinkedFragment("internal1", ({ config, options }) => {
1350
+ return instantiate(linkedFragmentDef1).withConfig(config).withOptions(options).build();
1351
+ })
1352
+ .withLinkedFragment("internal2", ({ config, options }) => {
1353
+ return instantiate(linkedFragmentDef2).withConfig(config).withOptions(options).build();
1354
+ })
1355
+ .build();
1356
+
1357
+ const fragment = instantiate(definition).withOptions({}).build();
1358
+
1359
+ expect(Object.keys(fragment.$internal.linkedFragments).length).toBe(2);
1360
+ expect("internal1" in fragment.$internal.linkedFragments).toBe(true);
1361
+ expect("internal2" in fragment.$internal.linkedFragments).toBe(true);
1362
+
1363
+ const linked1 = fragment.$internal.linkedFragments.internal1;
1364
+ const linked2 = fragment.$internal.linkedFragments.internal2;
1365
+
1366
+ expect(linked1?.services.service1.method()).toBe("service1");
1367
+ expect(linked2?.services.service2.method()).toBe("service2");
1368
+ });
1369
+
1370
+ it("should pass service dependencies to linked fragments", () => {
1371
+ interface ExternalService {
1372
+ getValue: () => string;
1373
+ }
1374
+
1375
+ const externalService: ExternalService = {
1376
+ getValue: () => "external-value",
1377
+ };
1378
+
1379
+ const linkedFragmentDef = defineFragment("linked-fragment")
1380
+ .usesService<"externalService", ExternalService>("externalService")
1381
+ .providesService("linkedService", ({ serviceDeps }) => ({
1382
+ getFromExternal: () => serviceDeps.externalService.getValue(),
1383
+ }))
1384
+ .build();
1385
+
1386
+ const definition = defineFragment("main-fragment")
1387
+ .usesService<"externalService", ExternalService>("externalService")
1388
+ .withLinkedFragment("internal", ({ config, options, serviceDependencies }) => {
1389
+ return instantiate(linkedFragmentDef)
1390
+ .withConfig(config)
1391
+ .withOptions(options)
1392
+ .withServices(serviceDependencies!)
1393
+ .build();
1394
+ })
1395
+ .build();
1396
+
1397
+ const fragment = instantiate(definition)
1398
+ .withOptions({})
1399
+ .withServices({ externalService })
1400
+ .build();
1401
+
1402
+ const linkedFragment = fragment.$internal.linkedFragments.internal;
1403
+ expect(linkedFragment?.services.linkedService.getFromExternal()).toBe("external-value");
1404
+ });
1405
+
1406
+ it("should expose linked fragment services as private services", () => {
1407
+ const linkedFragmentDef = defineFragment("linked-fragment")
1408
+ .providesService("linkedService", () => ({
1409
+ getValue: () => "from-linked",
1410
+ }))
1411
+ .build();
1412
+
1413
+ const definition = defineFragment("main-fragment")
1414
+ .withLinkedFragment("internal", ({ config, options }) => {
1415
+ return instantiate(linkedFragmentDef).withConfig(config).withOptions(options).build();
1416
+ })
1417
+ .providesService("mainService", ({ privateServices }) => ({
1418
+ getLinkedValue: () => {
1419
+ return privateServices.linkedService.getValue();
1420
+ },
1421
+ }))
1422
+ .build();
1423
+
1424
+ const fragment = instantiate(definition).withOptions({}).build();
1425
+
1426
+ // The main service can access linked fragment services via privateServices
1427
+ expect(fragment.services.mainService.getLinkedValue()).toBe("from-linked");
1428
+
1429
+ // Linked fragment services are NOT directly exposed on the main fragment
1430
+ // @ts-expect-error - Linked fragment service should not be accessible
1431
+ expect(fragment.services.linkedService).toBeUndefined();
1432
+ });
1433
+ });
1434
+
1435
+ describe("error handling", () => {
1436
+ it("should handle errors in route handlers", async () => {
1437
+ const definition = defineFragment("test-fragment").build();
1438
+
1439
+ const route = defineRoute({
1440
+ method: "GET",
1441
+ path: "/error",
1442
+ handler: async () => {
1443
+ throw new Error("Test error");
1444
+ },
1445
+ });
1446
+
1447
+ const fragment = instantiate(definition)
1448
+ .withRoutes([route])
1449
+ .withOptions({ mountRoute: "/api" })
1450
+ .build();
1451
+
1452
+ const request = new Request("http://localhost/api/error");
1453
+ const response = await fragment.handler(request);
1454
+
1455
+ expect(response.status).toBe(500);
1456
+ const data = await response.json();
1457
+ expect(data.code).toBe("INTERNAL_SERVER_ERROR");
1458
+ });
1459
+
1460
+ it("should handle errors in middleware", async () => {
1461
+ const definition = defineFragment("test-fragment").build();
1462
+
1463
+ const route = defineRoute({
1464
+ method: "GET",
1465
+ path: "/test",
1466
+ handler: async (_input, { json }) => {
1467
+ return json({ message: "test" });
1468
+ },
1469
+ });
1470
+
1471
+ const fragment = instantiate(definition)
1472
+ .withRoutes([route])
1473
+ .withOptions({ mountRoute: "/api" })
1474
+ .build();
1475
+
1476
+ fragment.withMiddleware(async () => {
1477
+ throw new Error("Middleware error");
1478
+ });
1479
+
1480
+ const request = new Request("http://localhost/api/test");
1481
+ const response = await fragment.handler(request);
1482
+
1483
+ expect(response.status).toBe(500);
1484
+ const data = await response.json();
1485
+ expect(data.code).toBe("INTERNAL_SERVER_ERROR");
1486
+ });
1487
+ });
1488
+ });