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