@fragno-dev/core 0.1.10 → 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 +139 -131
  2. package/CHANGELOG.md +63 -0
  3. package/dist/api/api.d.ts +23 -5
  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 +129 -32
  11. package/dist/api/fragment-instantiator.d.ts.map +1 -1
  12. package/dist/api/fragment-instantiator.js +232 -50
  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 +5 -27
  37. package/dist/mod-client.d.ts.map +1 -1
  38. package/dist/mod-client.js +50 -13
  39. package/dist/mod-client.js.map +1 -1
  40. package/dist/mod.d.ts +4 -3
  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 +31 -18
  50. package/src/api/api.ts +24 -0
  51. package/src/api/fragment-definition-builder.ts +36 -17
  52. package/src/api/fragment-instantiator.test.ts +429 -1
  53. package/src/api/fragment-instantiator.ts +572 -58
  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 +89 -9
  66. package/src/mod.ts +7 -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,112 @@ 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
+
97
+ /**
98
+ * Helper type to extract the instantiated fragment type from a fragment definition.
99
+ * This is useful for typing functions that accept instantiated fragments based on their definition.
100
+ *
101
+ * @example
102
+ * ```typescript
103
+ * const myFragmentDef = defineFragment("my-fragment").build();
104
+ * type MyInstantiatedFragment = InstantiatedFragmentFromDefinition<typeof myFragmentDef>;
105
+ * ```
106
+ */
107
+ export type InstantiatedFragmentFromDefinition<
108
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
109
+ TDef extends FragmentDefinition<any, any, any, any, any, any, any, any, any, any, any>,
110
+ > =
111
+ TDef extends FragmentDefinition<
112
+ infer _TConfig,
113
+ infer TOptions,
114
+ infer TDeps,
115
+ infer TBaseServices,
116
+ infer TServices,
117
+ infer _TServiceDependencies,
118
+ infer _TPrivateServices,
119
+ infer TServiceThisContext,
120
+ infer THandlerThisContext,
121
+ infer TRequestStorage,
122
+ infer TLinkedFragments
123
+ >
124
+ ? FragnoInstantiatedFragment<
125
+ RoutesWithInternal<readonly AnyFragnoRouteConfig[], TLinkedFragments>,
126
+ TDeps,
127
+ BoundServices<TBaseServices & TServices>,
128
+ TServiceThisContext,
129
+ THandlerThisContext,
130
+ TRequestStorage,
131
+ TOptions,
132
+ TLinkedFragments
133
+ >
134
+ : never;
135
+
34
136
  type AstroHandlers = {
35
137
  ALL: (req: Request) => Promise<Response>;
36
138
  };
@@ -40,6 +142,34 @@ type ReactRouterHandlers = {
40
142
  action: (args: { request: Request }) => Promise<Response>;
41
143
  };
