@fragno-dev/core 0.1.11 → 0.2.2

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 (155) hide show
  1. package/.turbo/turbo-build.log +87 -69
  2. package/CHANGELOG.md +79 -0
  3. package/dist/api/api.d.ts +21 -2
  4. package/dist/api/api.d.ts.map +1 -1
  5. package/dist/api/api.js +2 -1
  6. package/dist/api/api.js.map +1 -1
  7. package/dist/api/bind-services.d.ts +0 -1
  8. package/dist/api/bind-services.d.ts.map +1 -1
  9. package/dist/api/bind-services.js.map +1 -1
  10. package/dist/api/error.d.ts.map +1 -1
  11. package/dist/api/error.js.map +1 -1
  12. package/dist/api/fragment-definition-builder.d.ts +32 -40
  13. package/dist/api/fragment-definition-builder.d.ts.map +1 -1
  14. package/dist/api/fragment-definition-builder.js +15 -21
  15. package/dist/api/fragment-definition-builder.js.map +1 -1
  16. package/dist/api/fragment-instantiator.d.ts +51 -30
  17. package/dist/api/fragment-instantiator.d.ts.map +1 -1
  18. package/dist/api/fragment-instantiator.js +201 -52
  19. package/dist/api/fragment-instantiator.js.map +1 -1
  20. package/dist/api/request-context-storage.d.ts +4 -0
  21. package/dist/api/request-context-storage.d.ts.map +1 -1
  22. package/dist/api/request-context-storage.js +6 -0
  23. package/dist/api/request-context-storage.js.map +1 -1
  24. package/dist/api/request-input-context.d.ts +57 -1
  25. package/dist/api/request-input-context.d.ts.map +1 -1
  26. package/dist/api/request-input-context.js +67 -0
  27. package/dist/api/request-input-context.js.map +1 -1
  28. package/dist/api/request-middleware.d.ts +2 -2
  29. package/dist/api/request-middleware.d.ts.map +1 -1
  30. package/dist/api/request-middleware.js.map +1 -1
  31. package/dist/api/request-output-context.d.ts +1 -1
  32. package/dist/api/request-output-context.d.ts.map +1 -1
  33. package/dist/api/request-output-context.js.map +1 -1
  34. package/dist/api/route-caller.d.ts +30 -0
  35. package/dist/api/route-caller.d.ts.map +1 -0
  36. package/dist/api/route-caller.js +63 -0
  37. package/dist/api/route-caller.js.map +1 -0
  38. package/dist/api/route-handler-input-options.d.ts.map +1 -1
  39. package/dist/api/route.d.ts +8 -8
  40. package/dist/api/route.d.ts.map +1 -1
  41. package/dist/api/route.js.map +1 -1
  42. package/dist/api/shared-types.d.ts.map +1 -1
  43. package/dist/client/client-error.d.ts.map +1 -1
  44. package/dist/client/client-error.js.map +1 -1
  45. package/dist/client/client.d.ts +90 -50
  46. package/dist/client/client.d.ts.map +1 -1
  47. package/dist/client/client.js +128 -16
  48. package/dist/client/client.js.map +1 -1
  49. package/dist/client/client.svelte.d.ts +6 -5
  50. package/dist/client/client.svelte.d.ts.map +1 -1
  51. package/dist/client/client.svelte.js +10 -2
  52. package/dist/client/client.svelte.js.map +1 -1
  53. package/dist/client/internal/ndjson-streaming.js.map +1 -1
  54. package/dist/client/react.d.ts +5 -4
  55. package/dist/client/react.d.ts.map +1 -1
  56. package/dist/client/react.js +104 -12
  57. package/dist/client/react.js.map +1 -1
  58. package/dist/client/solid.d.ts +7 -5
  59. package/dist/client/solid.d.ts.map +1 -1
  60. package/dist/client/solid.js +23 -9
  61. package/dist/client/solid.js.map +1 -1
  62. package/dist/client/vanilla.d.ts +16 -4
  63. package/dist/client/vanilla.d.ts.map +1 -1
  64. package/dist/client/vanilla.js +21 -1
  65. package/dist/client/vanilla.js.map +1 -1
  66. package/dist/client/vue.d.ts +10 -4
  67. package/dist/client/vue.d.ts.map +1 -1
  68. package/dist/client/vue.js +24 -1
  69. package/dist/client/vue.js.map +1 -1
  70. package/dist/id.d.ts +2 -0
  71. package/dist/id.js +3 -0
  72. package/dist/internal/cuid.d.ts +16 -0
  73. package/dist/internal/cuid.d.ts.map +1 -0
  74. package/dist/internal/cuid.js +82 -0
  75. package/dist/internal/cuid.js.map +1 -0
  76. package/dist/internal/trace-context.d.ts +23 -0
  77. package/dist/internal/trace-context.d.ts.map +1 -0
  78. package/dist/internal/trace-context.js +14 -0
  79. package/dist/internal/trace-context.js.map +1 -0
  80. package/dist/mod-client.d.ts +7 -20
  81. package/dist/mod-client.d.ts.map +1 -1
  82. package/dist/mod-client.js +25 -13
  83. package/dist/mod-client.js.map +1 -1
  84. package/dist/mod.d.ts +8 -6
  85. package/dist/mod.js +3 -1
  86. package/dist/runtime.d.ts +15 -0
  87. package/dist/runtime.d.ts.map +1 -0
  88. package/dist/runtime.js +33 -0
  89. package/dist/runtime.js.map +1 -0
  90. package/dist/test/test.d.ts +6 -6
  91. package/dist/test/test.d.ts.map +1 -1
  92. package/dist/test/test.js.map +1 -1
  93. package/dist/util/ssr.js.map +1 -1
  94. package/package.json +42 -52
  95. package/src/api/api.test.ts +3 -1
  96. package/src/api/api.ts +28 -0
  97. package/src/api/bind-services.ts +0 -5
  98. package/src/api/error.ts +1 -0
  99. package/src/api/fragment-definition-builder.extend.test.ts +2 -1
  100. package/src/api/fragment-definition-builder.test.ts +2 -1
  101. package/src/api/fragment-definition-builder.ts +56 -112
  102. package/src/api/fragment-instantiator.test.ts +311 -166
  103. package/src/api/fragment-instantiator.ts +470 -131
  104. package/src/api/fragment-services.test.ts +1 -0
  105. package/src/api/internal/path-runtime.test.ts +8 -0
  106. package/src/api/internal/path-type.test.ts +3 -1
  107. package/src/api/internal/route.test.ts +1 -0
  108. package/src/api/request-context-storage.ts +7 -0
  109. package/src/api/request-input-context.test.ts +156 -2
  110. package/src/api/request-input-context.ts +87 -1
  111. package/src/api/request-middleware.test.ts +43 -2
  112. package/src/api/request-middleware.ts +4 -3
  113. package/src/api/request-output-context.test.ts +3 -1
  114. package/src/api/request-output-context.ts +2 -1
  115. package/src/api/route-caller.test.ts +195 -0
  116. package/src/api/route-caller.ts +167 -0
  117. package/src/api/route-handler-input-options.ts +2 -1
  118. package/src/api/route.test.ts +4 -2
  119. package/src/api/route.ts +9 -3
  120. package/src/api/shared-types.ts +2 -1
  121. package/src/client/client-builder.test.ts +4 -2
  122. package/src/client/client-error.test.ts +2 -1
  123. package/src/client/client-error.ts +1 -1
  124. package/src/client/client-types.test.ts +19 -5
  125. package/src/client/client.ssr.test.ts +6 -4
  126. package/src/client/client.svelte.test.ts +18 -9
  127. package/src/client/client.svelte.ts +38 -13
  128. package/src/client/client.test.ts +244 -10
  129. package/src/client/client.ts +473 -148
  130. package/src/client/internal/ndjson-streaming.test.ts +6 -3
  131. package/src/client/internal/ndjson-streaming.ts +1 -0
  132. package/src/client/react.test.ts +176 -6
  133. package/src/client/react.ts +226 -31
  134. package/src/client/solid.test.ts +29 -5
  135. package/src/client/solid.ts +60 -22
  136. package/src/client/vanilla.test.ts +148 -6
  137. package/src/client/vanilla.ts +63 -9
  138. package/src/client/vue.test.ts +397 -8
  139. package/src/client/vue.ts +74 -4
  140. package/src/id.ts +1 -0
  141. package/src/internal/cuid.test.ts +164 -0
  142. package/src/internal/cuid.ts +133 -0
  143. package/src/internal/trace-context.ts +35 -0
  144. package/src/mod-client.ts +55 -9
  145. package/src/mod.ts +9 -3
  146. package/src/runtime.ts +48 -0
  147. package/src/test/test.test.ts +4 -2
  148. package/src/test/test.ts +14 -7
  149. package/src/util/async.test.ts +1 -0
  150. package/src/util/content-type.test.ts +1 -0
  151. package/src/util/nanostores.test.ts +3 -1
  152. package/src/util/ssr.ts +1 -0
  153. package/tsconfig.json +1 -1
  154. package/tsdown.config.ts +2 -0
  155. package/vitest.config.ts +2 -1
