flamefront 0.0.0 → 0.1.0-alpha.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/src/octane.ts ADDED
@@ -0,0 +1,654 @@
1
+ import type {
2
+ AppDefinition,
3
+ GeneratedRouteMetadata,
4
+ RouteDefinition,
5
+ } from "./index.ts"
6
+ import type {
7
+ DocumentMode,
8
+ RouteRuntime,
9
+ RenderedDocument,
10
+ RouteRuntimeContextOptions,
11
+ } from "./server.ts"
12
+ import type {
13
+ ServerRouterOptions,
14
+ ServerRouterResult,
15
+ } from "./remix-router-core.ts"
16
+ import { stripFlamefrontProtocolRequest } from "./fragment-protocol.ts"
17
+ import {
18
+ staticFragmentProtocol,
19
+ type StaticFragmentArtifact,
20
+ type StaticFragmentBoundary,
21
+ } from "./fragment-client.ts"
22
+
23
+ export type { DocumentMode, RenderedDocument } from "./server.ts"
24
+
25
+ export interface RouterDocumentProps {
26
+ readonly router: unknown
27
+ readonly context: unknown
28
+ }
29
+
30
+ export type RouterDocument = (props: RouterDocumentProps) => unknown
31
+
32
+ /** Framework-rendered document pieces available to the app composer. */
33
+ export interface DocumentParts {
34
+ readonly template: string
35
+ readonly body: string
36
+ readonly css: string
37
+ readonly hydrationScript: string
38
+ }
39
+
40
+ export interface DocumentCompositionContext<
41
+ Route extends RouteDefinition = RouteDefinition,
42
+ > {
43
+ readonly request: Request
44
+ readonly mode: DocumentMode
45
+ readonly route: Route | null
46
+ readonly params: Readonly<Record<string, string | undefined>>
47
+ readonly status: number
48
+ }
49
+
50
+ export type ComposeDocument<Route extends RouteDefinition = RouteDefinition> = (
51
+ parts: DocumentParts,
52
+ context: DocumentCompositionContext<Route>,
53
+ ) => string | Promise<string>
54
+
55
+ export interface RenderDocumentOptions {
56
+ readonly mode?: DocumentMode
57
+ }
58
+
59
+ export interface OctaneRenderResult {
60
+ readonly html: string
61
+ readonly css: string
62
+ }
63
+
64
+ /**
65
+ * The renderer is injectable so document behavior can be tested without
66
+ * loading the compiler-only Octane and Remix Router source modules in Node.
67
+ */
68
+ export interface OctaneRenderer {
69
+ readonly createStaticRouter: (
70
+ routes: readonly unknown[],
71
+ context: unknown,
72
+ ) => unknown
73
+ readonly renderToString: (
74
+ component: unknown,
75
+ props: RouterDocumentProps,
76
+ ) => OctaneRenderResult
77
+ /** Render one generated route boundary directly from the static router tree. */
78
+ readonly renderRouteFragment?: (
79
+ router: unknown,
80
+ context: unknown,
81
+ boundary: string,
82
+ ) => OctaneRenderResult
83
+ readonly defaultRouterDocument: RouterDocument
84
+ }
85
+
86
+ export interface DocumentRouter {
87
+ readonly routes: readonly unknown[]
88
+ readonly routeMetadata?: readonly GeneratedRouteMetadata[]
89
+ readonly createServerRouter: (
90
+ request: Request,
91
+ options?: ServerRouterOptions,
92
+ ) => Promise<Response | ServerRouterResult<unknown, unknown>>
93
+ }
94
+
95
+ export interface OctaneDocumentsOptions<
96
+ Context = unknown,
97
+ Route extends RouteDefinition = RouteDefinition,
98
+ > {
99
+ readonly app: AppDefinition<Route>
100
+ readonly runtime: RouteRuntime<Context, Route>
101
+ /** Wrap the framework's default RouterProvider with app providers. */
102
+ readonly routerDocument?: RouterDocument
103
+ /** Control HTML placement while retaining framework protocol pieces. */
104
+ readonly composeDocument?: ComposeDocument<Route>
105
+ readonly router?: DocumentRouter
106
+ readonly renderer?: OctaneRenderer
107
+ }
108
+
109
+ export interface OctaneDocuments {
110
+ readonly renderDocument: (
111
+ template: string,
112
+ request: Request,
113
+ options?: RenderDocumentOptions,
114
+ ) => Promise<RenderedDocument>
115
+ readonly loadRouteData: (request: Request) => Promise<Response>
116
+ readonly renderFragment: (request: Request) => Promise<StaticFragmentArtifact>
117
+ }
118
+
119
+ interface StaticDocumentContext {
120
+ readonly loaderData?: Record<string, unknown>
121
+ readonly actionData?: Record<string, unknown> | null
122
+ readonly errors?: Record<string, unknown> | null
123
+ readonly statusCode?: number
124
+ readonly matches?: readonly {
125
+ readonly params?: Readonly<Record<string, string | undefined>>
126
+ readonly pathname?: string
127
+ readonly pathnameBase?: string
128
+ readonly route?: { readonly id?: string }
129
+ }[]
130
+ }
131
+
132
+ function isRouteErrorResponse(
133
+ value: unknown,
134
+ ): value is Record<string, unknown> {
135
+ return Boolean(
136
+ value &&
137
+ typeof value === "object" &&
138
+ typeof (value as { status?: unknown }).status === "number" &&
139
+ typeof (value as { statusText?: unknown }).statusText === "string" &&
140
+ typeof (value as { internal?: unknown }).internal === "boolean" &&
141
+ "data" in value,
142
+ )
143
+ }
144
+
145
+ export function serializeErrors(
146
+ errors: Record<string, unknown> | null | undefined,
147
+ ): Record<string, unknown> | null {
148
+ if (!errors) {
149
+ return null
150
+ }
151
+
152
+ const serialized: Record<string, unknown> = {}
153
+
154
+ for (const [key, value] of Object.entries(errors)) {
155
+ if (isRouteErrorResponse(value)) {
156
+ serialized[key] = { ...value, __type: "RouteErrorResponse" }
157
+ } else if (value instanceof Error) {
158
+ serialized[key] = {
159
+ message: value.message,
160
+ __type: "Error",
161
+ ...(value.name !== "Error" ? { __subType: value.name } : {}),
162
+ }
163
+ } else {
164
+ serialized[key] = value
165
+ }
166
+ }
167
+
168
+ return serialized
169
+ }
170
+
171
+ export const staticRouterHydrationScriptId =
172
+ "flamefront-static-router-hydration"
173
+
174
+ export function staticRouterHydrationScript(
175
+ context: StaticDocumentContext,
176
+ ): string {
177
+ const data = JSON.stringify({
178
+ loaderData: context.loaderData,
179
+ actionData: context.actionData,
180
+ errors: serializeErrors(context.errors),
181
+ })
182
+ const escaped = JSON.stringify(data).replace(
183
+ /[&><\u2028\u2029]/g,
184
+ (character) => {
185
+ const escapes: Record<string, string> = {
186
+ "&": "\\u0026",
187
+ ">": "\\u003e",
188
+ "<": "\\u003c",
189
+ "
": "\\u2028",
190
+ "
": "\\u2029",
191
+ }
192
+
193
+ return escapes[character] ?? character
194
+ },
195
+ )
196
+
197
+ return `<script id="${staticRouterHydrationScriptId}">window.__staticRouterHydrationData = JSON.parse(${escaped});</script>`
198
+ }
199
+
200
+ export function composeDefaultDocument(
201
+ template: string,
202
+ body: string,
203
+ css: string,
204
+ hydrationScript: string,
205
+ ): string {
206
+ const root = '<div id="root"></div>'
207
+
208
+ if (!template.includes(root)) {
209
+ throw new Error(
210
+ 'The HTML shell must contain an empty <div id="root"></div>.',
211
+ )
212
+ }
213
+
214
+ return template
215
+ .replace(root, `<div id="root">${body}</div>`)
216
+ .replace("</head>", `${css}</head>`)
217
+ .replace("</body>", `${hydrationScript}</body>`)
218
+ }
219
+
220
+ async function loadDefaultRouter(): Promise<DocumentRouter> {
221
+ const module = await import("./remix-router.ts")
222
+
223
+ return {
224
+ routes: module.routes,
225
+ routeMetadata: module.routeMetadata,
226
+ createServerRouter: module.createServerRouter,
227
+ }
228
+ }
229
+
230
+ async function loadDefaultRenderer(): Promise<OctaneRenderer> {
231
+ const [remix, routerDocument, octane, fragment] = await Promise.all([
232
+ import("@octanejs/remix-router"),
233
+ import("./octane-router-document.ts"),
234
+ import("octane/server"),
235
+ import("./fragment.ts"),
236
+ ])
237
+
238
+ const createStaticNavigator = (router: {
239
+ readonly createHref: (to: unknown) => string
240
+ readonly encodeLocation: (to: unknown) => unknown
241
+ }) => ({
242
+ createHref: router.createHref,
243
+ encodeLocation: router.encodeLocation,
244
+ push() {
245
+ throw new Error(
246
+ "Static fragment rendering cannot navigate on the server.",
247
+ )
248
+ },
249
+ replace() {
250
+ throw new Error(
251
+ "Static fragment rendering cannot navigate on the server.",
252
+ )
253
+ },
254
+ go() {
255
+ throw new Error(
256
+ "Static fragment rendering cannot navigate on the server.",
257
+ )
258
+ },
259
+ back() {
260
+ throw new Error(
261
+ "Static fragment rendering cannot navigate on the server.",
262
+ )
263
+ },
264
+ forward() {
265
+ throw new Error(
266
+ "Static fragment rendering cannot navigate on the server.",
267
+ )
268
+ },
269
+ })
270
+
271
+ return {
272
+ createStaticRouter: (routes, context) =>
273
+ remix.createStaticRouter(routes as any[], context as any),
274
+ renderToString: (component, props) =>
275
+ octane.renderToString(component as never, props as never),
276
+ renderRouteFragment: (router, context, boundary) => {
277
+ const dataRouter = router as {
278
+ readonly state: {
279
+ readonly location: unknown
280
+ readonly matches: readonly {
281
+ readonly route?: { readonly id?: string }
282
+ }[]
283
+ }
284
+ readonly createHref: (to: unknown) => string
285
+ readonly encodeLocation: (to: unknown) => unknown
286
+ }
287
+ const staticContext = context as { readonly basename?: string }
288
+ const state = dataRouter.state
289
+ const matchIndex = state.matches.findIndex(
290
+ (match) => match.route?.id === boundary,
291
+ )
292
+
293
+ if (matchIndex < 0) {
294
+ throw new Error(
295
+ `No static router match exists for fragment boundary ${JSON.stringify(boundary)}.`,
296
+ )
297
+ }
298
+
299
+ const navigator = createStaticNavigator(dataRouter)
300
+ const dataRouterContext = {
301
+ router: dataRouter,
302
+ navigator,
303
+ static: true,
304
+ staticContext: context,
305
+ basename: staticContext.basename ?? "/",
306
+ }
307
+ const fragmentTree = remix.renderMatches(
308
+ state.matches.slice(matchIndex) as never,
309
+ )
310
+ const fragmentRoot = octane.createElement(
311
+ remix.UNSAFE_DataRouterContext.Provider as never,
312
+ {
313
+ value: dataRouterContext,
314
+ children: octane.createElement(
315
+ remix.UNSAFE_DataRouterStateContext.Provider as never,
316
+ {
317
+ value: state,
318
+ children: octane.createElement(
319
+ remix.UNSAFE_FetchersContext.Provider as never,
320
+ {
321
+ value: new Map(),
322
+ children: octane.createElement(
323
+ remix.UNSAFE_ViewTransitionContext.Provider as never,
324
+ {
325
+ value: { isTransitioning: false },
326
+ children: octane.createElement(
327
+ remix.StaticRouter as never,
328
+ {
329
+ basename: staticContext.basename ?? "/",
330
+ location: state.location,
331
+ children: octane.createElement(
332
+ fragment.staticFragmentBoundaryTarget
333
+ .Provider as never,
334
+ { value: boundary, children: fragmentTree },
335
+ ),
336
+ },
337
+ ),
338
+ },
339
+ ),
340
+ },
341
+ ),
342
+ },
343
+ ),
344
+ },
345
+ )
346
+ const FragmentRoot = () => fragmentRoot
347
+
348
+ return octane.renderToString(FragmentRoot, {})
349
+ },
350
+ defaultRouterDocument: routerDocument.RouterDocument,
351
+ }
352
+ }
353
+
354
+ function createShellRouter(
355
+ request: Request,
356
+ app: AppDefinition,
357
+ routeGraph: readonly unknown[],
358
+ renderer: OctaneRenderer,
359
+ ): { readonly context: StaticDocumentContext; readonly router: unknown } {
360
+ const url = new URL(request.url)
361
+ const rootRoute =
362
+ routeGraph[0] && typeof routeGraph[0] === "object" ? routeGraph[0] : {}
363
+ const context: StaticDocumentContext & {
364
+ readonly basename: string
365
+ readonly location: Record<string, unknown>
366
+ } = {
367
+ basename: app.routing.basename,
368
+ location: {
369
+ pathname: url.pathname,
370
+ search: url.search,
371
+ hash: url.hash,
372
+ state: null,
373
+ key: "default",
374
+ },
375
+ matches: [
376
+ {
377
+ params: {},
378
+ pathname: "",
379
+ pathnameBase: app.routing.basename,
380
+ route: { ...rootRoute, id: "0" },
381
+ },
382
+ ],
383
+ loaderData: {},
384
+ actionData: null,
385
+ errors: null,
386
+ statusCode: 200,
387
+ }
388
+
389
+ return {
390
+ context,
391
+ router: renderer.createStaticRouter(routeGraph, context),
392
+ }
393
+ }
394
+
395
+ function resolveDocumentMode(
396
+ request: Request,
397
+ mode: DocumentMode | undefined,
398
+ route: RouteDefinition | null,
399
+ ): DocumentMode {
400
+ if (mode) {
401
+ return mode
402
+ }
403
+
404
+ if (new URL(request.url).searchParams.has("__flamefront_shell")) {
405
+ return "shell"
406
+ }
407
+
408
+ if (!route) {
409
+ throw new Response("Not found", { status: 404 })
410
+ }
411
+
412
+ return route.render
413
+ }
414
+
415
+ function assertRouteMode(
416
+ route: RouteDefinition | null,
417
+ mode: DocumentMode,
418
+ ): void {
419
+ if (mode === "shell") {
420
+ return
421
+ }
422
+
423
+ if (!route || route.render !== mode) {
424
+ throw new Response("Not found", { status: 404 })
425
+ }
426
+ }
427
+
428
+ function routeData(context: StaticDocumentContext): unknown {
429
+ const leaf = context.matches?.at(-1)
430
+ const routeId = leaf?.route?.id
431
+
432
+ return routeId ? (context.loaderData?.[routeId] ?? null) : null
433
+ }
434
+
435
+ function fragmentMetadataChain(
436
+ route: RouteDefinition,
437
+ metadata: readonly GeneratedRouteMetadata[] | undefined,
438
+ ): GeneratedRouteMetadata[] {
439
+ const leaf = metadata?.find(
440
+ (item) =>
441
+ item.kind === "route" &&
442
+ item.entry === route.entry &&
443
+ (item.path === route.path || item.path === undefined),
444
+ )
445
+
446
+ if (!leaf) {
447
+ return [
448
+ {
449
+ id: route.entry,
450
+ boundary: route.entry,
451
+ kind: "route",
452
+ entry: route.entry,
453
+ path: route.path,
454
+ render: route.render,
455
+ navigation: "fragment",
456
+ hydration: route.hydration,
457
+ },
458
+ ]
459
+ }
460
+
461
+ const byId = new Map((metadata ?? []).map((item) => [item.id, item]))
462
+ const chain: GeneratedRouteMetadata[] = []
463
+ let current: GeneratedRouteMetadata | undefined = leaf
464
+
465
+ while (current) {
466
+ chain.unshift(current)
467
+ current = current.parent ? byId.get(current.parent) : undefined
468
+ }
469
+
470
+ return chain
471
+ }
472
+
473
+ function createStaticFragmentArtifact(
474
+ route: RouteDefinition,
475
+ context: StaticDocumentContext,
476
+ router: unknown,
477
+ renderer: OctaneRenderer,
478
+ fallbackBody: string,
479
+ metadata: readonly GeneratedRouteMetadata[] | undefined,
480
+ ): StaticFragmentArtifact {
481
+ const chain = fragmentMetadataChain(route, metadata)
482
+ const boundaries: StaticFragmentBoundary[] = chain.map((item) => {
483
+ const rendered = renderer.renderRouteFragment?.(
484
+ router,
485
+ context,
486
+ item.boundary,
487
+ )
488
+
489
+ return {
490
+ id: item.id,
491
+ boundary: item.boundary,
492
+ kind: item.kind,
493
+ ...(item.parent ? { parent: item.parent } : {}),
494
+ html: rendered?.html ?? (item === chain.at(-1) ? fallbackBody : ""),
495
+ }
496
+ })
497
+ const fragmentHtml = boundaries.at(-1)?.html || fallbackBody
498
+
499
+ return {
500
+ protocol: staticFragmentProtocol,
501
+ route: route.path,
502
+ boundary: chain.at(-1)?.boundary ?? route.entry,
503
+ html: fragmentHtml,
504
+ routeData: routeData(context),
505
+ boundaries,
506
+ hydration: route.hydration,
507
+ status: context.statusCode ?? 200,
508
+ }
509
+ }
510
+
511
+ export function createOctaneDocuments<
512
+ Context = unknown,
513
+ Route extends RouteDefinition = RouteDefinition,
514
+ >(options: OctaneDocumentsOptions<Context, Route>): OctaneDocuments {
515
+ let routerPromise: Promise<DocumentRouter> | undefined
516
+ let rendererPromise: Promise<OctaneRenderer> | undefined
517
+
518
+ const getRouter = (): Promise<DocumentRouter> => {
519
+ if (options.router) {
520
+ return Promise.resolve(options.router)
521
+ }
522
+
523
+ return (routerPromise ??= loadDefaultRouter())
524
+ }
525
+
526
+ const getRenderer = (): Promise<OctaneRenderer> => {
527
+ if (options.renderer) {
528
+ return Promise.resolve(options.renderer)
529
+ }
530
+
531
+ return (rendererPromise ??= loadDefaultRenderer())
532
+ }
533
+
534
+ const renderRoute = async (
535
+ request: Request,
536
+ mode: DocumentMode,
537
+ ): Promise<{
538
+ readonly router: DocumentRouter
539
+ readonly dataRouter: unknown
540
+ readonly renderer: OctaneRenderer
541
+ readonly context: StaticDocumentContext
542
+ readonly rendered: OctaneRenderResult
543
+ }> => {
544
+ const contextOptions: RouteRuntimeContextOptions = {
545
+ purpose: "document",
546
+ mode,
547
+ }
548
+ const requestContext = await options.runtime.createRequestContext(
549
+ request,
550
+ contextOptions,
551
+ )
552
+ const [router, renderer] = await Promise.all([getRouter(), getRenderer()])
553
+ const routerDocument =
554
+ options.routerDocument ?? renderer.defaultRouterDocument
555
+ const result =
556
+ mode === "shell" || mode === "client"
557
+ ? createShellRouter(request, options.app, router.routes, renderer)
558
+ : await router.createServerRouter(request, {
559
+ basename: options.app.routing.basename,
560
+ requestContext,
561
+ })
562
+
563
+ if (result instanceof Response) {
564
+ throw result
565
+ }
566
+
567
+ const context = result.context as StaticDocumentContext
568
+
569
+ return {
570
+ router,
571
+ dataRouter: result.router,
572
+ renderer,
573
+ context,
574
+ rendered: renderer.renderToString(routerDocument, {
575
+ router: result.router,
576
+ context: result.context,
577
+ }),
578
+ }
579
+ }
580
+
581
+ const renderDocument = async (
582
+ template: string,
583
+ request: Request,
584
+ renderOptions: RenderDocumentOptions = {},
585
+ ): Promise<RenderedDocument> => {
586
+ const sanitizedRequest = stripFlamefrontProtocolRequest(request)
587
+ const routeMatch = options.app.match(sanitizedRequest.url)
588
+ const route = routeMatch?.data ?? null
589
+ const mode = resolveDocumentMode(request, renderOptions.mode, route)
590
+
591
+ assertRouteMode(route, mode)
592
+ const { context: staticContext, rendered } = await renderRoute(
593
+ sanitizedRequest,
594
+ mode,
595
+ )
596
+ const status = staticContext.statusCode ?? 200
597
+ const compositionContext: DocumentCompositionContext<Route> = {
598
+ request: sanitizedRequest,
599
+ mode,
600
+ route,
601
+ params: routeMatch?.params ?? {},
602
+ status,
603
+ }
604
+ const parts: DocumentParts = {
605
+ template,
606
+ body: rendered.html,
607
+ css: rendered.css,
608
+ hydrationScript: staticRouterHydrationScript(staticContext),
609
+ }
610
+ const html = await (options.composeDocument
611
+ ? options.composeDocument(parts, compositionContext)
612
+ : composeDefaultDocument(
613
+ parts.template,
614
+ parts.body,
615
+ parts.css,
616
+ parts.hydrationScript,
617
+ ))
618
+
619
+ return mode === "static"
620
+ ? { html, routeData: routeData(staticContext), status }
621
+ : { html, status }
622
+ }
623
+
624
+ const renderFragment = async (
625
+ request: Request,
626
+ ): Promise<StaticFragmentArtifact> => {
627
+ const sanitizedRequest = stripFlamefrontProtocolRequest(request)
628
+ const routeMatch = options.app.match(sanitizedRequest.url)
629
+ const route = routeMatch?.data ?? null
630
+
631
+ assertRouteMode(route, "static")
632
+ if (!route) {
633
+ throw new Response("Not found", { status: 404 })
634
+ }
635
+
636
+ const { router, dataRouter, renderer, rendered, context } =
637
+ await renderRoute(sanitizedRequest, "static")
638
+
639
+ return createStaticFragmentArtifact(
640
+ route,
641
+ context,
642
+ dataRouter,
643
+ renderer,
644
+ rendered.html,
645
+ router.routeMetadata,
646
+ )
647
+ }
648
+
649
+ return {
650
+ renderDocument,
651
+ loadRouteData: options.runtime.loadRouteData,
652
+ renderFragment,
653
+ }
654
+ }
@@ -0,0 +1,68 @@
1
+ import {
2
+ createRouteDataClient,
3
+ type RouteDataLoadOptions,
4
+ type RouteDataRoutingOptions,
5
+ } from "./route-data-client.ts"
6
+ import {
7
+ loadStaticFragment,
8
+ type StaticFragmentLoadOptions,
9
+ type StaticFragmentRoutingOptions,
10
+ } from "./fragment-client.ts"
11
+
12
+ export {
13
+ createRouteDataClient,
14
+ type RouteDataClient,
15
+ type RouteDataLoadOptions,
16
+ type RouteDataRoutingOptions,
17
+ type RouteDataSource,
18
+ } from "./route-data-client.ts"
19
+
20
+ export interface ClientLoaderArgs {
21
+ readonly request: Request
22
+ }
23
+
24
+ export interface RouteDataOptions {
25
+ readonly basename?: string
26
+ readonly dataPath?: string
27
+ }
28
+
29
+ function client(options: RouteDataOptions) {
30
+ return createRouteDataClient(options satisfies RouteDataRoutingOptions)
31
+ }
32
+
33
+ /** Load route data through Flamefront's server endpoint during browser navigation. */
34
+ export async function loadRouteData(
35
+ { request }: ClientLoaderArgs,
36
+ options: RouteDataOptions = {},
37
+ ): Promise<unknown> {
38
+ const loadOptions: RouteDataLoadOptions = { signal: request.signal }
39
+
40
+ return client(options).load(request.url, "live", loadOptions)
41
+ }
42
+
43
+ /** Load a build-time static route artifact during browser navigation. */
44
+ export async function loadStaticRouteData(
45
+ { request }: ClientLoaderArgs,
46
+ options: RouteDataOptions = {},
47
+ ): Promise<unknown> {
48
+ const loadOptions: RouteDataLoadOptions = { signal: request.signal }
49
+
50
+ return client(options).load(request.url, "static", loadOptions)
51
+ }
52
+
53
+ /** Load a static fragment and expose only its route data to the router. */
54
+ export async function loadStaticRouteFragment(
55
+ { request }: ClientLoaderArgs,
56
+ options: RouteDataOptions = {},
57
+ ): Promise<unknown> {
58
+ const fragmentOptions: StaticFragmentLoadOptions = {
59
+ signal: request.signal,
60
+ }
61
+ const artifact = await loadStaticFragment(
62
+ request.url,
63
+ options satisfies StaticFragmentRoutingOptions,
64
+ fragmentOptions,
65
+ )
66
+
67
+ return artifact.routeData
68
+ }