42
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
+
43
173
  type SolidStartHandlers = {
44
174
  GET: (args: { request: Request }) => Promise<Response>;
45
175
  POST: (args: { request: Request }) => Promise<Response>;
@@ -73,8 +203,7 @@ type HandlersByFramework = {
73
203
 
74
204
  type FullstackFrameworks = keyof HandlersByFramework;
75
205
 
76
- // Safe: This is a catch-all type for any instantiated fragment
77
- type AnyFragnoInstantiatedFragment = FragnoInstantiatedFragment<
206
+ export type AnyFragnoInstantiatedFragment = FragnoInstantiatedFragment<
78
207
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
79
208
  any,
80
209
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -93,7 +222,65 @@ type AnyFragnoInstantiatedFragment = FragnoInstantiatedFragment<
93
222
  any
94
223
  >;
95
224
 
96
- export type { AnyFragnoInstantiatedFragment };
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
+ }
97
284
 
98
285
  export interface FragnoFragmentSharedConfig<
99
286
  TRoutes extends readonly FragnoRouteConfig<
@@ -122,7 +309,8 @@ export class FragnoInstantiatedFragment<
122
309
  TRequestStorage = {},
123
310
  TOptions extends FragnoPublicConfig = FragnoPublicConfig,
124
311
  TLinkedFragments extends Record<string, AnyFragnoInstantiatedFragment> = {},
125
- > {
312
+ > implements IFragnoInstantiatedFragment
313
+ {
126
314
  readonly [instantiatedFragmentFakeSymbol] = instantiatedFragmentFakeSymbol;
127
315
 
128
316
  // Private fields
@@ -139,6 +327,7 @@ export class FragnoInstantiatedFragment<
139
327
  #createRequestStorage?: () => TRequestStorage;
140
328
  #options: TOptions;
141
329
  #linkedFragments: TLinkedFragments;
330
+ #internalData: Record<string, unknown>;
142
331
 
143
332
  constructor(params: {
144
333
  name: string;
@@ -152,6 +341,7 @@ export class FragnoInstantiatedFragment<
152
341
  createRequestStorage?: () => TRequestStorage;
153
342
  options: TOptions;
154
343
  linkedFragments?: TLinkedFragments;
344
+ internalData?: Record<string, unknown>;
155
345
  }) {
156
346
  this.#name = params.name;
157
347
  this.#routes = params.routes;
@@ -164,6 +354,7 @@ export class FragnoInstantiatedFragment<
164
354
  this.#createRequestStorage = params.createRequestStorage;
165
355
  this.#options = params.options;
166
356
  this.#linkedFragments = params.linkedFragments ?? ({} as TLinkedFragments);
357
+ this.#internalData = params.internalData ?? {};
167
358
 
168
359
  // Build router
169
360
  this.#router =
@@ -212,6 +403,7 @@ export class FragnoInstantiatedFragment<
212
403
  deps: this.#deps,
213
404
  options: this.#options,
214
405
  linkedFragments: this.#linkedFragments,
406
+ ...this.#internalData,
215
407
  };
216
408
  }
217
409
 
@@ -372,31 +564,90 @@ export class FragnoInstantiatedFragment<
372
564
  );
373
565
  }
374
566
 
375
- // 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
376
572
  let requestBody: RequestBodyType = undefined;
377
573
  let rawBody: string | undefined = undefined;
378
574
 
379
575
  if (req.body instanceof ReadableStream) {
380
- // Clone request to make sure we don't consume body stream
381
- const clonedReq = req.clone();
382
-
383
- // Get raw text
384
- 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
+ }
385
589
 
386
- // Parse JSON if body is not empty
387
- if (rawBody) {
388
590
  try {
389
- requestBody = JSON.parse(rawBody);
591
+ requestBody = await req.formData();
390
592
  } catch {
391
- // If JSON parsing fails, keep body as undefined
392
- // This handles cases where body is not JSON
393
- 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
+ }
394
639
  }
395
640
  }
396
641
  }
