flamefront 0.1.1 → 0.1.2

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/index.ts CHANGED
@@ -12,6 +12,18 @@ import type { HydrationInteractionEvents } from "octane/hydration"
12
12
  import { createRouteDataClient } from "./route-data-client.ts"
13
13
  import { stripFlamefrontProtocolParams } from "./fragment-protocol.ts"
14
14
 
15
+ export { action } from "./action.ts"
16
+ export type {
17
+ ActionDataWithResponseInit,
18
+ ActionFunction,
19
+ ActionInput,
20
+ ActionOutput,
21
+ ActionValidationError,
22
+ StandardSchema,
23
+ StandardSchemaIssue,
24
+ StandardSchemaV1,
25
+ } from "./action.ts"
26
+
15
27
  export { glob, joinRoutePath } from "./glob.ts"
16
28
  export type { GlobFile } from "./glob.ts"
17
29
 
@@ -75,6 +87,13 @@ export type RouteNavigationStrategy = "router" | "fragment"
75
87
 
76
88
  export type RouteBoundaryKind = "shell" | "layout" | "route"
77
89
 
90
+ /** Handle exposed by generated router matches, including content metadata. */
91
+ export interface RouteHandle {
92
+ readonly flamefront: GeneratedRouteMetadata
93
+ /** Markdown/MDX frontmatter; absent on component routes and layouts. */
94
+ readonly frontmatter?: Readonly<Record<string, unknown>>
95
+ }
96
+
78
97
  /** Metadata emitted on generated router nodes for route-aware navigation. */
79
98
  export interface GeneratedRouteMetadata {
80
99
  readonly id: string
@@ -205,6 +224,25 @@ export type RouteLoaderData<Path extends string = string> =
205
224
  ? Awaited<Result>
206
225
  : unknown
207
226
 
227
+ type RouteActionFunction<Module> = Module extends {
228
+ readonly action?: infer Action
229
+ }
230
+ ? Action extends (...args: infer _Args) => infer Result
231
+ ? (...args: _Args) => Result
232
+ : never
233
+ : never
234
+
235
+ /** The authored page action function associated with a generated route. */
236
+ export type RouteActionFor<Path extends string = string> = RouteActionFunction<
237
+ RouteModuleFor<Path>
238
+ >
239
+
240
+ /** The awaited result of a generated route's page action. */
241
+ export type RouteActionData<Path extends string = string> =
242
+ RouteActionFor<Path> extends (...args: infer _Args) => infer Result
243
+ ? Awaited<Result>
244
+ : unknown
245
+
208
246
  type RoutePageParams<Path extends RouteImportMapPath> =
209
247
  keyof RouteParams<Path> extends never
210
248
  ? BroadRouteParams
package/src/lifecycle.ts CHANGED
@@ -3,8 +3,8 @@ import {
3
3
  type IncomingMessage,
4
4
  type ServerResponse,
5
5
  } from "node:http"
6
- import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises"
7
- import { dirname, relative, resolve } from "node:path"
6
+ import { access, readFile, rm, writeFile } from "node:fs/promises"
7
+ import { relative, resolve } from "node:path"
8
8
  import { pathToFileURL } from "node:url"
9
9
  import { serve } from "srvx"
10
10
  import type { ServerMiddleware } from "srvx"
@@ -16,17 +16,33 @@ import type {
16
16
  import { joinBasename } from "./index.ts"
17
17
  import type { FlamefrontServerEntry } from "./srvx.ts"
18
18
  import type { RenderDocumentResult } from "./server.ts"
19
+ import type {
20
+ PrerenderCache,
21
+ PrerenderOptions,
22
+ PrerenderPage,
23
+ } from "./prerender.ts"
19
24
  import {
20
- staticRouteFile,
21
- staticRouteDataFile,
22
- staticRouteFragmentFile,
23
- staticRouteFragmentDataFile,
24
- } from "./static-fragment-artifacts.ts"
25
+ cacheKey as prerenderCacheKey,
26
+ cacheNamespace,
27
+ createFilesystemPrerenderCache,
28
+ deserializePrerenderArtifact,
29
+ hash,
30
+ isParameterizedRoute,
31
+ renderingFingerprint,
32
+ routeSourceFile,
33
+ serializePrerenderArtifact,
34
+ } from "./prerender.ts"
35
+ import { staticRouteFile } from "./static-fragment-artifacts.ts"
36
+ import type { RouteFragmentArtifact } from "./fragment-client.ts"
25
37
  import {
26
- routeFragmentProtocol,
27
- type RouteFragmentArtifact,
28
- } from "./fragment-client.ts"
38
+ assembleStaticRouteArtifact,
39
+ documentParts,
40
+ renderStaticRoute,
41
+ staticRouteRequest,
42
+ writeStaticRouteArtifact,
43
+ } from "./prerender-artifacts.ts"
29
44
  import { setGlobRoot } from "./glob.ts"
