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/LICENSE.md +110 -0
- package/README.md +643 -0
- package/bin/ff-loader.mjs +18 -0
- package/bin/ff.js +6 -0
- package/package.json +63 -10
- package/src/babel.ts +22 -0
- package/src/cli.ts +83 -0
- package/src/fragment-client.ts +231 -0
- package/src/fragment-protocol.ts +39 -0
- package/src/fragment.ts +260 -0
- package/src/index.ts +648 -0
- package/src/lifecycle.ts +486 -0
- package/src/octane-client-core.ts +134 -0
- package/src/octane-client.ts +42 -0
- package/src/octane-router-document.ts +5 -0
- package/src/octane.ts +654 -0
- package/src/remix-route-data.ts +68 -0
- package/src/remix-router-core.ts +106 -0
- package/src/remix-router.ts +111 -0
- package/src/remove-exports.ts +148 -0
- package/src/route-data-client.ts +224 -0
- package/src/route-prefetch.ts +72 -0
- package/src/server.ts +240 -0
- package/src/srvx.ts +314 -0
- package/src/static-fragment-artifacts.ts +86 -0
- package/src/virtual-remix-routes.d.ts +20 -0
- package/src/vite.ts +693 -0
- package/readme.md +0 -1
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
|
+
}
|