flamefront 0.1.1 → 0.1.3

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.
@@ -0,0 +1,185 @@
1
+ import { mkdir, writeFile } from "node:fs/promises"
2
+ import { dirname } from "node:path"
3
+ import type { RouteDefinition } from "./index.ts"
4
+ import type { NormalizedRoutingOptions } from "./index.ts"
5
+ import { joinBasename } from "./index.ts"
6
+ import type { RenderDocumentResult } from "./server.ts"
7
+ import {
8
+ routeFragmentProtocol,
9
+ type RouteFragmentArtifact,
10
+ } from "./fragment-client.ts"
11
+ import {
12
+ staticRouteDataFile,
13
+ staticRouteFile,
14
+ staticRouteFragmentDataFile,
15
+ staticRouteFragmentFile,
16
+ } from "./static-fragment-artifacts.ts"
17
+
18
+ /** The complete render result needed to publish one static route. */
19
+ export interface StaticRouteArtifact {
20
+ readonly html: string
21
+ readonly routeData: unknown
22
+ readonly fragment: RouteFragmentArtifact
23
+ readonly status: number
24
+ /** Template metadata used for safe asset-only assembly on a later build. */
25
+ readonly template?: {
26
+ readonly fingerprint: string
27
+ readonly assets: readonly string[]
28
+ }
29
+ }
30
+
31
+ /** The normalized fields needed by the build and cache layers. */
32
+ export function documentParts(document: RenderDocumentResult): {
33
+ readonly html: string
34
+ readonly status: number
35
+ readonly hasRouteData: boolean
36
+ readonly routeData: unknown
37
+ } {
38
+ if (typeof document === "string") {
39
+ return { html: document, status: 200, hasRouteData: false, routeData: null }
40
+ }
41
+
42
+ return {
43
+ html: document.html,
44
+ status: document.status ?? 200,
45
+ hasRouteData: "routeData" in document,
46
+ routeData: document.routeData,
47
+ }
48
+ }
49
+
50
+ /** Render all artifacts for a route before touching the deployment directory. */
51
+ export async function renderStaticRoute(
52
+ route: RouteDefinition,
53
+ request: Request,
54
+ render: (request: Request) => Promise<RenderDocumentResult>,
55
+ loadData?: (request: Request) => Promise<unknown>,
56
+ renderFragment?: (request: Request) => Promise<RouteFragmentArtifact>,
57
+ template?: string,
58
+ ): Promise<StaticRouteArtifact> {
59
+ const rendered = documentParts(await render(request))
60
+ const routeData = rendered.hasRouteData
61
+ ? rendered.routeData
62
+ : loadData
63
+ ? await loadData(request)
64
+ : null
65
+ const fragment = renderFragment
66
+ ? await renderFragment(request)
67
+ : {
68
+ protocol: routeFragmentProtocol,
69
+ route: route.path,
70
+ boundary: route.entry,
71
+ html: rendered.html,
72
+ routeData,
73
+ boundaries: [],
74
+ status: rendered.status,
75
+ }
76
+
77
+ return {
78
+ html: rendered.html,
79
+ routeData,
80
+ fragment,
81
+ status: rendered.status,
82
+ ...(template === undefined ? {} : { template: templateMetadata(template) }),
83
+ }
84
+ }
85
+
86
+ function assetReferences(template: string): readonly string[] {
87
+ return [...template.matchAll(/(?:src|href)=(['"])(\/assets\/[^'"]+)\1/g)].map(
88
+ (match) => match[2],
89
+ )
90
+ }
91
+
92
+ function normalizedTemplate(template: string): string {
93
+ let index = 0
94
+
95
+ return template.replace(
96
+ /(?:src|href)=(['"])(\/assets\/[^'"]+)\1/g,
97
+ (_match, quote: string) =>
98
+ `asset=${quote}__flamefront_asset_${index++}__${quote}`,
99
+ )
100
+ }
101
+
102
+ function templateMetadata(
103
+ template: string,
104
+ ): NonNullable<StaticRouteArtifact["template"]> {
105
+ return {
106
+ fingerprint: normalizedTemplate(template),
107
+ assets: assetReferences(template),
108
+ }
109
+ }
110
+
111
+ /** Fingerprint template structure while ignoring hashed asset names. */
112
+ export function templateFingerprint(template: string): string {
113
+ return normalizedTemplate(template)
114
+ }
115
+
116
+ /**
117
+ * Reassemble a cached document when only Vite's hashed asset names changed.
118
+ * Returns `null` when the surrounding template changed and a fresh render is
119
+ * required.
120
+ */
121
+ export function assembleStaticRouteArtifact(
122
+ artifact: StaticRouteArtifact,
123
+ template: string,
124
+ ): StaticRouteArtifact | null {
125
+ if (artifact.template === undefined) {
126
+ return null
127
+ }
128
+
129
+ const current = templateMetadata(template)
130
+
131
+ if (artifact.template.fingerprint !== current.fingerprint) {
132
+ return null
133
+ }
134
+
135
+ const previous = artifact.template.assets
136
+
137
+ if (previous.length !== current.assets.length) {
138
+ return null
139
+ }
140
+
141
+ let html = artifact.html
142
+
143
+ for (let index = 0; index < previous.length; index += 1) {
144
+ html = html.replaceAll(previous[index], current.assets[index])
145
+ }
146
+
147
+ return { ...artifact, html, template: current }
148
+ }
149
+
150
+ /** Publish one complete route artifact into the current client output. */
151
+ export async function writeStaticRouteArtifact(
152
+ clientDirectory: string,
153
+ route: RouteDefinition,
154
+ artifact: StaticRouteArtifact,
155
+ ): Promise<void> {
156
+ const outputFile = staticRouteFile(clientDirectory, route)
157
+
158
+ await mkdir(dirname(outputFile), { recursive: true })
159
+ await writeFile(outputFile, artifact.html)
160
+ await writeFile(
161
+ staticRouteDataFile(clientDirectory, route),
162
+ JSON.stringify(artifact.routeData ?? null),
163
+ )
164
+ await writeFile(
165
+ staticRouteFragmentFile(clientDirectory, route),
166
+ artifact.fragment.html,
167
+ )
168
+ await writeFile(
169
+ staticRouteFragmentDataFile(clientDirectory, route),
170
+ JSON.stringify(artifact.fragment),
171
+ )
172
+ }
173
+
174
+ /** Build the URL used by a static render without changing its output path. */
175
+ export function staticRouteRequest(
176
+ routing: Pick<NormalizedRoutingOptions, "basename">,
177
+ routePath: string,
178
+ ): Request {
179
+ return new Request(
180
+ new URL(
181
+ joinBasename(routing.basename, routePath),
182
+ "http://flamefront.build",
183
+ ),
184
+ )
185
+ }
@@ -0,0 +1,376 @@
1
+ import { createHash } from "node:crypto"
2
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises"
3
+ import { dirname, relative, resolve } from "node:path"
4
+ import { randomUUID } from "node:crypto"
5
+ import type {
6
+ AppDefinition,
7
+ LayoutDefinition,
8
+ RouteConfig,
9
+ RouteDefinition,
10
+ } from "./index.ts"
11
+ import { routeFragmentProtocol } from "./fragment-client.ts"
12
+ import {
13
+ templateFingerprint,
14
+ type StaticRouteArtifact,
15
+ } from "./prerender-artifacts.ts"
16
+
17
+ export interface PrerenderPage {
18
+ /** Concrete app-relative pathname, before the configured basename. */
19
+ readonly path: string
20
+ /** App-owned content version. Omit for the page-type default. */
21
+ readonly key?: string | null
22
+ }
23
+
24
+ export interface PrerenderContext {
25
+ readonly root: string
26
+ readonly routes: readonly RouteDefinition[]
27
+ }
28
+
29
+ export type PrerenderPageSource =
30
+ Iterable<PrerenderPage> | AsyncIterable<PrerenderPage>
31
+
32
+ export type PrerenderPageLoader = (
33
+ context: PrerenderContext,
34
+ ) => PrerenderPageSource | Promise<PrerenderPageSource>
35
+
36
+ export interface PrerenderCache {
37
+ get(key: string): Promise<Uint8Array | null>
38
+ put(key: string, value: Uint8Array): Promise<void>
39
+ }
40
+
41
+ export interface PrerenderOptions {
42
+ /** Enumerate concrete pages and app-owned cache keys. */
43
+ readonly pages?: PrerenderPageLoader
44
+ /** Use `false` to disable all cache reads and writes. */
45
+ readonly cache?: PrerenderCache | false
46
+ /** Version external inputs that are not represented by page keys. */
47
+ readonly revision?: string
48
+ }
49
+
50
+ export interface PrerenderFingerprintOptions {
51
+ readonly markdown?: unknown
52
+ readonly target?: string
53
+ readonly template?: string
54
+ }
55
+
56
+ const cacheEntryVersion = 1
57
+ const artifactVersion = "flamefront-static-artifact-v1"
58
+
59
+ function stableValue(value: unknown, seen = new WeakSet<object>()): unknown {
60
+ if (value === undefined) {
61
+ return "[undefined]"
62
+ }
63
+
64
+ if (typeof value === "function") {
65
+ return "[function]"
66
+ }
67
+
68
+ if (typeof value === "bigint") {
69
+ return `${value}n`
70
+ }
71
+
72
+ if (value instanceof Uint8Array) {
73
+ return Array.from(value)
74
+ }
75
+
76
+ if (value && typeof value === "object") {
77
+ if (seen.has(value)) {
78
+ return "[circular]"
79
+ }
80
+
81
+ seen.add(value)
82
+
83
+ if (Array.isArray(value)) {
84
+ return value.map((item) => stableValue(item, seen))
85
+ }
86
+
87
+ return Object.fromEntries(
88
+ Object.keys(value)
89
+ .sort()
90
+ .map((key) => [
91
+ key,
92
+ stableValue((value as Record<string, unknown>)[key], seen),
93
+ ]),
94
+ )
95
+ }
96
+
97
+ return value
98
+ }
99
+
100
+ /** Create a stable SHA-256 key from bytes, strings, or JSON-compatible values. */
101
+ export function hash(value: string | Uint8Array | unknown): string {
102
+ const input =
103
+ typeof value === "string"
104
+ ? value
105
+ : value instanceof Uint8Array
106
+ ? value
107
+ : JSON.stringify(stableValue(value))
108
+
109
+ return createHash("sha256").update(input).digest("hex")
110
+ }
111
+
112
+ export function cacheNamespace(root: string): string {
113
+ return hash(resolve(root)).slice(0, 24)
114
+ }
115
+
116
+ /** The default persistent cache used by a Flamefront project. */
117
+ export function createFilesystemPrerenderCache(root: string): PrerenderCache {
118
+ const directory = resolve(root, ".flamefront/cache", cacheNamespace(root))
119
+
120
+ function fileFor(key: string): string {
121
+ return resolve(directory, `${key}.bin`)
122
+ }
123
+
124
+ return {
125
+ async get(key) {
126
+ try {
127
+ return await readFile(fileFor(key))
128
+ } catch (error) {
129
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
130
+ return null
131
+ }
132
+
133
+ throw error
134
+ }
135
+ },
136
+ async put(key, value) {
137
+ const file = fileFor(key)
138
+ const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`
139
+
140
+ await mkdir(dirname(file), { recursive: true })
141
+ await writeFile(temporary, value)
142
+ await rename(temporary, file)
143
+ },
144
+ }
145
+ }
146
+
147
+ export interface CachedPrerenderArtifact {
148
+ readonly artifact: StaticRouteArtifact
149
+ readonly path: string
150
+ }
151
+
152
+ export function serializePrerenderArtifact(
153
+ path: string,
154
+ artifact: StaticRouteArtifact,
155
+ ): Uint8Array {
156
+ return Buffer.from(
157
+ JSON.stringify({
158
+ version: cacheEntryVersion,
159
+ path,
160
+ artifact,
161
+ }),
162
+ "utf8",
163
+ )
164
+ }
165
+
166
+ export function deserializePrerenderArtifact(
167
+ value: Uint8Array,
168
+ ): CachedPrerenderArtifact | null {
169
+ try {
170
+ const parsed = JSON.parse(Buffer.from(value).toString("utf8")) as {
171
+ version?: unknown
172
+ path?: unknown
173
+ artifact?: unknown
174
+ }
175
+ const artifact = parsed.artifact as Partial<StaticRouteArtifact> | undefined
176
+ const fragment = artifact?.fragment as
177
+ { html?: unknown; protocol?: unknown } | undefined
178
+
179
+ if (
180
+ parsed.version !== cacheEntryVersion ||
181
+ typeof parsed.path !== "string" ||
182
+ !artifact ||
183
+ typeof artifact.html !== "string" ||
184
+ typeof artifact.status !== "number" ||
185
+ !fragment ||
186
+ fragment.protocol !== routeFragmentProtocol ||
187
+ typeof fragment.html !== "string"
188
+ ) {
189
+ return null
190
+ }
191
+
192
+ return parsed as unknown as CachedPrerenderArtifact
193
+ } catch {
194
+ return null
195
+ }
196
+ }
197
+
198
+ function sourcePath(root: string, entry: string): string {
199
+ return resolve(root, entry.startsWith("/") ? `.${entry}` : entry)
200
+ }
201
+
202
+ async function sourceFingerprint(
203
+ root: string,
204
+ entry: string,
205
+ seen: Set<string>,
206
+ ): Promise<readonly [string, string][]> {
207
+ const file = sourcePath(root, entry)
208
+
209
+ if (seen.has(file)) {
210
+ return []
211
+ }
212
+
213
+ seen.add(file)
214
+
215
+ let source: string
216
+
217
+ try {
218
+ source = await readFile(file, "utf8")
219
+ } catch {
220
+ return [[entry, "[missing]"]]
221
+ }
222
+
223
+ const result: [string, string][] = [[entry, hash(source)]]
224
+ const imports = source.matchAll(
225
+ /(?:from\s*|import\s*\(\s*|import\s*)(["'])(\.?\.?\/[^"']+)\1/g,
226
+ )
227
+
228
+ for (const match of imports) {
229
+ const specifier = match[2]
230
+ const imported = resolve(dirname(file), specifier)
231
+ const candidates = [
232
+ imported,
233
+ ...[".ts", ".tsx", ".js", ".jsx", ".tsrx", ".md", ".mdx", ".json"].map(
234
+ (extension) => `${imported}${extension}`,
235
+ ),
236
+ resolve(imported, "index.ts"),
237
+ resolve(imported, "index.tsx"),
238
+ ]
239
+
240
+ let candidate: string | undefined
241
+
242
+ for (const path of candidates) {
243
+ try {
244
+ await readFile(path)
245
+ candidate = path
246
+ break
247
+ } catch {
248
+ // Try the next conventional extension.
249
+ }
250
+ }
251
+
252
+ if (candidate) {
253
+ const relativeEntry = `/${relative(root, candidate).replaceAll("\\", "/")}`
254
+
255
+ result.push(...(await sourceFingerprint(root, relativeEntry, seen)))
256
+ }
257
+ }
258
+
259
+ return result
260
+ }
261
+
262
+ function layoutEntries(
263
+ tree: readonly RouteConfig[],
264
+ target: RouteDefinition,
265
+ ancestors: string[] = [],
266
+ ): readonly string[] | null {
267
+ for (const config of tree) {
268
+ if (isLayout(config)) {
269
+ const nested = layoutEntries(config.children, target, [
270
+ ...ancestors,
271
+ config.entry,
272
+ ])
273
+
274
+ if (nested) {
275
+ return nested
276
+ }
277
+
278
+ continue
279
+ }
280
+
281
+ if (config.path === target.path) {
282
+ return ancestors
283
+ }
284
+ }
285
+
286
+ return null
287
+ }
288
+
289
+ /** Fingerprint the route-scoped rendering inputs used by one page. */
290
+ export async function renderingFingerprint(
291
+ root: string,
292
+ app: AppDefinition,
293
+ route: RouteDefinition,
294
+ path: string,
295
+ options: PrerenderFingerprintOptions = {},
296
+ ): Promise<string> {
297
+ const entries = [
298
+ app.shell,
299
+ ...(layoutEntries(app.routeTree, route) ?? []),
300
+ route.entry,
301
+ "/src/entry-server.ts",
302
+ ]
303
+ const sources: [string, string][] = []
304
+ const seen = new Set<string>()
305
+
306
+ for (const entry of entries) {
307
+ sources.push(...(await sourceFingerprint(root, entry, seen)))
308
+ }
309
+
310
+ let packageInputs: unknown = null
311
+
312
+ try {
313
+ const packageJson = JSON.parse(
314
+ await readFile(resolve(root, "package.json"), "utf8"),
315
+ ) as Record<string, unknown>
316
+
317
+ packageInputs = {
318
+ dependencies: packageJson.dependencies,
319
+ devDependencies: packageJson.devDependencies,
320
+ peerDependencies: packageJson.peerDependencies,
321
+ }
322
+ } catch {
323
+ packageInputs = "[missing]"
324
+ }
325
+
326
+ return hash({
327
+ artifactVersion,
328
+ fragmentProtocol: routeFragmentProtocol,
329
+ route: {
330
+ path: route.path,
331
+ concretePath: path,
332
+ entry: route.entry,
333
+ content: route.content,
334
+ render: route.render,
335
+ hydration: route.hydration,
336
+ },
337
+ shell: app.shell,
338
+ shellHydration: app.shellHydration,
339
+ routing: app.routing,
340
+ compiler: stableValue(options),
341
+ template: options.template
342
+ ? templateFingerprint(options.template)
343
+ : undefined,
344
+ sources,
345
+ packageInputs,
346
+ })
347
+ }
348
+
349
+ export function cacheKey(
350
+ path: string,
351
+ contentKey: string,
352
+ revision: string | undefined,
353
+ fingerprint: string,
354
+ namespace = "",
355
+ ): string {
356
+ return hash({
357
+ artifactVersion,
358
+ path,
359
+ contentKey,
360
+ revision: revision ?? "",
361
+ fingerprint,
362
+ namespace,
363
+ })
364
+ }
365
+
366
+ export function isParameterizedRoute(path: string): boolean {
367
+ return /[:*]/.test(path)
368
+ }
369
+
370
+ export function routeSourceFile(root: string, route: RouteDefinition): string {
371
+ return sourcePath(root, route.entry)
372
+ }
373
+
374
+ export function isLayout(config: RouteConfig): config is LayoutDefinition {
375
+ return "kind" in config && config.kind === "layout"
376
+ }
@@ -11,8 +11,14 @@ import {
11
11
  type RouteFragmentRoutingOptions,
12
12
  } from "./fragment-client.ts"
13
13
 
14
+ export {
15
+ submitRouteAction,
16
+ type ActionRequestOptions,
17
+ } from "./action-client.ts"
18
+
14
19
  export {
15
20
  createRouteDataClient,
21
+ invalidateRouteDataCache,
16
22
  type RouteDataClient,
17
23
  type RouteDataLoadOptions,
18
24
  type RouteDataRoutingOptions,
@@ -33,6 +33,8 @@ export interface RouteDataClient {
33
33
  options?: RouteDataLoadOptions,
34
34
  ): Promise<void>
35
35
  }
36
+ /** Drop cached live results so the next read observes a completed write. */
37
+ readonly invalidate: () => void
36
38
  }
37
39
 
38
40
  const defaultRouting = Object.freeze({
@@ -150,6 +152,14 @@ function createIsolatedRouteDataClient(routing: {
150
152
  }): RouteDataClient {
151
153
  const cache = new Map<string, Promise<unknown>>()
152
154
 
155
+ const invalidate = () => {
156
+ for (const key of cache.keys()) {
157
+ if (key.startsWith("live:")) {
158
+ cache.delete(key)
159
+ }
160
+ }
161
+ }
162
+
153
163
  const load = (<Data = unknown>(
154
164
  url: string | URL,
155
165
  source: RouteDataSource,
@@ -208,6 +218,7 @@ function createIsolatedRouteDataClient(routing: {
208
218
  prefetch: async (url, source, options) => {
209
219
  await load(url, source, options)
210
220
  },
221
+ invalidate,
211
222
  }
212
223
  }
213
224
 
@@ -232,3 +243,18 @@ export function createRouteDataClient(
232
243
  browserClients.set(key, client)
233
244
  return client
234
245
  }
246
+
247
+ /** Invalidate live route-data caches for one routing configuration or all apps. */
248
+ export function invalidateRouteDataCache(
249
+ options?: RouteDataRoutingOptions,
250
+ ): void {
251
+ if (!options) {
252
+ for (const client of browserClients.values()) {
253
+ client.invalidate()
254
+ }
255
+
256
+ return
257
+ }
258
+
259
+ createRouteDataClient(options).invalidate()
260
+ }