@@ -1,12 +1,16 @@
1
+ import { computed, task, type ReadableAtom, type Store } from "nanostores";
2
+
1
3
  import { nanoquery, type FetcherStore, type MutatorStore } from "@nanostores/query";
2
4
  import type { StandardSchemaV1 } from "@standard-schema/spec";
3
- import { computed, task, type ReadableAtom, type Store } from "nanostores";
5
+
4
6
  import type {
5
7
  FragnoRouteConfig,
6
8
  HTTPMethod,
7
9
  NonGetHTTPMethod,
8
10
  RequestThisContext,
11
+ RouteContentType,
9
12
  } from "../api/api";
13
+ import type { FragmentDefinition } from "../api/fragment-definition-builder";
10
14
  import {
11
15
  buildPath,
12
16
  extractPathParams,
@@ -17,28 +21,28 @@ import {
17
21
  import { getMountRoute } from "../api/internal/route";
18
22
  import { RequestInputContext } from "../api/request-input-context";
19
23
  import { RequestOutputContext } from "../api/request-output-context";
24
+ import {
25
+ type AnyFragnoRouteConfig,
26
+ type AnyRouteOrFactory,
27
+ type FlattenRouteFactories,
28
+ resolveRouteFactories,
29
+ } from "../api/route";
20
30
  import type {
21
31
  FetcherConfig,
22
32
  FragnoFragmentSharedConfig,
23
33
  FragnoPublicClientConfig,
24
34
  FragnoPublicConfig,
25
35
  } from "../api/shared-types";
26
- import { FragnoClientApiError, FragnoClientError, FragnoClientFetchError } from "./client-error";
27
- import type { InferOr } from "../util/types-util";
28
36
  import { parseContentType } from "../util/content-type";
37
+ import { unwrapObject } from "../util/nanostores";
38
+ import { addStore, getInitialData, SSR_ENABLED } from "../util/ssr";
39
+ import type { InferOr } from "../util/types-util";
40
+ import { FragnoClientApiError, FragnoClientError, FragnoClientFetchError } from "./client-error";
41
+ import { mergeFetcherConfigs } from "./internal/fetcher-merge";
29
42
  import {
30
43
  handleNdjsonStreamingFirstItem,
31
44
  type NdjsonStreamingStore,
32
45
  } from "./internal/ndjson-streaming";
33
- import { addStore, getInitialData, SSR_ENABLED } from "../util/ssr";
34
- import { unwrapObject } from "../util/nanostores";
35
- import type { FragmentDefinition } from "../api/fragment-definition-builder";
36
- import {
37
- type AnyRouteOrFactory,
38
- type FlattenRouteFactories,
39
- resolveRouteFactories,
40
- } from "../api/route";
41
- import { mergeFetcherConfigs } from "./internal/fetcher-merge";
42
46
 
43
47
  /**
44
48
  * Symbols used to identify hook types
@@ -48,58 +52,294 @@ const MUTATOR_HOOK_SYMBOL = Symbol("fragno-mutator-hook");
48
52
  const STORE_SYMBOL = Symbol("fragno-store");
49
53
 
50
54
  /**
51
- * Extract only GET routes from a library config's routes array
55
+ * Check if a value contains files that should be sent as FormData.
52
56
  * @internal
53
57
  */
54
- export type ExtractGetRoutes<
55
- T extends readonly FragnoRouteConfig<
56
- HTTPMethod,
57
- string,
58
- StandardSchemaV1 | undefined,
59
- StandardSchemaV1 | undefined,
60
- string,
61
- string
62
- >[],
63
- > = {
58
+ function containsFiles(value: unknown): boolean {
59
+ if (value instanceof File || value instanceof Blob) {
60
+ return true;
61
+ }
62
+
63
+ if (value instanceof FormData) {
64
+ return true;
65
+ }
66
+
67
+ if (typeof value === "object" && value !== null) {
68
+ return Object.values(value).some(
69
+ (v) => v instanceof File || v instanceof Blob || v instanceof FormData,
70
+ );
71
+ }
72
+
73
+ return false;
74
+ }
75
+
76
+ /**
77
+ * Convert an object containing files to FormData.
78
+ * Handles nested File/Blob values by appending them directly.
79
+ * Other values are JSON-stringified.
80
+ * @internal
81
+ */
82
+ function toFormData(value: object): FormData {
83
+ const formData = new FormData();
84
+
85
+ for (const [key, val] of Object.entries(value)) {
86
+ if (val instanceof File) {
87
+ formData.append(key, val, val.name);
88
+ } else if (val instanceof Blob) {
89
+ formData.append(key, val);
90
+ } else if (val !== undefined && val !== null) {
91
+ // For non-file values, stringify if needed
92
+ formData.append(key, typeof val === "string" ? val : JSON.stringify(val));
93
+ }
94
+ }
95
+
96
+ return formData;
97
+ }
98
+
99
+ /**
100
+ * Prepare request body and headers for sending.
101
+ * Handles FormData (file uploads) vs JSON data.
102
+ * @internal
103
+ */
104
+ function prepareRequestBody(
105
+ body: unknown,
106
+ contentType?: RouteContentType,
107
+ ): { body: BodyInit | undefined; headers?: HeadersInit } {
108
+ if (body === undefined) {
109
+ return { body: undefined };
110
+ }
111
+
112
+ if (contentType === "application/octet-stream") {
113
+ if (
114
+ body instanceof ReadableStream ||
115
+ body instanceof Blob ||
116
+ body instanceof File ||
117
+ body instanceof ArrayBuffer ||
118
+ body instanceof Uint8Array
119
+ ) {
120
+ return { body: body as BodyInit, headers: { "Content-Type": "application/octet-stream" } };
121
+ }
122
+
123
+ throw new Error(
124
+ "Octet-stream routes only accept Blob, File, ArrayBuffer, Uint8Array, or ReadableStream bodies.",
125
+ );
126
+ }
127
+
128
+ // If already FormData, send as-is (browser sets Content-Type with boundary)
129
+ if (body instanceof FormData) {
130
+ return { body };
131
+ }
132
+
133
+ // If body is directly a File or Blob, wrap it in FormData
134
+ if (body instanceof File) {
135
+ const formData = new FormData();
136
+ formData.append("file", body, body.name);
137
+ return { body: formData };
138
+ }
139
+
140
+ if (body instanceof Blob) {
141
+ const formData = new FormData();
142
+ formData.append("file", body);
143
+ return { body: formData };
144
+ }
145
+
146
+ // If object contains files, convert to FormData
147
+ if (typeof body === "object" && body !== null && containsFiles(body)) {
148
+ return { body: toFormData(body) };
149
+ }
150
+
151
+ // Otherwise, JSON-stringify
152
+ return {
153
+ body: JSON.stringify(body),
154
+ headers: { "Content-Type": "application/json" },
155
+ };
156
+ }
157
+
158
+ async function schemaAllowsUndefined(schema: StandardSchemaV1): Promise<boolean> {
159
+ try {
160
+ const result = await schema["~standard"].validate(undefined);
161
+ return !result.issues;
162
+ } catch {
163
+ return false;
164
+ }
165
+ }
166
+
167
+ async function assertBodyProvided(
168
+ body: unknown,
169
+ inputSchema: StandardSchemaV1 | undefined,
170
+ errorMessage: string,
171
+ ): Promise<void> {
172
+ if (typeof body !== "undefined" || inputSchema === undefined) {
173
+ return;
174
+ }
175
+
176
+ if (await schemaAllowsUndefined(inputSchema)) {
177
+ return;
178
+ }
179
+
180
+ throw new Error(errorMessage);
181
+ }
182
+
183
+ /**
184
+ * Merge request headers from multiple sources.
185
+ * Returns undefined if there are no headers to merge.
186
+ * @internal
187
+ */
188
+ function mergeRequestHeaders(
189
+ ...headerSources: (HeadersInit | undefined)[]
190
+ ): Record<string, string> | undefined {
191
+ const result: Record<string, string> = {};
192
+ let hasHeaders = false;
193
+
194
+ for (const source of headerSources) {
195
+ if (!source) {
196
+ continue;
197
+ }
198
+
199
+ if (source instanceof Headers) {
200
+ for (const [key, value] of source.entries()) {
201
+ result[key] = value;
202
+ hasHeaders = true;
203
+ }
204
+ } else if (Array.isArray(source)) {
205
+ for (const [key, value] of source) {
206
+ result[key] = value;
207
+ hasHeaders = true;
208
+ }
209
+ } else {
210
+ for (const [key, value] of Object.entries(source)) {
211
+ result[key] = value;
212
+ hasHeaders = true;
213
+ }
214
+ }
215
+ }
216
+
217
+ return hasHeaders ? result : undefined;
218
+ }
219
+
220
+ /**
221
+ * @internal
222
+ */
223
+ type FilterRouteByMethod<TRoute, TExpectedMethod extends HTTPMethod> =
224
+ TRoute extends FragnoRouteConfig<
225
+ infer TMethod,
226
+ infer TPath,
227
+ infer TInputSchema,
228
+ infer TOutputSchema,
229
+ infer TErrorCode,
230
+ infer TQueryParameters,
231
+ infer TThisContext
232
+ >
233
+ ? [Extract<TMethod, TExpectedMethod>] extends [never]
234
+ ? [Extract<TExpectedMethod, TMethod>] extends [never]
235
+ ? never
236
+ : FragnoRouteConfig<
237
+ TMethod,
238
+ TPath,
239
+ TInputSchema,
240
+ TOutputSchema,
241
+ TErrorCode,
242
+ TQueryParameters,
243
+ TThisContext
244
+ >
245
+ : FragnoRouteConfig<
246
+ TMethod,
247
+ TPath,
248
+ TInputSchema,
249
+ TOutputSchema,
250
+ TErrorCode,
251
+ TQueryParameters,
252
+ TThisContext
253
+ >
254
+ : never;
255
+
256
+ /**
257
+ * @internal
258
+ */
259
+ type FilterRouteByPath<TRoute, TPath extends string> =
260
+ TRoute extends FragnoRouteConfig<
261
+ infer TMethod,
262
+ infer TRoutePath,
263
+ infer TInputSchema,
264
+ infer TOutputSchema,
265
+ infer TErrorCode,
266
+ infer TQueryParameters,
267
+ infer TThisContext
268
+ >
269
+ ? [Extract<TRoutePath, TPath>] extends [never]
270
+ ? [Extract<TPath, TRoutePath>] extends [never]
271
+ ? never
272
+ : FragnoRouteConfig<
273
+ TMethod,
274
+ TRoutePath,
275
+ TInputSchema,
276
+ TOutputSchema,
277
+ TErrorCode,
278
+ TQueryParameters,
279
+ TThisContext
280
+ >
281
+ : FragnoRouteConfig<
282
+ TMethod,
283
+ TRoutePath,
284
+ TInputSchema,
285
+ TOutputSchema,
286
+ TErrorCode,
287
+ TQueryParameters,
288
+ TThisContext
289
+ >
290
+ : never;
291
+
292
+ /**
293
+ * @internal
294
+ */
295
+ type ExtractGetRoutesExact<T extends readonly AnyFragnoRouteConfig[]> = {
64
296
  [K in keyof T]: T[K] extends FragnoRouteConfig<
65
- infer Method,
66
- infer Path,
67
- infer Input,
68
- infer Output,
69
- infer ErrorCode,
70
- infer QueryParams
297
+ infer TMethod,
298
+ infer TPath,
299
+ infer TInputSchema,
300
+ infer TOutputSchema,
301
+ infer TErrorCode,
302
+ infer TQueryParameters,
303
+ infer TThisContext
71
304
  >
72
- ? Method extends "GET"
73
- ? FragnoRouteConfig<Method, Path, Input, Output, ErrorCode, QueryParams>
305
+ ? TMethod extends "GET"
306
+ ? FragnoRouteConfig<
307
+ TMethod,
308
+ TPath,
309
+ TInputSchema,
310
+ TOutputSchema,
311
+ TErrorCode,
312
+ TQueryParameters,
313
+ TThisContext
314
+ >
74
315
  : never
75
316
  : never;
76
317
  }[number][];
77
318
 
78
319
  /**
79
- * Extract the path from a route configuration for a given method
320
+ * Extract only GET routes from a library config's routes array
80
321
  * @internal
81
322
  */
82
- export type ExtractRoutePath<
83
- T extends readonly FragnoRouteConfig<
84
- HTTPMethod,
85
- string,
86
- StandardSchemaV1 | undefined,
87
- StandardSchemaV1 | undefined,
88
- string,
89
- string
90
- >[],
323
+ export type ExtractGetRoutes<T extends readonly AnyFragnoRouteConfig[]> = ExtractGetRoutesExact<T>;
324
+
325
+ /**
326
+ * @internal
327
+ */
328
+ type ExtractRoutePathExact<
329
+ T extends readonly AnyFragnoRouteConfig[],
91
330
  TExpectedMethod extends HTTPMethod = HTTPMethod,
92
331
  > = {
93
332
  [K in keyof T]: T[K] extends FragnoRouteConfig<
94
- infer Method,
95
- infer Path,
333
+ infer TMethod,
334
+ infer TPath,
96
335
  StandardSchemaV1 | undefined,
97
336
  StandardSchemaV1 | undefined,
98
337
  string,
99
- string
338
+ string,
339
+ RequestThisContext
100
340
  >
101
- ? Method extends TExpectedMethod
102
- ? Path
341
+ ? TMethod extends TExpectedMethod
342
+ ? TPath
103
343
  : never
104
344
  : never;
105
345
  }[number];
@@ -107,103 +347,154 @@ export type ExtractRoutePath<
107
347
  /**
108
348
  * @internal
109
349
  */
110
- export type ExtractGetRoutePaths<
111
- T extends readonly FragnoRouteConfig<
112
- HTTPMethod,
113
- string,
350
+ type ExtractRoutePathLoose<
351
+ T extends readonly AnyFragnoRouteConfig[],
352
+ TExpectedMethod extends HTTPMethod = HTTPMethod,
353
+ > = {
354
+ [K in keyof T]: FilterRouteByMethod<T[K], TExpectedMethod> extends FragnoRouteConfig<
355
+ infer _TMethod,
356
+ infer TPath,
114
357
  StandardSchemaV1 | undefined,
115
358
  StandardSchemaV1 | undefined,
116
359
  string,
117
- string
118
- >[],
119
- > = ExtractRoutePath<T, "GET">;
360
+ string,
361
+ RequestThisContext
362
+ >
363
+ ? TPath
364
+ : never;
365
+ }[number];
120
366
 
121
367
  /**
122
368
  * @internal
123
369
  */
124
- export type ExtractNonGetRoutePaths<
125
- T extends readonly FragnoRouteConfig<
126
- HTTPMethod,
127
- string,
128
- StandardSchemaV1 | undefined,
129
- StandardSchemaV1 | undefined,
130
- string,
131
- string
132
- >[],
133
- > = ExtractRoutePath<T, NonGetHTTPMethod>;
370
+ type HasWidenedRouteShape<T extends readonly AnyFragnoRouteConfig[]> =
371
+ T[number] extends infer TRoute
372
+ ? TRoute extends FragnoRouteConfig<
373
+ infer TMethod,
374
+ infer TPath,
375
+ StandardSchemaV1 | undefined,
376
+ StandardSchemaV1 | undefined,
377
+ string,
378
+ string,
379
+ RequestThisContext
380
+ >
381
+ ? string extends TPath
382
+ ? true
383
+ : HTTPMethod extends TMethod
384
+ ? true
385
+ : false
386
+ : false
387
+ : false;
134
388
 
135
389
  /**
136
- * Extract the route configuration type(s) for a given path from a routes array.
137
- * Optionally narrow by HTTP method via the third type parameter.
138
- *
139
- * Defaults to extracting all methods for the matching path, producing a union
140
- * if multiple methods exist for the same path.
390
+ * Extract the path from a route configuration for a given method
141
391
  * @internal
142
392
  */
143
- export type ExtractRouteByPath<
144
- TRoutes extends readonly FragnoRouteConfig<
145
- HTTPMethod,
146
- string,
147
- StandardSchemaV1 | undefined,
148
- StandardSchemaV1 | undefined,
149
- string,
150
- string
151
- >[],
393
+ export type ExtractRoutePath<
394
+ T extends readonly AnyFragnoRouteConfig[],
395
+ TExpectedMethod extends HTTPMethod = HTTPMethod,
396
+ > = [ExtractRoutePathExact<T, TExpectedMethod>] extends [never]
397
+ ? HasWidenedRouteShape<T> extends true
398
+ ? ExtractRoutePathLoose<T, TExpectedMethod> & string
399
+ : never
400
+ : ExtractRoutePathExact<T, TExpectedMethod>;
401
+
402
+ /**
403
+ * @internal
404
+ */
405
+ export type ExtractGetRoutePaths<T extends readonly AnyFragnoRouteConfig[]> = ExtractRoutePath<
406
+ T,
407
+ "GET"
408
+ >;
409
+
410
+ /**
411
+ * @internal
412
+ */
413
+ export type ExtractNonGetRoutePaths<T extends readonly AnyFragnoRouteConfig[]> = ExtractRoutePath<
414
+ T,
415
+ NonGetHTTPMethod
416
+ >;
417
+
418
+ /**
419
+ * @internal
420
+ */
421
+ type ExtractRouteByPathExact<
422
+ TRoutes extends readonly AnyFragnoRouteConfig[],
152
423
  TPath extends string,
153
424
  TMethod extends HTTPMethod = HTTPMethod,
154
425
  > = {
155
426
  [K in keyof TRoutes]: TRoutes[K] extends FragnoRouteConfig<
156
- infer M,
427
+ infer TRouteMethod,
157
428
  TPath,
158
- infer Input,
159
- infer Output,
160
- infer ErrorCode,
161
- infer QueryParams
429
+ infer TInputSchema,
430
+ infer TOutputSchema,
431
+ infer TErrorCode,
432
+ infer TQueryParameters,
433
+ infer TThisContext
162
434
  >
163
- ? M extends TMethod
164
- ? FragnoRouteConfig<M, TPath, Input, Output, ErrorCode, QueryParams>
435
+ ? TRouteMethod extends TMethod
436
+ ? FragnoRouteConfig<
437
+ TRouteMethod,
438
+ TPath,
439
+ TInputSchema,
440
+ TOutputSchema,
441
+ TErrorCode,
442
+ TQueryParameters,
443
+ TThisContext
444
+ >
165
445
  : never
166
446
  : never;
167
447
  }[number];
168
448
 
449
+ /**
450
+ * @internal
451
+ */
452
+ type ExtractRouteByPathLoose<
453
+ TRoutes extends readonly AnyFragnoRouteConfig[],
454
+ TPath extends string,
455
+ TMethod extends HTTPMethod = HTTPMethod,
456
+ > = {
457
+ [K in keyof TRoutes]: FilterRouteByPath<FilterRouteByMethod<TRoutes[K], TMethod>, TPath>;
458
+ }[number];
459
+
460
+ /**
461
+ * Extract the route configuration type(s) for a given path from a routes array.
462
+ * Optionally narrow by HTTP method via the third type parameter.
463
+ *
464
+ * Defaults to extracting all methods for the matching path, producing a union
465
+ * if multiple methods exist for the same path.
466
+ * @internal
467
+ */
468
+ export type ExtractRouteByPath<
469
+ TRoutes extends readonly AnyFragnoRouteConfig[],
470
+ TPath extends string,
471
+ TMethod extends HTTPMethod = HTTPMethod,
472
+ > = [ExtractRouteByPathExact<TRoutes, TPath, TMethod>] extends [never]
473
+ ? HasWidenedRouteShape<TRoutes> extends true
474
+ ? ExtractRouteByPathLoose<TRoutes, TPath, TMethod>
475
+ : never
476
+ : ExtractRouteByPathExact<TRoutes, TPath, TMethod>;
477
+
169
478
  /**
170
479
  * Extract the output schema type for a specific route path from a routes array
171
480
  * @internal
172
481
  */
173
482
  export type ExtractOutputSchemaForPath<
174
- TRoutes extends readonly FragnoRouteConfig<
175
- HTTPMethod,
176
- string,
177
- StandardSchemaV1 | undefined,
178
- StandardSchemaV1 | undefined
179
- >[],
483
+ TRoutes extends readonly AnyFragnoRouteConfig[],
180
484
  TPath extends string,
181
- > = {
182
- [K in keyof TRoutes]: TRoutes[K] extends FragnoRouteConfig<
183
- infer Method,
184
- TPath,
185
- StandardSchemaV1 | undefined,
186
- infer Output
187
- >
188
- ? Method extends "GET"
189
- ? Output
190
- : never
485
+ > =
486
+ ExtractRouteByPath<TRoutes, TPath, "GET"> extends {
487
+ outputSchema?: infer TOutputSchema;
488
+ }
489
+ ? TOutputSchema
191
490
  : never;
192
- }[number];
193
491
 
194
492
  /**
195
493
  * Check if a path exists as a GET route in the routes array
196
494
  * @internal
197
495
  */
198
496
  export type IsValidGetRoutePath<
199
- TRoutes extends readonly FragnoRouteConfig<
200
- HTTPMethod,
201
- string,
202
- StandardSchemaV1 | undefined,
203
- StandardSchemaV1 | undefined,
204
- string,
205
- string
206
- >[],
497
+ TRoutes extends readonly AnyFragnoRouteConfig[],
207
498
  TPath extends string,
208
499
  > = TPath extends ExtractGetRoutePaths<TRoutes> ? true : false;
209
500
 
@@ -212,14 +503,7 @@ export type IsValidGetRoutePath<
212
503
  * @internal
213
504
  */
214
505
  export type ValidateGetRoutePath<
215
- TRoutes extends readonly FragnoRouteConfig<
216
- HTTPMethod,
217
- string,
218
- StandardSchemaV1 | undefined,
219
- StandardSchemaV1 | undefined,
220
- string,
221
- string
222
- >[],
506
+ TRoutes extends readonly AnyFragnoRouteConfig[],
223
507
  TPath extends string,
224
508
  > =
225
509
  TPath extends ExtractGetRoutePaths<TRoutes>
@@ -230,16 +514,8 @@ export type ValidateGetRoutePath<
230
514
  * Helper type to check if a routes array has any GET routes
231
515
  * @internal
232
516
  */
233
- export type HasGetRoutes<
234
- T extends readonly FragnoRouteConfig<
235
- HTTPMethod,
236
- string,
237
- StandardSchemaV1 | undefined,
238
- StandardSchemaV1 | undefined,
239
- string,
240
- string
241
- >[],
242
- > = ExtractGetRoutePaths<T> extends never ? false : true;
517
+ export type HasGetRoutes<T extends readonly AnyFragnoRouteConfig[]> =
518
+ ExtractGetRoutePaths<T> extends never ? false : true;
243
519
 
244
520
  /**
245
521
  * @internal
@@ -255,11 +531,20 @@ export type ObjectContainingStoreField<T extends object> = T extends Store
255
531
  /**
256
532
  * @internal
257
533
  */
258
- export type FragnoStoreData<T extends object> = {
534
+ export type FragnoStoreObjectData<T extends object> = {
259
535
  obj: T;
260
536
  [STORE_SYMBOL]: true;
261
537
  };
262
538
 
539
+ export type FragnoStoreFactoryData<T extends object, TArgs extends unknown[] = []> = {
540
+ factory: (...args: TArgs) => T;
541
+ [STORE_SYMBOL]: true;
542
+ };
543
+
544
+ export type FragnoStoreData<T extends object, TArgs extends unknown[] = []> =
545
+ | FragnoStoreObjectData<T>
546
+ | FragnoStoreFactoryData<T, TArgs>;
547
+
263
548
  export type FragnoClientHookData<
264
549
  TMethod extends HTTPMethod,
265
550
  TPath extends string,
@@ -484,7 +769,9 @@ export function isMutatorHook<
484
769
  /**
485
770
  * @internal
486
771
  */
487
- export function isStore<TStore extends Store>(obj: unknown): obj is FragnoStoreData<TStore> {
772
+ export function isStore<TStore extends object, TArgs extends unknown[] = []>(
773
+ obj: unknown,
774
+ ): obj is FragnoStoreData<TStore, TArgs> {
488
775
  return (
489
776
  typeof obj === "object" && obj !== null && STORE_SYMBOL in obj && obj[STORE_SYMBOL] === true
490
777
  );
@@ -569,8 +856,18 @@ export class ClientBuilder<
569
856
  return Object.fromEntries(this.#cache.entries());
570
857
  }
571
858
 
572
- createStore<const T extends object>(obj: T): FragnoStoreData<T> {
573
- return { obj: obj, [STORE_SYMBOL]: true };
859
+ createStore<const TArgs extends unknown[], const T extends object>(
860
+ factory: (...args: TArgs) => T,
861
+ ): FragnoStoreFactoryData<T, TArgs>;
862
+ createStore<const T extends object>(obj: T): FragnoStoreObjectData<T>;
863
+ createStore<const TArgs extends unknown[], const T extends object>(
864
+ input: T | ((...args: TArgs) => T),
865
+ ): FragnoStoreData<T, TArgs> {
866
+ if (typeof input === "function") {
867
+ return { factory: input as (...args: TArgs) => T, [STORE_SYMBOL]: true };
868
+ }
869
+
870
+ return { obj: input, [STORE_SYMBOL]: true };
574
871
  }
575
872
 
576
873
  /**
@@ -585,7 +882,10 @@ export class ClientBuilder<
585
882
  },
586
883
  ): string {
587
884
  const baseUrl = this.#publicConfig.baseUrl ?? "";
588
- const mountRoute = getMountRoute(this.#fragmentConfig);
885
+ const mountRoute = getMountRoute({
886
+ name: this.#fragmentConfig.name,
887
+ mountRoute: this.#publicConfig.mountRoute,
888
+ });
589
889
 
590
890
  return buildUrl(
591
891
  { baseUrl, mountRoute, path },
@@ -611,7 +911,7 @@ export class ClientBuilder<
611
911
  if (this.#fetcherConfig?.type === "function") {
612
912
  return this.#fetcherConfig.fetcher;
613
913
  }
614
- return fetch;
914
+ return globalThis.fetch.bind(globalThis);
615
915
  }
616
916
 
617
917
  #getFetcherOptions(): RequestInit | undefined {
@@ -627,9 +927,11 @@ export class ClientBuilder<
627
927
  ): FragnoClientHookData<
628
928
  "GET",
629
929
  TPath,
630
- NonNullable<ExtractRouteByPath<TFragmentConfig["routes"], TPath>["outputSchema"]>,
631
- NonNullable<ExtractRouteByPath<TFragmentConfig["routes"], TPath>["errorCodes"]>[number],
632
- NonNullable<ExtractRouteByPath<TFragmentConfig["routes"], TPath>["queryParameters"]>[number]
930
+ NonNullable<ExtractRouteByPath<TFragmentConfig["routes"], TPath, "GET">["outputSchema"]>,
931
+ NonNullable<ExtractRouteByPath<TFragmentConfig["routes"], TPath, "GET">["errorCodes"]>[number],
932
+ NonNullable<
933
+ ExtractRouteByPath<TFragmentConfig["routes"], TPath, "GET">["queryParameters"]
934
+ >[number]
633
935
  > {
634
936
  const route = this.#fragmentConfig.routes.find(
635
937
  (
@@ -717,7 +1019,10 @@ export class ClientBuilder<
717
1019
  }
718
1020
 
719
1021
  const baseUrl = this.#publicConfig.baseUrl ?? "";
720
- const mountRoute = getMountRoute(this.#fragmentConfig);
1022
+ const mountRoute = getMountRoute({
1023
+ name: this.#fragmentConfig.name,
1024
+ mountRoute: this.#publicConfig.mountRoute,
1025
+ });
721
1026
  const fetcher = this.#getFetcher();
722
1027
  const fetcherOptions = this.#getFetcherOptions();
723
1028
 
@@ -908,7 +1213,10 @@ export class ClientBuilder<
908
1213
  const method = route.method;
909
1214
 
910
1215
  const baseUrl = this.#publicConfig.baseUrl ?? "";
911
- const mountRoute = getMountRoute(this.#fragmentConfig);
1216
+ const mountRoute = getMountRoute({
1217
+ name: this.#fragmentConfig.name,
1218
+ mountRoute: this.#publicConfig.mountRoute,
1219
+ });
912
1220
  const fetcher = this.#getFetcher();
913
1221
  const fetcherOptions = this.#getFetcherOptions();
914
1222
 
@@ -944,11 +1252,27 @@ export class ClientBuilder<
944
1252
 
945
1253
  let response: Response;
946
1254
  try {
947
- const requestOptions: RequestInit = {
1255
+ const { body: preparedBody, headers: bodyHeaders } = prepareRequestBody(
1256
+ body,
1257
+ route.contentType,
1258
+ );
1259
+
1260
+ // Merge headers: fetcherOptions headers + body-specific headers (e.g., Content-Type for JSON)
1261
+ // For FormData, bodyHeaders is undefined and browser sets Content-Type with boundary automatically
1262
+ const mergedHeaders = mergeRequestHeaders(
1263
+ fetcherOptions?.headers as HeadersInit | undefined,
1264
+ bodyHeaders,
1265
+ );
1266
+
1267
+ const requestOptions: RequestInit & { duplex?: "half" } = {
948
1268
  ...fetcherOptions,
949
1269
  method,
950
- body: body !== undefined ? JSON.stringify(body) : undefined,
1270
+ body: preparedBody,
1271
+ ...(mergedHeaders ? { headers: mergedHeaders } : {}),
951
1272
  };
1273
+ if (preparedBody instanceof ReadableStream) {
1274
+ requestOptions.duplex = "half";
1275
+ }
952
1276
  response = await fetcher(url, requestOptions);
953
1277
  } catch (error) {
954
1278
  throw FragnoClientFetchError.fromUnknownFetchError(error);
@@ -980,9 +1304,7 @@ export class ClientBuilder<
980
1304
  query?: Record<string, string>;
981
1305
  };
982
1306
 
983
- if (typeof body === "undefined" && route.inputSchema !== undefined) {
984
- throw new Error("Body is required.");
985
- }
1307
+ await assertBodyProvided(body, route.inputSchema, "Body is required.");
986
1308
 
987
1309
  const response = await executeMutateQuery({ body, path, query });
988
1310
 
@@ -1051,9 +1373,7 @@ export class ClientBuilder<
1051
1373
  query?: Record<string, string>;
1052
1374
  };
1053
1375
 
1054
- if (typeof body === "undefined" && route.inputSchema !== undefined) {
1055
- throw new Error("Body is required for mutateQuery");
1056
- }
1376
+ await assertBodyProvided(body, route.inputSchema, "Body is required for mutateQuery");
1057
1377
 
1058
1378
  const response = await executeMutateQuery({ body, path, query });
1059
1379
 
@@ -1130,6 +1450,7 @@ export function createClientBuilder<
1130
1450
  THandlerThisContext extends RequestThisContext,
1131
1451
  TRequestStorage,
1132
1452
  const TRoutesOrFactories extends readonly AnyRouteOrFactory[],
1453
+ TInternalRoutes extends readonly AnyRouteOrFactory[] = readonly [],
1133
1454
  >(
1134
1455
  definition: FragmentDefinition<
1135
1456
  TConfig,
@@ -1141,7 +1462,8 @@ export function createClientBuilder<
1141
1462
  TPrivateServices,
1142
1463
  TServiceThisContext,
1143
1464
  THandlerThisContext,
1144
- TRequestStorage
1465
+ TRequestStorage,
1466
+ TInternalRoutes
1145
1467
  >,
1146
1468
  publicConfig: FragnoPublicClientConfig,
1147
1469
  routesOrFactories: TRoutesOrFactories,
@@ -1166,7 +1488,10 @@ export function createClientBuilder<
1166
1488
  routes,
1167
1489
  };
1168
1490
 
1169
- const mountRoute = publicConfig.mountRoute ?? `/${definition.name}`;
1491
+ const mountRoute = getMountRoute({
1492
+ name: definition.name,
1493
+ mountRoute: publicConfig.mountRoute,
1494
+ });
1170
1495
  const mergedFetcherConfig = mergeFetcherConfigs(authorFetcherConfig, publicConfig.fetcherConfig);
1171
1496
  const fullPublicConfig = {
1172
1497
  ...publicConfig,