397
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
+
398
649
  const requestState = new MutableRequestState({
399
- pathParams: route.params ?? {},
650
+ pathParams: decodedRouteParams,
400
651
  searchParams: url.searchParams,
401
652
  body: requestBody,
402
653
  headers: new Headers(req.headers),
@@ -404,11 +655,27 @@ export class FragnoInstantiatedFragment<
404
655
 
405
656
  // Execute middleware and handler
406
657
  const executeRequest = async (): Promise<Response> => {
407
- // Middleware execution (if present)
408
- if (this.#middlewareHandler) {
409
- const middlewareResult = await this.#executeMiddleware(req, route, requestState);
410
- if (middlewareResult !== undefined) {
411
- 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;
412
679
  }
413
680
  }
414
681
 
@@ -475,7 +742,6 @@ export class FragnoInstantiatedFragment<
475
742
  ? new URLSearchParams(query)
476
743
  : new URLSearchParams();
477
744
 
478
- // Convert headers to Headers if needed
479
745
  const requestHeaders =
480
746
  headers instanceof Headers ? headers : headers ? new Headers(headers) : new Headers();
481
747
 
@@ -491,6 +757,16 @@ export class FragnoInstantiatedFragment<
491
757
  shouldValidateInput: true, // Enable validation for production use
492
758
  });
493
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
+
494
770
  // Construct RequestOutputContext
495
771
  const outputContext = new RequestOutputContext(route.outputSchema);
496
772
 
@@ -527,32 +803,73 @@ export class FragnoInstantiatedFragment<
527
803
  route: ReturnType<typeof findRoute>,
528
804
  requestState: MutableRequestState,
529
805
  ): Promise<Response | undefined> {
530
- if (!this.#middlewareHandler || !route) {
806
+ if (!route) {
531
807
  return undefined;
532
808
  }
533
809
 
534
810
  const { path } = route.data as AnyFragnoRouteConfig;
535
-
536
- const middlewareInputContext = new RequestMiddlewareInputContext(this.#routes, {
811
+ return FragnoInstantiatedFragment.#runMiddlewareForFragment(this, {
812
+ req,
537
813
  method: req.method as HTTPMethod,
538
814
  path,
539
- request: req,
540
- state: requestState,
815
+ requestState,
541
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
+ }
542
832
 
543
- const middlewareOutputContext = new RequestMiddlewareOutputContext(this.#deps, this.#services);
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
+ );
842
+
843
+ const middlewareOutputContext = new RequestMiddlewareOutputContext(
844
+ fragment.#deps,
845
+ fragment.#services,
846
+ );
544
847
 
545
848
  try {
546
- const middlewareResult = await this.#middlewareHandler(
849
+ const middlewareResult = await fragment.#middlewareHandler(
547
850
  middlewareInputContext,
548
851
  middlewareOutputContext,
549
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
+ });
550
860
  if (middlewareResult !== undefined) {
551
861
  return middlewareResult;
552
862
  }
553
863
  } catch (error) {
554
864
  console.error("Error in middleware", error);
555
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
+ });
556
873
  if (error instanceof FragnoApiError) {
557
874
  return error.toResponse();
558
875
  }
@@ -591,6 +908,16 @@ export class FragnoInstantiatedFragment<
591
908
  rawBody,
592
909
  });
593
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
+
594
921
  const outputContext = new RequestOutputContext(outputSchema);
595
922
 
596
923
  try {
@@ -615,6 +942,18 @@ export class FragnoInstantiatedFragment<
615
942
  }
616
943
  }
617
944
 
