@fragno-dev/core 0.1.11 → 0.2.0

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 (69) hide show
  1. package/.turbo/turbo-build.log +50 -42
  2. package/CHANGELOG.md +51 -0
  3. package/dist/api/api.d.ts +19 -1
  4. package/dist/api/api.d.ts.map +1 -1
  5. package/dist/api/api.js.map +1 -1
  6. package/dist/api/fragment-definition-builder.d.ts +17 -7
  7. package/dist/api/fragment-definition-builder.d.ts.map +1 -1
  8. package/dist/api/fragment-definition-builder.js +3 -2
  9. package/dist/api/fragment-definition-builder.js.map +1 -1
  10. package/dist/api/fragment-instantiator.d.ts +23 -16
  11. package/dist/api/fragment-instantiator.d.ts.map +1 -1
  12. package/dist/api/fragment-instantiator.js +163 -19
  13. package/dist/api/fragment-instantiator.js.map +1 -1
  14. package/dist/api/request-input-context.d.ts +57 -1
  15. package/dist/api/request-input-context.d.ts.map +1 -1
  16. package/dist/api/request-input-context.js +67 -0
  17. package/dist/api/request-input-context.js.map +1 -1
  18. package/dist/api/request-middleware.d.ts +1 -1
  19. package/dist/api/request-middleware.d.ts.map +1 -1
  20. package/dist/api/request-middleware.js.map +1 -1
  21. package/dist/api/route.d.ts +7 -7
  22. package/dist/api/route.d.ts.map +1 -1
  23. package/dist/api/route.js.map +1 -1
  24. package/dist/client/client.d.ts +4 -3
  25. package/dist/client/client.d.ts.map +1 -1
  26. package/dist/client/client.js +103 -7
  27. package/dist/client/client.js.map +1 -1
  28. package/dist/client/vue.d.ts +7 -3
  29. package/dist/client/vue.d.ts.map +1 -1
  30. package/dist/client/vue.js +16 -1
  31. package/dist/client/vue.js.map +1 -1
  32. package/dist/internal/trace-context.d.ts +23 -0
  33. package/dist/internal/trace-context.d.ts.map +1 -0
  34. package/dist/internal/trace-context.js +14 -0
  35. package/dist/internal/trace-context.js.map +1 -0
  36. package/dist/mod-client.d.ts +3 -17
  37. package/dist/mod-client.d.ts.map +1 -1
  38. package/dist/mod-client.js +20 -10
  39. package/dist/mod-client.js.map +1 -1
  40. package/dist/mod.d.ts +3 -2
  41. package/dist/mod.js +2 -1
  42. package/dist/runtime.d.ts +15 -0
  43. package/dist/runtime.d.ts.map +1 -0
  44. package/dist/runtime.js +33 -0
  45. package/dist/runtime.js.map +1 -0
  46. package/dist/test/test.d.ts +2 -2
  47. package/dist/test/test.d.ts.map +1 -1
  48. package/dist/test/test.js.map +1 -1
  49. package/package.json +23 -17
  50. package/src/api/api.ts +22 -0
  51. package/src/api/fragment-definition-builder.ts +36 -17
  52. package/src/api/fragment-instantiator.test.ts +286 -0
  53. package/src/api/fragment-instantiator.ts +338 -31
  54. package/src/api/internal/path-runtime.test.ts +7 -0
  55. package/src/api/request-input-context.test.ts +152 -0
  56. package/src/api/request-input-context.ts +85 -0
  57. package/src/api/request-middleware.test.ts +47 -1
  58. package/src/api/request-middleware.ts +1 -1
  59. package/src/api/route.ts +7 -2
  60. package/src/client/client.test.ts +195 -0
  61. package/src/client/client.ts +185 -10
  62. package/src/client/vue.test.ts +253 -3
  63. package/src/client/vue.ts +44 -1
  64. package/src/internal/trace-context.ts +35 -0
  65. package/src/mod-client.ts +51 -7
  66. package/src/mod.ts +6 -1
  67. package/src/runtime.ts +48 -0
  68. package/src/test/test.ts +13 -4
  69. package/tsdown.config.ts +1 -0
