flamefront 0.1.3 → 0.1.4

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/README.md CHANGED
@@ -76,6 +76,7 @@ Licensed under [MIT](./LICENSE.md).
76
76
 
77
77
  - [How Flamefront fits](./docs/index.md)
78
78
  - [Route rendering and data](./docs/routes.md)
79
+ - [Render the whole document](./docs/document.md)
79
80
  - [Forms and mutations](./docs/forms-and-mutations.md)
80
81
  - [Build and deployment](./docs/deployment.md)
81
82
  - [Migrating an existing app](./docs/brownfield-migration.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flamefront",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "private": false,
5
5
  "description": "Typed centralized route manifests for Octane.",
6
6
  "license": "MIT",
@@ -23,6 +23,7 @@
23
23
  ".": "./src/index.ts",
24
24
  "./action-client": "./src/action-client.ts",
25
25
  "./entry": "./src/entry.ts",
26
+ "./document": "./src/document.tsx",
26
27
  "./fetch": "./src/fetch.ts",
27
28
  "./fragment": "./src/fragment.tsx",
28
29
  "./octane": "./src/octane.tsx",
@@ -0,0 +1,106 @@
1
+ /** Internal, serializable description of a framework-owned document asset. */
2
+ export interface DocumentAsset {
3
+ readonly tag: "link" | "style" | "script"
4
+ readonly attributes: Readonly<Record<string, string>>
5
+ readonly content?: string
6
+ }
7
+
8
+ export interface DocumentAssets {
9
+ readonly head: readonly DocumentAsset[]
10
+ readonly scripts: readonly DocumentAsset[]
11
+ }
12
+
13
+ export const documentAssetAttribute = "data-flamefront-asset"
14
+
15
+ function decodeAttribute(value: string): string {
16
+ return value.replace(
17
+ /&(#x[\da-f]+|#\d+|amp|quot|apos|lt|gt);/gi,
18
+ (entity, name: string) => {
19
+ if (name[0] === "#") {
20
+ const code =
21
+ name[1].toLowerCase() === "x"
22
+ ? parseInt(name.slice(2), 16)
23
+ : Number(name.slice(1))
24
+
25
+ return code > 0 && code <= 0x10ffff
26
+ ? String.fromCodePoint(code)
27
+ : "\ufffd"
28
+ }
29
+
30
+ return (
31
+ (
32
+ { amp: "&", quot: '"', apos: "'", lt: "<", gt: ">" } as Record<
33
+ string,
34
+ string
35
+ >
36
+ )[name.toLowerCase()] ?? entity
37
+ )
38
+ },
39
+ )
40
+ }
41
+
42
+ /** Extract only asset tags; document metadata belongs to the document component. */
43
+ export function documentAssets(
44
+ template: string,
45
+ hydrationScript: string,
46
+ ): DocumentAssets {
47
+ const head: DocumentAsset[] = []
48
+ const scripts: DocumentAsset[] = []
49
+ // Consume comments and raw-text elements as units so their contents cannot
50
+ // masquerade as asset tags. Quoted attribute values may contain angle brackets.
51
+ const tags =
52
+ /<!--[\s\S]*?-->|<(script|style|title|textarea|template|link)\b((?:[^>"']|"[^"]*"|'[^']*')*)>(?:([\s\S]*?)<\/\1\s*>)?/gi
53
+
54
+ for (const match of `${template}${hydrationScript}`.matchAll(tags)) {
55
+ const tag = match[1]?.toLowerCase()
56
+
57
+ if (tag !== "script" && tag !== "style" && tag !== "link") {
58
+ continue
59
+ }
60
+
61
+ const attributes: Record<string, string> = {}
62
+
63
+ for (const attribute of match[2].matchAll(
64
+ /([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g,
65
+ )) {
66
+ attributes[attribute[1].toLowerCase()] = decodeAttribute(
67
+ attribute[2] ?? attribute[3] ?? attribute[4] ?? "",
68
+ )
69
+ }
70
+
71
+ attributes[documentAssetAttribute] = tag === "script" ? "scripts" : "head"
72
+ const asset: DocumentAsset = {
73
+ tag,
74
+ attributes,
75
+ ...(tag === "link" ? {} : { content: match[3] ?? "" }),
76
+ }
77
+
78
+ ;(tag === "script" ? scripts : head).push(asset)
79
+ }
80
+
81
+ return { head, scripts }
82
+ }
83
+
84
+ /** Snapshot server assets before hydration; no scripts are re-executed. */
85
+ export function readDocumentAssets(document: Document): DocumentAssets {
86
+ const read = (slot: string): DocumentAsset[] =>
87
+ Array.from(
88
+ document.querySelectorAll(`[${documentAssetAttribute}="${slot}"]`),
89
+ (element) => ({
90
+ tag: element.localName as DocumentAsset["tag"],
91
+ attributes: Object.fromEntries(
92
+ Array.from(element.attributes, ({ name, value }) => [
93
+ name,
94
+ name === "nonce"
95
+ ? ((element as HTMLElement).nonce ?? value)
96
+ : value,
97
+ ]),
98
+ ),
99
+ ...(element.localName === "link"
100
+ ? {}
101
+ : { content: element.textContent ?? "" }),
102
+ }),
103
+ )
104
+
105
+ return { head: read("head"), scripts: read("scripts") }
106
+ }
@@ -0,0 +1,22 @@
1
+ import { readFile } from "node:fs/promises"
2
+ import { resolve } from "node:path"
3
+
4
+ /** Vite still processes an HTML entry to discover and hash client assets. */
5
+ export async function readProjectTemplate(
6
+ root: string,
7
+ hasDocument: boolean,
8
+ ): Promise<string> {
9
+ try {
10
+ return await readFile(resolve(root, "index.html"), "utf8")
11
+ } catch (error) {
12
+ if (!hasDocument || (error as NodeJS.ErrnoException).code !== "ENOENT") {
13
+ throw error
14
+ }
15
+
16
+ return '<!doctype html><html><head></head><body><script type="module" src="/src/main.ts"></script></body></html>'
17
+ }
18
+ }
19
+
20
+ // Vite's evaluated SSR modules do not share the plugin's module cache. Keep
21
+ // development-only callbacks scoped by project, and release them on close.
22
+ export const devTemplateLoadersKey = "flamefront:dev-template-loaders"
@@ -0,0 +1,68 @@
1
+ import {
2
+ createContext,
3
+ createElement,
4
+ useContext,
5
+ type ReactNode,
6
+ } from "octane"
7
+ import type { DocumentAsset, DocumentAssets } from "./document-assets.ts"
8
+
9
+ /** Props supplied to the optional app document component. */
10
+ export interface DocumentProps {
11
+ /** The persistent shell and matched route, already inside router context. */
12
+ readonly children?: ReactNode
13
+ }
14
+
15
+ /** @internal Framework assets shared by server rendering and hydration. */
16
+ export const documentAssetsContext = createContext<DocumentAssets | null>(null)
17
+
18
+ function renderAssets(assets: readonly DocumentAsset[]) {
19
+ return assets.map(({ tag, attributes, content }) =>
20
+ createElement(tag, {
21
+ ...attributes,
22
+ ...(content === undefined
23
+ ? {}
24
+ : { dangerouslySetInnerHTML: { __html: content } }),
25
+ }),
26
+ )
27
+ }
28
+
29
+ /** Render Vite styles and asset links. Place once inside the document's head. */
30
+ export function Head() {
31
+ const assets = useContext(documentAssetsContext)
32
+
33
+ if (!assets) {
34
+ throw new Error("Head must be rendered inside a Flamefront document.")
35
+ }
36
+
37
+ return renderAssets(assets.head)
38
+ }
39
+
40
+ /** Render hydration data and client scripts. Place once at the end of body. */
41
+ export function Scripts() {
42
+ const assets = useContext(documentAssetsContext)
43
+
44
+ if (!assets) {
45
+ throw new Error("Scripts must be rendered inside a Flamefront document.")
46
+ }
47
+
48
+ return renderAssets(assets.scripts)
49
+ }
50
+
51
+ /** @internal Keep the document outside independently hydrated shell boundaries. */
52
+ export function createDocumentShell(
53
+ Document: (props: DocumentProps) => unknown,
54
+ Shell: (props: Record<string, unknown>) => unknown,
55
+ ) {
56
+ return (props: Record<string, unknown>) => {
57
+ const assets = useContext(documentAssetsContext)
58
+
59
+ // Fragment renders use a separate router root and return only the shell.
60
+ return assets ? (
61
+ <Document>
62
+ <Shell {...props} />
63
+ </Document>
64
+ ) : (
65
+ <Shell {...props} />
66
+ )
67
+ }
68
+ }
package/src/index.ts CHANGED
@@ -463,6 +463,8 @@ export type RouteDestination<Path extends string = RoutePath> =
463
463
  }
464
464
 
465
465
  export interface AppDefinition<T extends RouteDefinition = RouteDefinition> {
466
+ /** Optional project-root module ID for the component owning html, head, and body. */
467
+ readonly document?: string
466
468
  /** Octane/Vite project-root module ID for the persistent app shell. */
467
469
  readonly shell: string
468
470
  /** Hydration policy for the persistent shell region. */
@@ -1130,6 +1132,7 @@ function matchRoutes<T extends RouteDefinition>(
1130
1132
  /** Normalize and validate the application's explicit route graph. */
1131
1133
  export function defineApp<
1132
1134
  const T extends {
1135
+ readonly document?: string
1133
1136
  readonly shell: string
1134
1137
  readonly routes: readonly RouteConfig[]
1135
1138
  readonly shellHydration?: HydrationMode
@@ -1149,6 +1152,9 @@ export function defineApp<
1149
1152
  }
1150
1153
 
1151
1154
  assertString(options.shell, "app shell entry")
1155
+ if (options.document !== undefined) {
1156
+ assertString(options.document, "app document entry")
1157
+ }
1152
1158
 
1153
1159
  const hydrationDefaults = normalizeHydrationDefaults(
1154
1160
  options.hydrationDefaults,
@@ -3,7 +3,7 @@ import type { RouterDocument, RouterDocumentProps } from "./octane.tsx"
3
3
  import { shellIdentifierPrefix } from "./identifier-prefix.ts"
4
4
 
5
5
  export type OctaneClientApp<Route extends RouteDefinition = RouteDefinition> =
6
- Pick<AppDefinition<Route>, "match" | "prefetch">
6
+ Pick<AppDefinition<Route>, "match" | "prefetch" | "document">
7
7
 
8
8
  export interface OctaneClientRouter {
9
9
  readonly state: { readonly initialized: boolean }
@@ -14,10 +14,10 @@ export interface OctaneClientRouter {
14
14
 
15
15
  export interface StartOctaneClientOptions<
16
16
  Route extends RouteDefinition = RouteDefinition,
17
- Container = Element,
17
+ Container = Element | Document,
18
18
  > {
19
19
  readonly app: OctaneClientApp<Route>
20
- /** Defaults to the current document's `#root` element. */
20
+ /** Defaults to `document` for app documents, otherwise the `#root` element. */
21
21
  readonly root?: Container | null
22
22
  /** Use the same override in `createOctaneDocuments` on the server. */
23
23
  readonly routerDocument?: RouterDocument
@@ -39,6 +39,7 @@ export interface OctaneClientRuntime<
39
39
  readonly pathname: string
40
40
  readonly defaultRoot: Container | null
41
41
  readonly routerDocument: RouterDocument
42
+ readonly documentAssets?: RouterDocumentProps["documentAssets"]
42
43
  consumeHydrationData(): HydrationData
43
44
  createRoutePrefetcher(app: OctaneClientApp<Route>): Prefetch
44
45
  createClientRouter(options: {
@@ -121,14 +122,21 @@ export async function startOctaneClientWithRuntime<
121
122
  hydrationData,
122
123
  prefetch: runtime.createRoutePrefetcher(options.app),
123
124
  })
124
- const shouldHydrate = routeMatch.data.render !== "client"
125
+ const shouldHydrate =
126
+ Boolean(options.app.document) || routeMatch.data.render !== "client"
125
127
 
126
128
  if (shouldHydrate) {
127
129
  await waitForRouterInitialization(router)
128
130
  }
129
131
 
130
132
  const routerDocument = options.routerDocument ?? runtime.routerDocument
131
- const props: RouterDocumentProps = { router, context: undefined }
133
+ const props: RouterDocumentProps = {
134
+ router,
135
+ context: undefined,
136
+ ...(runtime.documentAssets
137
+ ? { documentAssets: runtime.documentAssets }
138
+ : {}),
139
+ }
132
140
  const clientRoot = shouldHydrate
133
141
  ? runtime.hydrateRoot(root, routerDocument, props, {
134
142
  identifierPrefix: shellIdentifierPrefix,
@@ -2,6 +2,7 @@ import type { DataRouter } from "@octanejs/remix-router"
2
2
  import { createRoot, hydrateRoot, type ComponentBody, type Root } from "octane"
3
3
  import type { RouteDefinition } from "./index.ts"
4
4
  import type { RouterDocumentProps } from "./octane.tsx"
5
+ import { readDocumentAssets } from "./document-assets.ts"
5
6
  import {
6
7
  startOctaneClientWithRuntime,
7
8
  type StartOctaneClientOptions,
@@ -26,9 +27,15 @@ export function startOctaneClient<Route extends RouteDefinition>(
26
27
  ): Promise<StartedOctaneClient<DataRouter, Root>> {
27
28
  return startOctaneClientWithRuntime(options, {
28
29
  pathname: window.location.pathname,
29
- defaultRoot: document.getElementById("root"),
30
+ defaultRoot: options.app.document
31
+ ? document
32
+ : document.getElementById("root"),
33
+ ...(options.app.document
34
+ ? { documentAssets: readDocumentAssets(document) }
35
+ : {}),
30
36
  routerDocument: RouterDocument,
31
- consumeHydrationData: consumeStaticRouterHydrationData,
37
+ consumeHydrationData: () =>
38
+ consumeStaticRouterHydrationData(Boolean(options.app.document)),
32
39
  createRoutePrefetcher,
33
40
  createClientRouter,
34
41
  renderRoot(root, component, props, options) {
@@ -1,6 +1,7 @@
1
1
  import { RouterProvider } from "@octanejs/remix-router/dom"
2
2
  import { createElement } from "octane"
3
3
  import { routeOutletHtmlContext } from "./fragment.tsx"
4
+ import { documentAssetsContext } from "./document.tsx"
4
5
  import type {
5
6
  RouterDocument as RouterDocumentComponent,
6
7
  RouterDocumentProps,
@@ -14,9 +15,17 @@ export const RouterDocument: RouterDocumentComponent = (
14
15
  readonly router: unknown
15
16
  }) => unknown
16
17
 
17
- return createElement(
18
+ const content = createElement(
18
19
  routeOutletHtmlContext.Provider,
19
20
  { value: props.outletHtml ?? null },
20
21
  createElement(Router, { router: props.router }),
21
22
  )
23
+
24
+ return props.documentAssets
25
+ ? createElement(
26
+ documentAssetsContext.Provider,
27
+ { value: props.documentAssets },
28
+ content,
29
+ )
30
+ : content
22
31
  }
package/src/octane.tsx CHANGED
@@ -4,6 +4,7 @@ import type {
4
4
  RouteDefinition,
5
5
  RouteParams,
6
6
  } from "./index.ts"
7
+ import { documentAssets, type DocumentAssets } from "./document-assets.ts"
7
8
  import type {
8
9
  DocumentMode,
9
10
  RouteRuntime,
@@ -28,6 +29,7 @@ import {
28
29
  export type { DocumentMode, RenderedDocument } from "./server.ts"
29
30
 
30
31
  export interface RouterDocumentProps {
32
+ readonly documentAssets?: DocumentAssets
31
33
  readonly router: unknown
32
34
  readonly context: unknown
33
35
  /** Server-only markup for the independently-owned routed outlet. */
@@ -231,6 +233,28 @@ export function composeDefaultDocument(
231
233
  .replace("</body>", `${hydrationScript}</body>`)
232
234
  }
233
235
 
236
+ function composeComponentDocument(body: string, css: string): string {
237
+ if (
238
+ !/<html\b/i.test(body) ||
239
+ !body.includes("</head>") ||
240
+ !body.includes("</body>")
241
+ ) {
242
+ throw new Error(
243
+ "The app document must render html, head, and body elements.",
244
+ )
245
+ }
246
+
247
+ const scripts = body.split(`id="${staticRouterHydrationScriptId}"`).length - 1
248
+
249
+ if (scripts !== 1) {
250
+ throw new Error(
251
+ "The app document must render Scripts exactly once inside body.",
252
+ )
253
+ }
254
+
255
+ return `<!doctype html>${body.replace(/^\s*<!doctype html>/i, "").replace("</head>", `${css}</head>`)}`
256
+ }
257
+
234
258
  async function loadDefaultRouter(): Promise<DocumentRouter> {
235
259
  const module = await import("./remix-router.ts")
236
260
 
@@ -534,6 +558,7 @@ export function createOctaneDocuments<
534
558
  mode: DocumentMode,
535
559
  route: RouteDefinition | null,
536
560
  renderOutlet = false,
561
+ template = "",
537
562
  ): Promise<{
538
563
  readonly router: DocumentRouter
539
564
  readonly dataRouter: unknown
@@ -586,6 +611,14 @@ export function createOctaneDocuments<
586
611
  const documentProps: RouterDocumentProps = {
587
612
  router: dataRouter,
588
613
  context,
614
+ ...(options.app.document
615
+ ? {
616
+ documentAssets: documentAssets(
617
+ template,
618
+ staticRouterHydrationScript(context),
619
+ ),
620
+ }
621
+ : {}),
589
622
  ...(outlet ? { outletHtml: outlet.html } : {}),
590
623
  }
591
624
  const rendered = renderer.renderToString(routerDocument, documentProps, {
@@ -620,6 +653,7 @@ export function createOctaneDocuments<
620
653
  mode,
621
654
  route,
622
655
  true,
656
+ template,
623
657
  )
624
658
  const status = staticContext.statusCode ?? 200
625
659
  const compositionContext: DocumentCompositionContext<Route> = {
@@ -639,12 +673,14 @@ export function createOctaneDocuments<
639
673
  }
640
674
  const html = await (options.composeDocument
641
675
  ? options.composeDocument(parts, compositionContext)
642
- : composeDefaultDocument(
643
- parts.template,
644
- parts.body,
645
- parts.css,
646
- parts.hydrationScript,
647
- ))
676
+ : options.app.document
677
+ ? composeComponentDocument(parts.body, parts.css)
678
+ : composeDefaultDocument(
679
+ parts.template,
680
+ parts.body,
681
+ parts.css,
682
+ parts.hydrationScript,
683
+ ))
648
684
 
649
685
  const headers = actionResponseHeaders(staticContext)
650
686
 
@@ -138,10 +138,26 @@ export function assembleStaticRouteArtifact(
138
138
  return null
139
139
  }
140
140
 
141
- let html = artifact.html
141
+ let index = 0
142
+ let valid = true
143
+ const html = artifact.html.replace(
144
+ /((?:src|href)=)(['"])(\/assets\/[^'"]+)\2/g,
145
+ (match, attribute: string, quote: string, asset: string) => {
146
+ if (index >= previous.length) {
147
+ return match
148
+ }
149
+
150
+ if (asset !== previous[index]) {
151
+ valid = false
152
+ return match
153
+ }
142
154
 
143
- for (let index = 0; index < previous.length; index += 1) {
144
- html = html.replaceAll(previous[index], current.assets[index])
155
+ return `${attribute}${quote}${current.assets[index++]}${quote}`
156
+ },
157
+ )
158
+
159
+ if (!valid || index !== previous.length) {
160
+ return null
145
161
  }
146
162
 
147
163
  return { ...artifact, html, template: current }
package/src/prerender.ts CHANGED
@@ -296,6 +296,7 @@ export async function renderingFingerprint(
296
296
  ): Promise<string> {
297
297
  const entries = [
298
298
  app.shell,
299
+ ...(app.document ? [app.document] : []),
299
300
  ...(layoutEntries(app.routeTree, route) ?? []),
300
301
  route.entry,
301
302
  "/src/entry-server.ts",
@@ -335,6 +336,7 @@ export async function renderingFingerprint(
335
336
  hydration: route.hydration,
336
337
  },
337
338
  shell: app.shell,
339
+ document: app.document,
338
340
  shellHydration: app.shellHydration,
339
341
  routing: app.routing,
340
342
  compiler: stableValue(options),
@@ -40,15 +40,20 @@ export type {
40
40
  export const staticRouterHydrationScriptId =
41
41
  "flamefront-static-router-hydration"
42
42
 
43
- /** Read and remove the hydration payload emitted by the server document adapter. */
44
- export function consumeStaticRouterHydrationData(): HydrationState | undefined {
43
+ /** Read hydration data; preserve its script when the document owns that node. */
44
+ export function consumeStaticRouterHydrationData(
45
+ preserveScript = false,
46
+ ): HydrationState | undefined {
45
47
  const data = (
46
48
  window as typeof window & {
47
49
  __staticRouterHydrationData?: unknown
48
50
  }
49
51
  ).__staticRouterHydrationData
50
52
 
51
- document.getElementById(staticRouterHydrationScriptId)?.remove()
53
+ if (!preserveScript) {
54
+ document.getElementById(staticRouterHydrationScriptId)?.remove()
55
+ }
56
+
52
57
  return data as HydrationState | undefined
53
58
  }
54
59
 
package/src/vite.ts CHANGED
@@ -1,4 +1,9 @@
1
1
  import fs from "node:fs"
2
+ import type { ViteDevServer } from "vite"
3
+ import {
4
+ readProjectTemplate,
5
+ devTemplateLoadersKey,
6
+ } from "./document-template.ts"
2
7
  import path from "node:path"
3
8
  import { pathToFileURL } from "node:url"
4
9
  import { compile as compileOctane } from "octane/compiler"
@@ -319,7 +324,7 @@ function generateConfigs(
319
324
  export function generateRemixRoutes(
320
325
  app: Pick<
321
326
  AppDefinition,
322
- "shell" | "shellHydration" | "routeTree" | "routing"
327
+ "shell" | "shellHydration" | "routeTree" | "routing" | "document"
323
328
  >,
324
329
  ): string {
325
330
  const rootId = "flamefront:shell:root"
@@ -328,8 +333,15 @@ export function generateRemixRoutes(
328
333
  app.shell,
329
334
  app.shellHydration,
330
335
  )
331
-
332
- return `// Generated by Flamefront.\nimport Shell from ${quote(app.shell)};\nimport { RouterDocument } from 'flamefront/octane/router-document';\nimport { createRouteBoundary, createRouteFragmentRoute } from 'flamefront/fragment';\nimport { loadRouteData, loadRouteFragment } from 'flamefront/remix-router/data';\nimport { submitRouteAction } from 'flamefront/remix-router/data';\n\nexport { RouterDocument };\nexport const routing = ${JSON.stringify(app.routing)};\nexport const routeMetadata = ${JSON.stringify(routeMetadata)};\n\nexport const routes = [\n\t{\n\t\tid: ${quote(rootId)},\n\t\tComponent: createRouteBoundary(Shell, ${JSON.stringify(routeMetadata[0])}),\n\t\thandle: { flamefront: ${JSON.stringify(routeMetadata[0])} },\n\t\tchildren: [\n${generateConfigs(app.routeTree, app.routing, 3)}\n\t\t],\n\t},\n];\n\n${generateRoutePreloaders(app.routeTree)}`
336
+ const documentImport = app.document
337
+ ? `import Document from ${quote(app.document)};\nimport { createDocumentShell } from 'flamefront/document';\n`
338
+ : ""
339
+ const shellComponent = `createRouteBoundary(Shell, ${JSON.stringify(routeMetadata[0])})`
340
+ const rootComponent = app.document
341
+ ? `createDocumentShell(Document, ${shellComponent})`
342
+ : shellComponent
343
+
344
+ return `// Generated by Flamefront.\n${documentImport}import Shell from ${quote(app.shell)};\nimport { RouterDocument } from 'flamefront/octane/router-document';\nimport { createRouteBoundary, createRouteFragmentRoute } from 'flamefront/fragment';\nimport { loadRouteData, loadRouteFragment } from 'flamefront/remix-router/data';\nimport { submitRouteAction } from 'flamefront/remix-router/data';\n\nexport { RouterDocument };\nexport const routing = ${JSON.stringify(app.routing)};\nexport const routeMetadata = ${JSON.stringify(routeMetadata)};\n\nexport const routes = [\n\t{\n\t\tid: ${quote(rootId)},\n\t\tComponent: ${rootComponent},\n\t\thandle: { flamefront: ${JSON.stringify(routeMetadata[0])} },\n\t\tchildren: [\n${generateConfigs(app.routeTree, app.routing, 3)}\n\t\t],\n\t},\n];\n\n${generateRoutePreloaders(app.routeTree)}`
333
345
  }
334
346
 
335
347
  /** Generate the server-only route-module importer used by loader endpoints. */
@@ -725,6 +737,8 @@ export function flamefront(options: FlamefrontOptions = {}) {
725
737
  })
726
738
  let root = process.cwd()
727
739
  let serverBuild = false
740
+ let devServer: ViteDevServer | undefined
741
+ let releaseDevTemplate: (() => void) | undefined
728
742
  let appPromise: Promise<AppDefinition> | undefined
729
743
  let manifestRevision = 0
730
744
  let manifestGlobDirectories: readonly string[] = []
@@ -773,6 +787,29 @@ export function flamefront(options: FlamefrontOptions = {}) {
773
787
  name: "flamefront:framework-modules",
774
788
  enforce: "pre" as const,
775
789
  configResolved: configureRoot,
790
+ configureServer(server: ViteDevServer) {
791
+ devServer = server
792
+ const registry = globalThis as typeof globalThis & {
793
+ [key: symbol]: Map<string, (url: string) => Promise<string>> | undefined
794
+ }
795
+ const key = Symbol.for(devTemplateLoadersKey)
796
+ const loaders = (registry[key] ??= new Map())
797
+ const loadTemplate = async (url: string) =>
798
+ server.transformIndexHtml(
799
+ url,
800
+ await readProjectTemplate(root, Boolean((await loadApp()).document)),
801
+ )
802
+
803
+ loaders.set(root, loadTemplate)
804
+ releaseDevTemplate = () => {
805
+ if (loaders.get(root) === loadTemplate) {
806
+ loaders.delete(root)
807
+ }
808
+ }
809
+ },
810
+ closeBundle() {
811
+ releaseDevTemplate?.()
812
+ },
776
813
  async buildStart() {
777
814
  await generateTypes()
778
815
  },
@@ -881,6 +918,14 @@ export function flamefront(options: FlamefrontOptions = {}) {
881
918
  importer?: string,
882
919
  resolveOptions: ResolveOptions = {},
883
920
  ) {
921
+ if (
922
+ id === path.resolve(root, "index.html") &&
923
+ !fs.existsSync(id) &&
924
+ (await loadApp()).document
925
+ ) {
926
+ return id
927
+ }
928
+
884
929
  if (id === remixRoutesId) {
885
930
  return resolvedRemixRoutesId
886
931
  }
@@ -979,6 +1024,14 @@ export function flamefront(options: FlamefrontOptions = {}) {
979
1024
  )
980
1025
  },
981
1026
  async load(id: string) {
1027
+ if (
1028
+ id === path.resolve(root, "index.html") &&
1029
+ !fs.existsSync(id) &&
1030
+ (await loadApp()).document
1031
+ ) {
1032
+ return readProjectTemplate(root, true)
1033
+ }
1034
+
982
1035
  if (id === resolvedRemixRoutesId) {
983
1036
  return generateRemixRoutes(await loadApp())
984
1037
  }
@@ -988,6 +1041,12 @@ export function flamefront(options: FlamefrontOptions = {}) {
988
1041
  }
989
1042
 
990
1043
  if (id === resolvedServerEntryId) {
1044
+ if (devServer && (await loadApp()).document) {
1045
+ // Development renders static routes too; production artifacts may be
1046
+ // stale and must not bypass Vite's current document or asset graph.
1047
+ return `import { createFetchServerEntry } from 'flamefront/fetch';\nexport function createServerEntry(options) {\n const entry = createFetchServerEntry({ ...options, ${output.adapter === "srvx" ? "middleware: undefined," : ""} assets: { loadTemplate: options.assets.loadTemplate ?? (({ request }) => globalThis[Symbol.for(${quote(devTemplateLoadersKey)})].get(${quote(root)})(new URL(request.url).pathname)) } });\n return ${output.adapter === "srvx" ? "{ ...entry, middleware: options.middleware }" : "entry"};\n}`
1048
+ }
1049
+
991
1050
  return generateServerEntry(output)
992
1051
  }
993
1052