945
+ /**
946
+ * Options for fragment instantiation.
947
+ */
948
+ export interface InstantiationOptions {
949
+ /**
950
+ * If true, catches errors during initialization and returns stub implementations.
951
+ * This is useful for CLI tools that need to extract metadata (like database schemas)
952
+ * without requiring all dependencies to be fully initialized.
953
+ */
954
+ dryRun?: boolean;
955
+ }
956
+
618
957
  /**
619
958
  * Core instantiation function that creates a fragment instance from a definition.
620
959
  * This function validates dependencies, calls all callbacks, and wires everything together.
@@ -650,8 +989,9 @@ export function instantiateFragment<
650
989
  routesOrFactories: TRoutesOrFactories,
651
990
  options: TOptions,
652
991
  serviceImplementations?: TServiceDependencies,
992
+ instantiationOptions?: InstantiationOptions,
653
993
  ): FragnoInstantiatedFragment<
654
- FlattenRouteFactories<TRoutesOrFactories>,
994
+ RoutesWithInternal<FlattenRouteFactories<TRoutesOrFactories>, TLinkedFragments>,
655
995
  TDeps,
656
996
  BoundServices<TBaseServices & TServices>,
657
997
  TServiceThisContext,
@@ -660,6 +1000,8 @@ export function instantiateFragment<
660
1000
  TOptions,
661
1001
  TLinkedFragments
662
1002
  > {
1003
+ const { dryRun = false } = instantiationOptions ?? {};
1004
+
663
1005
  // 1. Validate service dependencies
664
1006
  const serviceDependencies = definition.serviceDependencies;
665
1007
  if (serviceDependencies) {
@@ -675,7 +1017,21 @@ export function instantiateFragment<
675
1017
  }
676
1018
 
677
1019
  // 2. Call dependencies callback
678
- const deps = definition.dependencies?.({ config, options }) ?? ({} as TDeps);
1020
+ let deps: TDeps;
1021
+ try {
1022
+ deps = definition.dependencies?.({ config, options }) ?? ({} as TDeps);
1023
+ } catch (error) {
1024
+ if (dryRun) {
1025
+ console.warn(
1026
+ "Warning: Failed to initialize dependencies in dry run mode:",
1027
+ error instanceof Error ? error.message : String(error),
1028
+ );
1029
+ // Return empty deps - database fragments will add implicit deps later
1030
+ deps = {} as TDeps;
1031
+ } else {
1032
+ throw error;
1033
+ }
1034
+ }
679
1035
 
680
1036
  // 3. Instantiate linked fragments FIRST (before any services)
681
1037
  // Their services will be merged into private services
@@ -717,28 +1073,54 @@ export function instantiateFragment<
717
1073
  privateServices: TPrivateServices;
718
1074
  defineService: <T>(svc: T & ThisType<TServiceThisContext>) => T;
719
1075
  }) => unknown;
720
- (privateServices as Record<string, unknown>)[serviceName] = serviceFactory({
1076
+
1077
+ try {
1078
+ (privateServices as Record<string, unknown>)[serviceName] = serviceFactory({
1079
+ config,
1080
+ options,
1081
+ deps,
1082
+ serviceDeps: (serviceImplementations ?? {}) as TServiceDependencies,
1083
+ privateServices, // Pass the current state of private services (earlier ones are available)
1084
+ defineService,
1085
+ });
1086
+ } catch (error) {
1087
+ if (dryRun) {
1088
+ console.warn(
1089
+ `Warning: Failed to initialize private service '${serviceName}' in dry run mode:`,
1090
+ error instanceof Error ? error.message : String(error),
1091
+ );
1092
+ (privateServices as Record<string, unknown>)[serviceName] = {};
1093
+ } else {
1094
+ throw error;
1095
+ }
1096
+ }
1097
+ }
1098
+ }
1099
+
1100
+ // 5. Call baseServices callback (with access to private services including linked fragment services)
1101
+ let baseServices: TBaseServices;
1102
+ try {
1103
+ baseServices =
1104
+ definition.baseServices?.({
721
1105
  config,
722
1106
  options,
723
1107
  deps,
724
1108
  serviceDeps: (serviceImplementations ?? {}) as TServiceDependencies,
725
- privateServices, // Pass the current state of private services (earlier ones are available)
1109
+ privateServices,
726
1110
  defineService,
727
- });
1111
+ }) ?? ({} as TBaseServices);
1112
+ } catch (error) {
1113
+ if (dryRun) {
1114
+ console.warn(
1115
+ "Warning: Failed to initialize base services in dry run mode:",
1116
+ error instanceof Error ? error.message : String(error),
1117
+ );
1118
+ baseServices = {} as TBaseServices;
1119
+ } else {
1120
+ throw error;
728
1121
  }
729
1122
  }
730
1123
 
731
- // 5. Call baseServices callback (with access to private services including linked fragment services)
732
- const baseServices =
733
- definition.baseServices?.({
734
- config,
735
- options,
736
- deps,
737
- serviceDeps: (serviceImplementations ?? {}) as TServiceDependencies,
738
- privateServices,
739
- defineService,
740
- }) ?? ({} as TBaseServices);
741
-
742
1124
  // 6. Call namedServices factories (with access to private services including linked fragment services)
743
1125
  const namedServices = {} as TServices;
744
1126
  if (definition.namedServices) {
@@ -751,14 +1133,27 @@ export function instantiateFragment<
751
1133
  privateServices: TPrivateServices;
752
1134
  defineService: <T>(svc: T & ThisType<TServiceThisContext>) => T;
753
1135
  }) => unknown;
754
- (namedServices as Record<string, unknown>)[serviceName] = serviceFactory({
755
- config,
756
- options,
757
- deps,
758
- serviceDeps: (serviceImplementations ?? {}) as TServiceDependencies,
759
- privateServices,
760
- defineService,
761
- });
1136
+
1137
+ try {
1138
+ (namedServices as Record<string, unknown>)[serviceName] = serviceFactory({
1139
+ config,
1140
+ options,
1141
+ deps,
1142
+ serviceDeps: (serviceImplementations ?? {}) as TServiceDependencies,
1143
+ privateServices,
1144
+ defineService,
1145
+ });
1146
+ } catch (error) {
1147
+ if (dryRun) {
1148
+ console.warn(
1149
+ `Warning: Failed to initialize service '${serviceName}' in dry run mode:`,
1150
+ error instanceof Error ? error.message : String(error),
1151
+ );
1152
+ (namedServices as Record<string, unknown>)[serviceName] = {};
1153
+ } else {
1154
+ throw error;
1155
+ }
1156
+ }
762
1157
  }
763
1158
  }
764
1159
 
@@ -784,6 +1179,13 @@ export function instantiateFragment<
784
1179
 
785
1180
  const serviceContext = contexts?.serviceContext;
786
1181
  const handlerContext = contexts?.handlerContext;
1182
+ const internalData =
1183
+ definition.internalDataFactory?.({
1184
+ config,
1185
+ options,
1186
+ deps,
1187
+ linkedFragments: linkedFragmentInstances,
1188
+ }) ?? {};
787
1189
 
788
1190
  // 9. Bind services to serviceContext (restricted)
789
1191
  // Services get the restricted context (for database fragments, this excludes execute methods)
@@ -796,7 +1198,12 @@ export function instantiateFragment<
796
1198
  services: boundServices,
797
1199
  serviceDeps: serviceImplementations ?? ({} as TServiceDependencies),
798
1200
  };
799
- 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[]);
800
1207
 
801
1208
  // 11. Calculate mount route
802
1209
  const mountRoute = getMountRoute({
@@ -814,7 +1221,10 @@ export function instantiateFragment<
814
1221
  // Handlers get handlerContext which may have more capabilities than serviceContext
815
1222
  return new FragnoInstantiatedFragment({
816
1223
  name: definition.name,
817
- routes,
1224
+ routes: finalRoutes as unknown as RoutesWithInternal<
1225
+ FlattenRouteFactories<TRoutesOrFactories>,
1226
+ TLinkedFragments
1227
+ >,
818
1228
  deps,
819
1229
  services: boundServices as BoundServices<TBaseServices & TServices>,
820
1230
  mountRoute,
@@ -824,9 +1234,105 @@ export function instantiateFragment<
824
1234
  createRequestStorage: createRequestStorageWithContext,
825
1235
  options,
826
1236
  linkedFragments: linkedFragmentInstances,
1237
+ internalData: internalData as Record<string, unknown>,
827
1238
  });
828
1239
  }
829
1240
 
1241
+ /**
1242
+ * Interface that defines the public API for a fragment instantiation builder.
1243
+ * Used to ensure consistency between real implementations and stubs.
1244
+ */
1245
+ interface IFragmentInstantiationBuilder {
1246
+ /**
1247
+ * Get the fragment definition
1248
+ */
1249
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1250
+ get definition(): any;
1251
+
1252
+ /**
1253
+ * Get the configured routes
1254
+ */
1255
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1256
+ get routes(): any;
1257
+
1258
+ /**
1259
+ * Get the configuration
1260
+ */
1261
+ get config(): unknown;
1262
+
1263
+ /**
1264
+ * Get the options
1265
+ */
1266
+ get options(): unknown;
1267
+
1268
+ /**
1269
+ * Set the configuration for the fragment
1270
+ */
1271
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1272
+ withConfig(config: any): unknown;
1273
+
1274
+ /**
1275
+ * Set the routes for the fragment
1276
+ */
1277
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1278
+ withRoutes(routes: any): unknown;
1279
+
1280
+ /**
1281
+ * Set the options for the fragment (e.g., mountRoute, databaseAdapter)
1282
+ */
1283
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1284
+ withOptions(options: any): unknown;
1285
+
1286
+ /**
1287
+ * Provide implementations for services that this fragment uses
1288
+ */
1289
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1290
+ withServices(services: any): unknown;
1291
+
1292
+ /**
1293
+ * Build and return the instantiated fragment
1294
+ */
1295
+ build(): IFragnoInstantiatedFragment;
1296
+ }
1297
+
1298
+ /**
1299
+ * Interface that defines the public API for an instantiated fragment.
1300
+ * Used to ensure consistency between real implementations and stubs.
1301
+ */
1302
+ interface IFragnoInstantiatedFragment {
1303
+ readonly [instantiatedFragmentFakeSymbol]: typeof instantiatedFragmentFakeSymbol;
1304
+
1305
+ get name(): string;
1306
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1307
+ get routes(): any;
1308
+ get services(): Record<string, unknown>;
1309
+ get mountRoute(): string;
1310
+ get $internal(): {
1311
+ deps: unknown;
1312
+ options: unknown;
1313
+ linkedFragments: unknown;
1314
+ } & Record<string, unknown>;
1315
+
1316
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1317
+ withMiddleware(handler: any): this;
1318
+
1319
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1320
+ inContext<T>(callback: any): T;
1321
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1322
+ inContext<T>(callback: any): Promise<T>;
1323
+
1324
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1325
+ handlersFor(framework: FullstackFrameworks): any;
1326
+
1327
+ handler(req: Request): Promise<Response>;
1328
+
1329
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1330
+ callRoute(method: HTTPMethod, path: string, inputOptions?: any): Promise<any>;
1331
+
1332
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1333
+ callRouteRaw(method: HTTPMethod, path: string, inputOptions?: any): Promise<Response>;
1334
+ }
1335
+
830
1336
  /**
831
1337
  * Fluent builder for instantiating fragments.
832
1338
  * Provides a type-safe API for configuring and building fragment instances.
@@ -844,7 +1350,8 @@ export class FragmentInstantiationBuilder<
844
1350
  TRequestStorage,
845
1351
  TRoutesOrFactories extends readonly AnyRouteOrFactory[],
846
1352
  TLinkedFragments extends Record<string, AnyFragnoInstantiatedFragment>,
847
- > {
1353
+ > implements IFragmentInstantiationBuilder
1354
+ {
848
1355
  #definition: FragmentDefinition<
849
1356
  TConfig,
850
1357
  TOptions,
@@ -978,7 +1485,7 @@ export class FragmentInstantiationBuilder<
978
1485
  * Build and return the instantiated fragment
979
1486
  */
