@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.
- package/.turbo/turbo-build.log +50 -42
- package/CHANGELOG.md +51 -0
- package/dist/api/api.d.ts +19 -1
- package/dist/api/api.d.ts.map +1 -1
- package/dist/api/api.js.map +1 -1
- package/dist/api/fragment-definition-builder.d.ts +17 -7
- package/dist/api/fragment-definition-builder.d.ts.map +1 -1
- package/dist/api/fragment-definition-builder.js +3 -2
- package/dist/api/fragment-definition-builder.js.map +1 -1
- package/dist/api/fragment-instantiator.d.ts +23 -16
- package/dist/api/fragment-instantiator.d.ts.map +1 -1
- package/dist/api/fragment-instantiator.js +163 -19
- package/dist/api/fragment-instantiator.js.map +1 -1
- package/dist/api/request-input-context.d.ts +57 -1
- package/dist/api/request-input-context.d.ts.map +1 -1
- package/dist/api/request-input-context.js +67 -0
- package/dist/api/request-input-context.js.map +1 -1
- package/dist/api/request-middleware.d.ts +1 -1
- package/dist/api/request-middleware.d.ts.map +1 -1
- package/dist/api/request-middleware.js.map +1 -1
- package/dist/api/route.d.ts +7 -7
- package/dist/api/route.d.ts.map +1 -1
- package/dist/api/route.js.map +1 -1
- package/dist/client/client.d.ts +4 -3
- package/dist/client/client.d.ts.map +1 -1
- package/dist/client/client.js +103 -7
- package/dist/client/client.js.map +1 -1
- package/dist/client/vue.d.ts +7 -3
- package/dist/client/vue.d.ts.map +1 -1
- package/dist/client/vue.js +16 -1
- package/dist/client/vue.js.map +1 -1
- package/dist/internal/trace-context.d.ts +23 -0
- package/dist/internal/trace-context.d.ts.map +1 -0
- package/dist/internal/trace-context.js +14 -0
- package/dist/internal/trace-context.js.map +1 -0
- package/dist/mod-client.d.ts +3 -17
- package/dist/mod-client.d.ts.map +1 -1
- package/dist/mod-client.js +20 -10
- package/dist/mod-client.js.map +1 -1
- package/dist/mod.d.ts +3 -2
- package/dist/mod.js +2 -1
- package/dist/runtime.d.ts +15 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +33 -0
- package/dist/runtime.js.map +1 -0
- package/dist/test/test.d.ts +2 -2
- package/dist/test/test.d.ts.map +1 -1
- package/dist/test/test.js.map +1 -1
- package/package.json +23 -17
- package/src/api/api.ts +22 -0
- package/src/api/fragment-definition-builder.ts +36 -17
- package/src/api/fragment-instantiator.test.ts +286 -0
- package/src/api/fragment-instantiator.ts +338 -31
- package/src/api/internal/path-runtime.test.ts +7 -0
- package/src/api/request-input-context.test.ts +152 -0
- package/src/api/request-input-context.ts +85 -0
- package/src/api/request-middleware.test.ts +47 -1
- package/src/api/request-middleware.ts +1 -1
- package/src/api/route.ts +7 -2
- package/src/client/client.test.ts +195 -0
- package/src/client/client.ts +185 -10
- package/src/client/vue.test.ts +253 -3
- package/src/client/vue.ts +44 -1
- package/src/internal/trace-context.ts +35 -0
- package/src/mod-client.ts +51 -7
- package/src/mod.ts +6 -1
- package/src/runtime.ts +48 -0
- package/src/test/test.ts +13 -4
- 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[],
|
|
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
|
-
//
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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 =
|
|
591
|
+
requestBody = await req.formData();
|
|
427
592
|
} catch {
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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:
|
|
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
|
-
//
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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 (!
|
|
806
|
+
if (!route) {
|
|
567
807
|
return undefined;
|
|
568
808
|
}
|
|
569
809
|
|
|
570
810
|
const { path } = route.data as AnyFragnoRouteConfig;
|
|
571
|
-
|
|
572
|
-
|
|
811
|
+
return FragnoInstantiatedFragment.#runMiddlewareForFragment(this, {
|
|
812
|
+
req,
|
|
573
813
|
method: req.method as HTTPMethod,
|
|
574
814
|
path,
|
|
575
|
-
|
|
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(
|
|
843
|
+
const middlewareOutputContext = new RequestMiddlewareOutputContext(
|
|
844
|
+
fragment.#deps,
|
|
845
|
+
fragment.#services,
|
|
846
|
+
);
|
|
580
847
|
|
|
581
848
|
try {
|
|
582
|
-
const middlewareResult = await
|
|
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: "" });
|