45
+ import { getFlamefrontOptions } from "./vite-options.ts"
30
46
 
31
47
  export {
32
48
  staticRouteFile,
@@ -35,6 +51,12 @@ export {
35
51
  staticRouteFragmentDataFile,
36
52
  } from "./static-fragment-artifacts.ts"
37
53
 
54
+ export type {
55
+ PrerenderCache,
56
+ PrerenderOptions,
57
+ PrerenderPage,
58
+ } from "./prerender.ts"
59
+
38
60
  interface AppModule {
39
61
  app?: AppDefinition
40
62
  default?: AppDefinition
@@ -50,6 +72,38 @@ export interface ProjectContext {
50
72
  readonly routesFile: string
51
73
  }
52
74
 
75
+ export interface BuildProjectOptions {
76
+ readonly forcePrerender?: boolean
77
+ }
78
+
79
+ export interface PrerenderRouteEntry {
80
+ readonly path: string
81
+ readonly route: RouteDefinition
82
+ readonly key: string | null
83
+ }
84
+
85
+ interface CollectedPrerenderRoute {
86
+ readonly path: string
87
+ readonly route: RouteDefinition
88
+ readonly supplied?: PrerenderPage
89
+ }
90
+
91
+ export interface PrerenderStats {
92
+ readonly rendered: number
93
+ readonly reused: number
94
+ }
95
+
96
+ export interface PrerenderStaticRouteOptions {
97
+ readonly cache?: PrerenderCache | false
98
+ readonly force?: boolean
99
+ readonly revision?: string
100
+ readonly template?: string
101
+ readonly fingerprint?: (
102
+ route: RouteDefinition,
103
+ path: string,
104
+ ) => string | Promise<string>
105
+ }
106
+
53
107
  export async function loadProject(
54
108
  root = process.cwd(),
55
109
  ): Promise<ProjectContext> {
@@ -165,24 +219,6 @@ function requestUrl(request: IncomingMessage): URL {
165
219
  )
166
220
  }
167
221
 
168
- function documentParts(document: RenderDocumentResult): {
169
- readonly html: string
170
- readonly status: number
171
- readonly hasRouteData: boolean
172
- readonly routeData: unknown
173
- } {
174
- if (typeof document === "string") {
175
- return { html: document, status: 200, hasRouteData: false, routeData: null }
176
- }
177
-
178
- return {
179
- html: document.html,
180
- status: document.status ?? 200,
181
- hasRouteData: "routeData" in document,
182
- routeData: document.routeData,
183
- }
184
- }
185
-
186
222
  function concreteRoutePath(path: string): string {
187
223
  return (
188
224
  path
@@ -224,8 +260,268 @@ async function listen(
224
260
  })
225
261
  }
226
262
 
227
- export async function buildProject(root = process.cwd()): Promise<void> {
263
+ async function loadBuildFlamefrontOptions(root: string) {
264
+ const { loadConfigFromFile } = await import("vite")
265
+
266
+ await loadConfigFromFile(
267
+ { command: "build", mode: "production" },
268
+ resolve(root, "vite.config.ts"),
269
+ root,
270
+ )
271
+
272
+ return getFlamefrontOptions(root)
273
+ }
274
+
275
+ function validatePrerenderPath(path: unknown): asserts path is string {
276
+ if (typeof path !== "string" || !path.startsWith("/")) {
277
+ throw new TypeError(
278
+ `flamefront prerender page path must start with '/'; received ${JSON.stringify(path)}.`,
279
+ )
280
+ }
281
+
282
+ if (path.includes("?") || path.includes("#")) {
283
+ throw new TypeError(
284
+ `flamefront prerender page path cannot contain a query or fragment: ${JSON.stringify(path)}.`,
285
+ )
286
+ }
287
+
288
+ if (isParameterizedRoute(path)) {
289
+ throw new TypeError(
290
+ `flamefront prerender page path must be concrete: ${JSON.stringify(path)}.`,
291
+ )
292
+ }
293
+ }
294
+
295
+ function normalizedPrerenderPath(path: string): string {
296
+ return path.length > 1 ? path.replace(/\/+$/, "") : path
297
+ }
298
+
299
+ async function collectPrerenderRoutes(
300
+ root: string,
301
+ app: AppDefinition,
302
+ options: PrerenderOptions,
303
+ ): Promise<readonly CollectedPrerenderRoute[]> {
304
+ const pages = new Map<string, RouteDefinition>()
305
+ const suppliedPages = new Map<string, PrerenderPage>()
306
+ const callbackPaths = new Set<string>()
307
+ const staticRoutes = app.routes.filter((route) => route.render === "static")
308
+
309
+ for (const route of staticRoutes) {
310
+ if (!isParameterizedRoute(route.path)) {
311
+ pages.set(route.path, route)
312
+ }
313
+ }
314
+
315
+ if (options.pages) {
316
+ const supplied = await options.pages({ root, routes: app.routes })
317
+
318
+ for await (const page of supplied) {
319
+ if (!page || typeof page !== "object") {
320
+ throw new TypeError("flamefront prerender pages must contain objects.")
321
+ }
322
+
323
+ validatePrerenderPath(page.path)
324
+ const path = normalizedPrerenderPath(page.path)
325
+ const normalizedPage = path === page.path ? page : { ...page, path }
326
+
327
+ if (callbackPaths.has(path)) {
328
+ throw new TypeError(
329
+ `flamefront prerender page path is duplicated: ${path}`,
330
+ )
331
+ }
332
+
333
+ if (
334
+ page.key !== undefined &&
335
+ page.key !== null &&
336
+ typeof page.key !== "string"
337
+ ) {
338
+ throw new TypeError(
339
+ `flamefront prerender page key must be a string or null: ${path}`,
340
+ )
341
+ }
342
+
343
+ const match = app.match(
344
+ new URL(joinRoutePath(app.routing, path), "http://flamefront.build"),
345
+ { render: "static" },
346
+ )
347
+
348
+ if (!match) {
349
+ throw new Error(
350
+ `flamefront prerender page ${JSON.stringify(path)} does not match a static route.`,
351
+ )
352
+ }
353
+
354
+ callbackPaths.add(path)
355
+ pages.set(path, match.data)
356
+ suppliedPages.set(path, normalizedPage)
357
+ }
358
+ } else if (staticRoutes.some((route) => isParameterizedRoute(route.path))) {
359
+ const route = staticRoutes.find((item) => isParameterizedRoute(item.path))!
360
+
361
+ throw new Error(
362
+ `Cannot prerender parameterized static route ${JSON.stringify(route.path)} without concrete paths.`,
363
+ )
364
+ }
365
+
366
+ return [...pages].map(([path, route]) => ({
367
+ path,
368
+ route,
369
+ supplied: suppliedPages.get(path),
370
+ }))
371
+ }
372
+
373
+ async function resolvePrerenderKey(
374
+ root: string,
375
+ entry: Omit<PrerenderRouteEntry, "key">,
376
+ supplied: PrerenderPage | undefined,
377
+ ): Promise<string | null> {
378
+ if (supplied?.key !== undefined) {
379
+ return supplied.key
380
+ }
381
+
382
+ if (entry.route.content !== "markdown") {
383
+ return null
384
+ }
385
+
386
+ const source = routeSourceFile(root, entry.route)
387
+
388
+ try {
389
+ return hash(await readFile(source))
390
+ } catch (error) {
391
+ throw new Error(
392
+ `Cannot hash Markdown route source ${relative(root, source)}.`,
393
+ { cause: error },
394
+ )
395
+ }
396
+ }
397
+
398
+ async function resolvePrerenderEntries(
399
+ root: string,
400
+ app: AppDefinition,
401
+ options: PrerenderOptions,
402
+ ): Promise<readonly PrerenderRouteEntry[]> {
403
+ const routes = await collectPrerenderRoutes(root, app, options)
404
+
405
+ return Promise.all(
406
+ routes.map(async (entry) => ({
407
+ ...entry,
408
+ key: await resolvePrerenderKey(root, entry, entry.supplied),
409
+ })),
410
+ )
411
+ }
412
+
413
+ export async function prerenderStaticRouteEntries(
414
+ root: string,
415
+ clientDirectory: string,
416
+ entries: readonly PrerenderRouteEntry[],
417
+ render: (request: Request) => Promise<RenderDocumentResult>,
418
+ loadData?: (request: Request) => Promise<unknown>,
419
+ routing: Pick<NormalizedRoutingOptions, "basename"> = { basename: "/" },
420
+ renderFragment?: (request: Request) => Promise<RouteFragmentArtifact>,
421
+ options: PrerenderStaticRouteOptions = {},
422
+ ): Promise<PrerenderStats> {
423
+ const cache =
424
+ options.cache === undefined
425
+ ? createFilesystemPrerenderCache(root)
426
+ : options.cache
427
+ let rendered = 0
428
+ let reused = 0
429
+
430
+ for (const entry of entries) {
431
+ const outputRoute =
432
+ entry.route.path === entry.path
433
+ ? entry.route
434
+ : ({ ...entry.route, path: entry.path } satisfies RouteDefinition)
435
+ const request = staticRouteRequest(routing, entry.path)
436
+ const fingerprint = options.fingerprint
437
+ ? await options.fingerprint(entry.route, entry.path)
438
+ : hash({ route: entry.route, path: entry.path })
439
+ const key =
440
+ entry.key === null
441
+ ? undefined
442
+ : prerenderCacheKey(
443
+ entry.path,
444
+ entry.key,
445
+ options.revision,
446
+ fingerprint,
447
+ cacheNamespace(root),
448
+ )
449
+ let artifact = null as Awaited<ReturnType<typeof renderStaticRoute>> | null
450
+ let reusedEntry = false
451
+
452
+ if (cache && key && !options.force) {
453
+ try {
454
+ const cached = await cache.get(key)
455
+ const decoded = cached ? deserializePrerenderArtifact(cached) : null
456
+
457
+ if (decoded?.path === entry.path) {
458
+ artifact = options.template
459
+ ? assembleStaticRouteArtifact(decoded.artifact, options.template)
460
+ : decoded.artifact
461
+ reusedEntry = artifact !== null
462
+
463
+ if (artifact && options.template !== undefined && cache) {
464
+ try {
465
+ await cache.put(
466
+ key,
467
+ serializePrerenderArtifact(entry.path, artifact),
468
+ )
469
+ } catch (error) {
470
+ console.warn(
471
+ `Flamefront prerender cache write failed for ${entry.path}; continuing with the build.`,
472
+ error,
473
+ )
474
+ }
475
+ }
476
+ }
477
+ } catch (error) {
478
+ console.warn(
479
+ `Flamefront prerender cache read failed for ${entry.path}; rendering it again.`,
480
+ error,
481
+ )
482
+ }
483
+ }
484
+
485
+ if (!artifact) {
486
+ artifact = await renderStaticRoute(
487
+ outputRoute,
488
+ request,
489
+ render,
490
+ loadData,
491
+ renderFragment,
492
+ options.template,
493
+ )
494
+ rendered += 1
495
+
496
+ if (cache && key) {
497
+ try {
498
+ await cache.put(key, serializePrerenderArtifact(entry.path, artifact))
499
+ } catch (error) {
500
+ console.warn(
501
+ `Flamefront prerender cache write failed for ${entry.path}; continuing with the build.`,
502
+ error,
503
+ )
504
+ }
505
+ }
506
+ } else if (reusedEntry) {
507
+ reused += 1
508
+ }
509
+
510
+ await writeStaticRouteArtifact(clientDirectory, outputRoute, artifact)
511
+ console.log(
512
+ `${reusedEntry ? "Reused" : "Generated"} ${relative(root, staticRouteFile(clientDirectory, outputRoute))}.`,
513
+ )
514
+ }
515
+
516
+ return { rendered, reused }
517
+ }
518
+
519
+ export async function buildProject(
520
+ root = process.cwd(),
521
+ buildOptions: BuildProjectOptions = {},
522
+ ): Promise<void> {
228
523
  const { app } = await loadProject(root)
524
+ const flamefrontOptions = await loadBuildFlamefrontOptions(root)
229
525
  const { build } = await import("vite")
230
526
  const dist = resolve(root, "dist")
231
527
  const clientDirectory = resolve(dist, "client")
@@ -284,33 +580,72 @@ export async function buildProject(root = process.cwd()): Promise<void> {
284
580
  return
285
581
  }
286
582
 
287
- await prerenderStaticRoutes(
583
+ const render = (request: Request) =>
584
+ serverEntry.renderDocument(clientTemplate, request, { mode: "static" })
585
+ const loadData = async (request: Request) => {
586
+ const endpoint = new URL(app.routing.dataPath, request.url)
587
+
588
+ endpoint.searchParams.set("url", request.url)
589
+ const response = await serverEntry.loadRouteData(
590
+ new Request(endpoint, {
591
+ headers: request.headers,
592
+ signal: request.signal,
593
+ }),
594
+ )
595
+
596
+ if (!response.ok) {
597
+ throw response
598
+ }
599
+
600
+ return response.json()
601
+ }
602
+
603
+ const renderFragment = serverEntry.renderFragment
604
+ ? (request: Request) => serverEntry.renderFragment!(request)
605
+ : undefined
606
+
607
+ if (!flamefrontOptions?.prerender) {
608
+ await prerenderStaticRoutes(
609
+ root,
610
+ clientDirectory,
611
+ staticRoutes,
612
+ render,
613
+ loadData,
614
+ app.routing,
615
+ renderFragment,
616
+ )
617
+ return
618
+ }
619
+
620
+ const entries = await resolvePrerenderEntries(
621
+ root,
622
+ app,
623
+ flamefrontOptions.prerender,
624
+ )
625
+ const stats = await prerenderStaticRouteEntries(
288
626
  root,
289
627
  clientDirectory,
290
- staticRoutes,
291
- (request) =>
292
- serverEntry.renderDocument(clientTemplate, request, { mode: "static" }),
293
- async (request) => {
294
- const endpoint = new URL(app.routing.dataPath, request.url)
295
-
296
- endpoint.searchParams.set("url", request.url)
297
- const response = await serverEntry.loadRouteData(
298
- new Request(endpoint, {
299
- headers: request.headers,
300
- signal: request.signal,
628
+ entries,
629
+ render,
630
+ loadData,
631
+ app.routing,
632
+ renderFragment,
633
+ {
634
+ cache: flamefrontOptions.prerender.cache,
635
+ force: buildOptions.forcePrerender,
636
+ revision: flamefrontOptions.prerender.revision,
637
+ template: clientTemplate,
638
+ fingerprint: (route, path) =>
639
+ renderingFingerprint(root, app, route, path, {
640
+ markdown: flamefrontOptions.markdown,
641
+ target: flamefrontOptions.target,
642
+ template: clientTemplate,
301
643
  }),
302
- )
303
-
304
- if (!response.ok) {
305
- throw response
306
- }
307
-
308
- return response.json()
309
644
  },
310
- app.routing,
311
- serverEntry.renderFragment
312
- ? (request) => serverEntry.renderFragment!(request)
313
- : undefined,
645
+ )
646
+
647
+ console.log(
648
+ `Prerendered ${stats.rendered} pages, reused ${stats.reused} cached pages.`,
314
649
  )
315
650
  }
316
651
 
@@ -324,40 +659,19 @@ export async function prerenderStaticRoutes(
324
659
  renderFragment?: (request: Request) => Promise<RouteFragmentArtifact>,
325
660
  ): Promise<void> {
326
661
  for (const route of routes) {
327
- const outputFile = staticRouteFile(clientDirectory, route)
328
- const outputDataFile = staticRouteDataFile(clientDirectory, route)
329
- const outputFragmentFile = staticRouteFragmentFile(clientDirectory, route)
330
- const outputFragmentDataFile = staticRouteFragmentDataFile(
331
- clientDirectory,
662
+ const request = staticRouteRequest(routing, route.path)
663
+ const artifact = await renderStaticRoute(
332
664
  route,
665
+ request,
666
+ render,
667
+ loadData,
668
+ renderFragment,
333
669
  )
334
- const request = new Request(
335
- new URL(joinRoutePath(routing, route.path), "http://flamefront.build"),
670
+
671
+ await writeStaticRouteArtifact(clientDirectory, route, artifact)
672
+ console.log(
673
+ `Generated ${relative(root, staticRouteFile(clientDirectory, route))}.`,
336
674
  )
337
- const rendered = documentParts(await render(request))
338
- const data = rendered.hasRouteData
339
- ? rendered.routeData
340
- : loadData
341
- ? await loadData(request)
342
- : null
343
- const fragment = renderFragment
344
- ? await renderFragment(request)
345
- : ({
346
- protocol: routeFragmentProtocol,
347
- route: route.path,
348
- boundary: route.entry,
349
- html: rendered.html,
350
- routeData: data,
351
- boundaries: [],
352
- status: rendered.status,
353
- } satisfies RouteFragmentArtifact)
354
-
355
- await mkdir(dirname(outputFile), { recursive: true })
356
- await writeFile(outputFile, rendered.html)
357
- await writeFile(outputDataFile, JSON.stringify(data ?? null))
358
- await writeFile(outputFragmentFile, fragment.html)
359
- await writeFile(outputFragmentDataFile, JSON.stringify(fragment))
360
- console.log(`Generated ${relative(root, outputFile)}.`)
361
675
  }
362
676
  }
363
677
 
package/src/octane.tsx CHANGED
@@ -125,6 +125,7 @@ export interface OctaneDocuments {
125
125
  options?: RenderDocumentOptions,
126
126
  ) => Promise<RenderedDocument>
127
127
  readonly loadRouteData: (request: Request) => Promise<Response>
128
+ readonly loadAction: (request: Request) => Promise<Response>
128
129
  readonly renderFragment: (request: Request) => Promise<RouteFragmentArtifact>
129
130
  }
130
131
 
@@ -139,6 +140,7 @@ interface StaticDocumentContext {
139
140
  readonly pathnameBase?: string
140
141
  readonly route?: { readonly id?: string }
141
142
  }[]
143
+ readonly actionHeaders?: Record<string, Headers>
142
144
  }
143
145
 
144
146
  function isRouteErrorResponse(
@@ -324,6 +326,26 @@ function routeData(context: StaticDocumentContext): unknown {
324
326
  return routeId ? (context.loaderData?.[routeId] ?? null) : null
325
327
  }
326
328
 
329
+ function actionResponseHeaders(
330
+ context: StaticDocumentContext,
331
+ ): Headers | undefined {
332
+ if (!context.actionHeaders) {
333
+ return undefined
334
+ }
335
+
336
+ const headers = new Headers()
337
+ let hasHeaders = false
338
+
339
+ for (const value of Object.values(context.actionHeaders)) {
340
+ for (const [name, header] of value) {
341
+ headers.append(name, header)
342
+ hasHeaders = true
343
+ }
344
+ }
345
+
346
+ return hasHeaders ? headers : undefined
347
+ }
348
+
327
349
  function remapShellRouteError(
328
350
  route: RouteDefinition | null,
329
351
  context: StaticDocumentContext,
@@ -520,7 +542,7 @@ export function createOctaneDocuments<
520
542
  readonly rendered: OctaneRenderResult
521
543
  }> => {
522
544
  const contextOptions: RouteRuntimeContextOptions = {
523
- purpose: "document",
545
+ purpose: ["GET", "HEAD"].includes(request.method) ? "document" : "action",
524
546
  mode,
525
547
  }
526
548
  const requestContext = await options.runtime.createRequestContext(
@@ -624,9 +646,16 @@ export function createOctaneDocuments<
624
646
  parts.hydrationScript,
625
647
  ))
626
648
 
649
+ const headers = actionResponseHeaders(staticContext)
650
+
627
651
  return mode === "static"
628
- ? { html, routeData: routeData(staticContext), status }
629
- : { html, status }
652
+ ? {
653
+ html,
654
+ routeData: routeData(staticContext),
655
+ status,
656
+ ...(headers ? { headers } : {}),
657
+ }
658
+ : { html, status, ...(headers ? { headers } : {}) }
630
659
  }
631
660
 
632
661
  const renderFragment = async (
@@ -656,6 +685,7 @@ export function createOctaneDocuments<
656
685
  return {
657
686
  renderDocument,
658
687
  loadRouteData: options.runtime.loadRouteData,
688
+ loadAction: options.runtime.loadAction,
659
689
  renderFragment,
660
690
  }
661
691
  }