@@ -27,10 +27,73 @@ import type { FragnoPublicConfig } from "./shared-types";
27
27
  import { RequestContextStorage } from "./request-context-storage";
28
28
  import { bindServicesToContext, type BoundServices } from "./bind-services";
29
29
  import { instantiatedFragmentFakeSymbol } from "../internal/symbols";
30
+ import { recordTraceEvent } from "../internal/trace-context";
30
31
 
31
32
  // Re-export types needed by consumers
32
33
  export type { BoundServices };
33
34
 
35
+ type InternalRoutePrefix = "/_internal";
36
+
37
+ type JoinInternalRoutePath<TPath extends string> = TPath extends "" | "/"
38
+ ? InternalRoutePrefix
39
+ : TPath extends `/${string}`
40
+ ? `${InternalRoutePrefix}${TPath}`
41
+ : `${InternalRoutePrefix}/${TPath}`;
42
+
43
+ type PrefixInternalRoute<TRoute> =
44
+ TRoute extends FragnoRouteConfig<
45
+ infer TMethod,
46
+ infer TPath,
47
+ infer TInputSchema,
48
+ infer TOutputSchema,
49
+ infer TErrorCode,
50
+ infer TQueryParameters,
51
+ infer TThisContext
52
+ >
53
+ ? FragnoRouteConfig<
54
+ TMethod,
55
+ JoinInternalRoutePath<TPath>,
56
+ TInputSchema,
57
+ TOutputSchema,
58
+ TErrorCode,
59
+ TQueryParameters,
60
+ TThisContext
61
+ >
62
+ : never;
63
+
64
+ type PrefixInternalRoutes<TRoutes extends readonly AnyFragnoRouteConfig[]> =
65
+ TRoutes extends readonly [...infer TRoutesTuple]
66
+ ? { [K in keyof TRoutesTuple]: PrefixInternalRoute<TRoutesTuple[K]> }
67
+ : readonly AnyFragnoRouteConfig[];
68
+
69
+ type ExtractRoutesFromFragment<T> =
70
+ T extends FragnoInstantiatedFragment<
71
+ infer TRoutes,
72
+ infer _TDeps,
73
+ infer _TServices,
74
+ infer _TServiceThisContext,
75
+ infer _THandlerThisContext,
76
+ infer _TRequestStorage,
77
+ infer _TOptions,
78
+ infer _TLinkedFragments
79
+ >
80
+ ? TRoutes
81
+ : never;
82
+
83
+ type InternalLinkedRoutes<TLinkedFragments> =
84
+ TLinkedFragments extends Record<string, AnyFragnoInstantiatedFragment>
85
+ ? TLinkedFragments extends { _fragno_internal: infer TInternal }
86
+ ? ExtractRoutesFromFragment<TInternal> extends readonly AnyFragnoRouteConfig[]
87
+ ? PrefixInternalRoutes<ExtractRoutesFromFragment<TInternal>>
88
+ : readonly []
89
+ : readonly []
90
+ : readonly [];
91
+
92
+ export type RoutesWithInternal<
93
+ TRoutes extends readonly AnyFragnoRouteConfig[],
94
+ TLinkedFragments,
95
+ > = readonly [...TRoutes, ...InternalLinkedRoutes<TLinkedFragments>];
96
+
34
97
  /**
35
98
  * Helper type to extract the instantiated fragment type from a fragment definition.
36
99
  * This is useful for typing functions that accept instantiated fragments based on their definition.
@@ -59,7 +122,7 @@ export type InstantiatedFragmentFromDefinition<
59
122
  infer TLinkedFragments
60
123
  >
61
124
  ? FragnoInstantiatedFragment<
62
- readonly AnyFragnoRouteConfig[], // Routes are dynamic, so we use a generic array
125
+ RoutesWithInternal<readonly AnyFragnoRouteConfig[], TLinkedFragments>,
63
126
  TDeps,
64
127
  BoundServices<TBaseServices & TServices>,
65
128
  TServiceThisContext,
@@ -79,6 +142,34 @@ type ReactRouterHandlers = {
79
142
  action: (args: { request: Request }) => Promise<Response>;
80
143
  };
81
144
 
145
+ const serializeHeadersForTrace = (headers: Headers): [string, string][] =>
146
+ Array.from(headers.entries()).sort(([a], [b]) => a.localeCompare(b));
147
+
148
+ const serializeQueryForTrace = (query: URLSearchParams): [string, string][] =>
149
+ Array.from(query.entries()).sort(([a], [b]) => a.localeCompare(b));
150
+
151
+ const serializeBodyForTrace = (body: RequestBodyType): unknown => {
152
+ if (body instanceof FormData) {
153
+ const entries = Array.from(body.entries()).map(([key, value]) => {
154
+ if (value instanceof Blob) {
155
+ return [key, { type: "blob", size: value.size, mime: value.type }] as const;
156
+ }
157
+ return [key, value] as const;
158
+ });
159
+ return { type: "form-data", entries };
160
+ }
161
+
162
+ if (body instanceof Blob) {
163
+ return { type: "blob", size: body.size, mime: body.type };
164
+ }
165
+
166
+ if (body instanceof ReadableStream) {
167
+ return { type: "stream" };
168
+ }
169
+
170
+ return body;
171
+ };
172
+
82
173
  type SolidStartHandlers = {
83
174
  GET: (args: { request: Request }) => Promise<Response>;
84
175
  POST: (args: { request: Request }) => Promise<Response>;
@@ -131,6 +222,66 @@ export type AnyFragnoInstantiatedFragment = FragnoInstantiatedFragment<
131
222
  any
132
223
  >;
133
224
 
225
+ const INTERNAL_LINKED_FRAGMENT_NAME = "_fragno_internal";
226
+ const INTERNAL_ROUTE_PREFIX = "/_internal";
227
+
228
+ type InternalLinkedRouteMeta = {
229
+ fragment: AnyFragnoInstantiatedFragment;
230
+ originalPath: string;
231
+ routes: readonly AnyFragnoRouteConfig[];
232
+ };
233
+
234
+ type InternalLinkedRouteConfig = AnyFragnoRouteConfig & {
235
+ __internal?: InternalLinkedRouteMeta;
236
+ };
237
+
238
+ function normalizeRoutePrefix(prefix: string): string {
239
+ if (!prefix.startsWith("/")) {
240
+ prefix = `/${prefix}`;
241
+ }
242
+ return prefix.endsWith("/") && prefix.length > 1 ? prefix.slice(0, -1) : prefix;
243
+ }
244
+
245
+ function joinRoutePath(prefix: string, path: string): string {
246
+ const normalizedPrefix = normalizeRoutePrefix(prefix);
247
+ if (!path || path === "/") {
248
+ return normalizedPrefix;
249
+ }
250
+ const normalizedPath = path.startsWith("/") ? path : `/${path}`;
251
+ return `${normalizedPrefix}${normalizedPath}`;
252
+ }
253
+
254
+ function collectLinkedFragmentRoutes(
255
+ linkedFragments: Record<string, AnyFragnoInstantiatedFragment>,
256
+ ): InternalLinkedRouteConfig[] {
257
+ const linkedRoutes: InternalLinkedRouteConfig[] = [];
258
+
259
+ for (const [name, fragment] of Object.entries(linkedFragments)) {
260
+ if (name !== INTERNAL_LINKED_FRAGMENT_NAME) {
261
+ continue;
262
+ }
263
+
264
+ const internalRoutes = (fragment.routes ?? []) as readonly AnyFragnoRouteConfig[];
265
+ if (internalRoutes.length === 0) {
266
+ continue;
267
+ }
268
+
269
+ for (const route of internalRoutes) {
270
+ linkedRoutes.push({
271
+ ...route,
272
+ path: joinRoutePath(INTERNAL_ROUTE_PREFIX, route.path),
273
+ __internal: {
274
+ fragment,
275
+ originalPath: route.path,
276
+ routes: internalRoutes,
277
+ },
278
+ });
279
+ }
280
+ }
281
+
282
+ return linkedRoutes;
283
+ }
284
+
134
285
  export interface FragnoFragmentSharedConfig<
135
286
  TRoutes extends readonly FragnoRouteConfig<
136
287
  HTTPMethod,
@@ -176,6 +327,7 @@ export class FragnoInstantiatedFragment<
176
327
  #createRequestStorage?: () => TRequestStorage;
177
328
  #options: TOptions;
178
329
  #linkedFragments: TLinkedFragments;
330
+ #internalData: Record<string, unknown>;
179
331
 
180
332
  constructor(params: {
181
333
  name: string;
@@ -189,6 +341,7 @@ export class FragnoInstantiatedFragment<
189
341
  createRequestStorage?: () => TRequestStorage;
190
342
  options: TOptions;
191
343
  linkedFragments?: TLinkedFragments;
344
+ internalData?: Record<string, unknown>;
192
345
  }) {
193
346
  this.#name = params.name;
194
347
  this.#routes = params.routes;
@@ -201,6 +354,7 @@ export class FragnoInstantiatedFragment<
201
354
  this.#createRequestStorage = params.createRequestStorage;
202
355
  this.#options = params.options;
203
356
  this.#linkedFragments = params.linkedFragments ?? ({} as TLinkedFragments);
357
+ this.#internalData = params.internalData ?? {};
204
358
 
205
359
  // Build router
206
360
  this.#router =
@@ -249,6 +403,7 @@ export class FragnoInstantiatedFragment<
249
403
  deps: this.#deps,
250
404
  options: this.#options,
251
405
  linkedFragments: this.#linkedFragments,
406
+ ...this.#internalData,
252
407
  };
253
408
  }
254
409
 
@@ -409,31 +564,90 @@ export class FragnoInstantiatedFragment<
409
564
  );
410
565
  }
411
566
 
412
- // Parse request body
567
+ // Get the expected content type from route config (default: application/json)
568
+ const routeConfig = route.data as InternalLinkedRouteConfig;
569
+ const expectedContentType = routeConfig.contentType ?? "application/json";
570
+
571
+ // Parse request body based on route's expected content type
413
572
  let requestBody: RequestBodyType = undefined;
414
573
  let rawBody: string | undefined = undefined;
415
574
 
416
575
  if (req.body instanceof ReadableStream) {
417
- // Clone request to make sure we don't consume body stream
418
- const clonedReq = req.clone();
419
-
420
- // Get raw text
421
- rawBody = await clonedReq.text();
576
+ const requestContentType = (req.headers.get("content-type") ?? "").toLowerCase();
577
+
578
+ if (expectedContentType === "multipart/form-data") {
579
+ // Route expects FormData (file uploads)
580
+ if (!requestContentType.includes("multipart/form-data")) {
581
+ return Response.json(
582
+ {
583
+ error: `This endpoint expects multipart/form-data, but received: ${requestContentType || "no content-type"}`,
584
+ code: "UNSUPPORTED_MEDIA_TYPE",
585
+ },
586
+ { status: 415 },
587
+ );
588
+ }
422
589
 
423
- // Parse JSON if body is not empty
424
- if (rawBody) {
425
590
  try {
426
- requestBody = JSON.parse(rawBody);
591
+ requestBody = await req.formData();
427
592
  } catch {
428
- // If JSON parsing fails, keep body as undefined
429
- // This handles cases where body is not JSON
430
- requestBody = undefined;
593
+ return Response.json(
594
+ { error: "Failed to parse multipart form data", code: "INVALID_REQUEST_BODY" },
595
+ { status: 400 },
596
+ );
597
+ }
598
+ } else if (expectedContentType === "application/octet-stream") {
599
+ if (!requestContentType.includes("application/octet-stream")) {
600
+ return Response.json(
601
+ {
602
+ error: `This endpoint expects application/octet-stream, but received: ${requestContentType || "no content-type"}`,
603
+ code: "UNSUPPORTED_MEDIA_TYPE",
604
+ },
605
+ { status: 415 },
606
+ );
607
+ }
608
+
609
+ requestBody = req.body ?? new ReadableStream<Uint8Array>();
610
+ } else {
611
+ // Route expects JSON (default)
612
+ // Note: We're lenient here - we accept requests without Content-Type header
613
+ // or with application/json. We reject multipart/form-data for JSON routes.
614
+ if (requestContentType.includes("multipart/form-data")) {
615
+ return Response.json(
616
+ {
617
+ error: `This endpoint expects JSON, but received multipart/form-data. Use a route with contentType: "multipart/form-data" for file uploads.`,
618
+ code: "UNSUPPORTED_MEDIA_TYPE",
619
+ },
620
+ { status: 415 },
621
+ );
622
+ }
623
+
624
+ // Clone request to make sure we don't consume body stream
625
+ const clonedReq = req.clone();
626
+
627
+ // Get raw text
628
+ rawBody = await clonedReq.text();
629
+
630
+ // Parse JSON if body is not empty
631
+ if (rawBody) {
632
+ try {
633
+ requestBody = JSON.parse(rawBody);
634
+ } catch {
635
+ // If JSON parsing fails, keep body as undefined
636
+ // This handles cases where body is not JSON
637
+ requestBody = undefined;
638
+ }
431
639
  }
432
640
  }
433
641
  }
434
642
 
643
+ // URL decode path params from rou3 (which returns encoded values)
644
+ const decodedRouteParams: Record<string, string> = {};
645
+ for (const [key, value] of Object.entries(route.params ?? {})) {
646
+ decodedRouteParams[key] = decodeURIComponent(value);
647
+ }
648
+
435
649
  const requestState = new MutableRequestState({
436
- pathParams: route.params ?? {},
650
+ pathParams: decodedRouteParams,
437
651
  searchParams: url.searchParams,
438
652
  body: requestBody,
439
653
  headers: new Headers(req.headers),
@@ -441,11 +655,27 @@ export class FragnoInstantiatedFragment<
441
655
 
442
656
  // Execute middleware and handler
443
657
  const executeRequest = async (): Promise<Response> => {
444
- // Middleware execution (if present)
445
- if (this.#middlewareHandler) {
446
- const middlewareResult = await this.#executeMiddleware(req, route, requestState);
447
- if (middlewareResult !== undefined) {
448
- return middlewareResult;
658
+ // Parent middleware execution
659
+ const middlewareResult = await this.#executeMiddleware(req, route, requestState);
660
+ if (middlewareResult !== undefined) {
661
+ return middlewareResult;
662
+ }
663
+
664
+ // Internal fragment middleware execution (if linked)
665
+ const internalMeta = routeConfig.__internal;
666
+ if (internalMeta) {
667
+ const internalResult = await FragnoInstantiatedFragment.#runMiddlewareForFragment(
668
+ internalMeta.fragment as AnyFragnoInstantiatedFragment,
669
+ {
670
+ req,
671
+ method: routeConfig.method,
672
+ path: internalMeta.originalPath,
673
+ requestState,
674
+ routes: internalMeta.routes,
675
+ },
676
+ );
677
+ if (internalResult !== undefined) {
678
+ return internalResult;
449
679
  }
450
680
  }
451
681
 
@@ -527,6 +757,16 @@ export class FragnoInstantiatedFragment<
527
757
  shouldValidateInput: true, // Enable validation for production use
528
758
  });
529
759
 
760
+ recordTraceEvent({
761
+ type: "route-input",
762
+ method: route.method,
763
+ path: route.path,
764
+ pathParams: (pathParams ?? {}) as Record<string, string>,
765
+ queryParams: serializeQueryForTrace(searchParams),
766
+ headers: serializeHeadersForTrace(requestHeaders),
767
+ body: serializeBodyForTrace(body),
768
+ });
769
+
530
770
  // Construct RequestOutputContext
531
771
  const outputContext = new RequestOutputContext(route.outputSchema);
532
772
 
@@ -563,32 +803,73 @@ export class FragnoInstantiatedFragment<
563
803
  route: ReturnType<typeof findRoute>,
564
804
  requestState: MutableRequestState,
565
805
  ): Promise<Response | undefined> {
566
- if (!this.#middlewareHandler || !route) {
806
+ if (!route) {
567
807
  return undefined;
568
808
  }
569
809
 
570
810
  const { path } = route.data as AnyFragnoRouteConfig;
571
-
572
- const middlewareInputContext = new RequestMiddlewareInputContext(this.#routes, {
811
+ return FragnoInstantiatedFragment.#runMiddlewareForFragment(this, {
812
+ req,
573
813
  method: req.method as HTTPMethod,
574
814
  path,
575
- request: req,
576
- state: requestState,
815
+ requestState,
577
816
  });
817
+ }
818
+
819
+ static async #runMiddlewareForFragment(
820
+ fragment: AnyFragnoInstantiatedFragment,
821
+ options: {
822
+ req: Request;
823
+ method: HTTPMethod;
824
+ path: string;
825
+ requestState: MutableRequestState;
826
+ routes?: readonly AnyFragnoRouteConfig[];
827
+ },
828
+ ): Promise<Response | undefined> {
829
+ if (!fragment.#middlewareHandler) {
830
+ return undefined;
831
+ }
832
+
833
+ const middlewareInputContext = new RequestMiddlewareInputContext(
834
+ (options.routes ?? fragment.#routes) as readonly AnyFragnoRouteConfig[],
835
+ {
836
+ method: options.method,
837
+ path: options.path,
838
+ request: options.req,
839
+ state: options.requestState,
840
+ },
841
+ );
578
842
 
579
- const middlewareOutputContext = new RequestMiddlewareOutputContext(this.#deps, this.#services);
843
+ const middlewareOutputContext = new RequestMiddlewareOutputContext(
844
+ fragment.#deps,
845
+ fragment.#services,
846
+ );
580
847
 
581
848
  try {
582
- const middlewareResult = await this.#middlewareHandler(
849
+ const middlewareResult = await fragment.#middlewareHandler(
583
850
  middlewareInputContext,
584
851
  middlewareOutputContext,
585
852
  );
853
+ recordTraceEvent({
854
+ type: "middleware-decision",
855
+ method: options.method,
856
+ path: options.path,
857
+ outcome: middlewareResult ? "deny" : "allow",
858
+ status: middlewareResult?.status,
859
+ });
586
860
  if (middlewareResult !== undefined) {
587
861
  return middlewareResult;
588
862
  }
589
863
  } catch (error) {
590
864
  console.error("Error in middleware", error);
591
865
 
866
+ recordTraceEvent({
867
+ type: "middleware-decision",
868
+ method: options.method,
869
+ path: options.path,
870
+ outcome: "deny",
871
+ status: error instanceof FragnoApiError ? error.status : 500,
872
+ });
592
873
  if (error instanceof FragnoApiError) {
593
874
  return error.toResponse();
594
875
  }
@@ -627,6 +908,16 @@ export class FragnoInstantiatedFragment<
627
908
  rawBody,
628
909
  });
629
910
 
911
+ recordTraceEvent({
912
+ type: "route-input",
913
+ method: req.method,
914
+ path,
915
+ pathParams: inputContext.pathParams as Record<string, string>,
916
+ queryParams: serializeQueryForTrace(requestState.searchParams),
917
+ headers: serializeHeadersForTrace(requestState.headers),
918
+ body: serializeBodyForTrace(requestState.body),
919
+ });
920
+
630
921
  const outputContext = new RequestOutputContext(outputSchema);
631
922
 
632
923
  try {
@@ -700,7 +991,7 @@ export function instantiateFragment<
700
991
  serviceImplementations?: TServiceDependencies,
701
992
  instantiationOptions?: InstantiationOptions,
702
993
  ): FragnoInstantiatedFragment<
703
- FlattenRouteFactories<TRoutesOrFactories>,
994
+ RoutesWithInternal<FlattenRouteFactories<TRoutesOrFactories>, TLinkedFragments>,
704
995
  TDeps,
705
996
  BoundServices<TBaseServices & TServices>,
706
997
  TServiceThisContext,
@@ -888,6 +1179,13 @@ export function instantiateFragment<
888
1179
 
889
1180
  const serviceContext = contexts?.serviceContext;
890
1181
  const handlerContext = contexts?.handlerContext;
1182
+ const internalData =
1183
+ definition.internalDataFactory?.({
1184
+ config,
1185
+ options,
1186
+ deps,
1187
+ linkedFragments: linkedFragmentInstances,
1188
+ }) ?? {};
891
1189
 
892
1190
  // 9. Bind services to serviceContext (restricted)
893
1191
  // Services get the restricted context (for database fragments, this excludes execute methods)
@@ -900,7 +1198,12 @@ export function instantiateFragment<
900
1198
  services: boundServices,
901
1199
  serviceDeps: serviceImplementations ?? ({} as TServiceDependencies),
902
1200
  };
903
- const routes = resolveRouteFactories(context, routesOrFactories);
1201
+ const routes = resolveRouteFactories(context, routesOrFactories) as AnyFragnoRouteConfig[];
1202
+ const linkedRoutes = collectLinkedFragmentRoutes(
1203
+ linkedFragmentInstances as Record<string, AnyFragnoInstantiatedFragment>,
1204
+ );
1205
+ const finalRoutes =
1206
+ linkedRoutes.length > 0 ? [...routes, ...linkedRoutes] : (routes as AnyFragnoRouteConfig[]);
904
1207
 
905
1208
  // 11. Calculate mount route
906
1209
  const mountRoute = getMountRoute({
@@ -918,7 +1221,10 @@ export function instantiateFragment<
918
1221
  // Handlers get handlerContext which may have more capabilities than serviceContext
919
1222
  return new FragnoInstantiatedFragment({
920
1223
  name: definition.name,
921
- routes,
1224
+ routes: finalRoutes as unknown as RoutesWithInternal<
1225
+ FlattenRouteFactories<TRoutesOrFactories>,
1226
+ TLinkedFragments
1227
+ >,
922
1228
  deps,
923
1229
  services: boundServices as BoundServices<TBaseServices & TServices>,
924
1230
  mountRoute,
@@ -928,6 +1234,7 @@ export function instantiateFragment<
928
1234
  createRequestStorage: createRequestStorageWithContext,
929
1235
  options,
930
1236
  linkedFragments: linkedFragmentInstances,
1237
+ internalData: internalData as Record<string, unknown>,
931
1238
  });
932
1239
  }
933
1240
 
@@ -1004,7 +1311,7 @@ interface IFragnoInstantiatedFragment {
1004
1311
  deps: unknown;
1005
1312
  options: unknown;
1006
1313
  linkedFragments: unknown;
1007
- };
1314
+ } & Record<string, unknown>;
1008
1315
 
1009
1316
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1010
1317
  withMiddleware(handler: any): this;
@@ -1178,7 +1485,7 @@ export class FragmentInstantiationBuilder<
1178
1485
  * Build and return the instantiated fragment
1179
1486
  */
1180
1487
  build(): FragnoInstantiatedFragment<
1181
- FlattenRouteFactories<TRoutesOrFactories>,
1488
+ RoutesWithInternal<FlattenRouteFactories<TRoutesOrFactories>, TLinkedFragments>,
1182
1489
  TDeps,
1183
1490
  BoundServices<TBaseServices & TServices>,
1184
1491
  TServiceThisContext,
@@ -77,6 +77,13 @@ describe("matchPathParams (runtime extraction)", () => {
77
77
  expect(matchPathParams("/users/:id", "/users/123/")).toEqual({ id: "123" });
78
78
  });
79
79
 
80
+ test("URL decodes named params", () => {
81
+ expect(matchPathParams("/users/:name", "/users/a%20b")).toEqual({ name: "a b" });
82
+ expect(matchPathParams("/files/:path", "/files/folder%2Fsubfolder")).toEqual({
83
+ path: "folder/subfolder",
84
+ });
85
+ });
86
+
80
87
  test("pattern longer than path fills empty strings for remaining params", () => {
81
88
  // Remaining ":id" becomes empty string
82
89
  expect(matchPathParams("/users/:id", "/users")).toEqual({ id: "" });