980
1487
  build(): FragnoInstantiatedFragment<
981
- FlattenRouteFactories<TRoutesOrFactories>,
1488
+ RoutesWithInternal<FlattenRouteFactories<TRoutesOrFactories>, TLinkedFragments>,
982
1489
  TDeps,
983
1490
  BoundServices<TBaseServices & TServices>,
984
1491
  TServiceThisContext,
@@ -987,12 +1494,16 @@ export class FragmentInstantiationBuilder<
987
1494
  TOptions,
988
1495
  TLinkedFragments
989
1496
  > {
1497
+ // This variable is set by the frango-cli when extracting database schemas
1498
+ const dryRun = process.env["FRAGNO_INIT_DRY_RUN"] === "true";
1499
+
990
1500
  return instantiateFragment(
991
1501
  this.#definition,
992
1502
  this.#config ?? ({} as TConfig),
993
1503
  this.#routes ?? ([] as const as unknown as TRoutesOrFactories),
994
1504
  this.#options ?? ({} as TOptions),
995
1505
  this.#services,
1506
+ { dryRun },
996
1507
  );
997
1508
  }
998
1509
  }
@@ -1051,3 +1562,6 @@ export function instantiate<
1051
1562
  > {
1052
1563
  return new FragmentInstantiationBuilder(definition);
1053
1564
  }
1565
+
1566
+ // Export interfaces for stub implementations
1567
+ export type { IFragmentInstantiationBuilder, IFragnoInstantiatedFragment };