flamefront 0.1.0 → 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/LICENSE.md +2 -91
- package/README.md +22 -8
- package/package.json +19 -21
- package/src/action-client.ts +187 -0
- package/src/action-transform.ts +258 -0
- package/src/action.ts +497 -0
- package/src/cli.ts +8 -2
- package/src/fetch.ts +38 -1
- package/src/fragment-client.ts +13 -1
- package/src/fragment-protocol.ts +2 -0
- package/src/fragment.tsx +1 -0
- package/src/index.ts +38 -0
- package/src/lifecycle.ts +397 -83
- package/src/octane.tsx +33 -3
- package/src/prerender-artifacts.ts +185 -0
- package/src/prerender.ts +376 -0
- package/src/remix-route-data.ts +6 -0
- package/src/route-data-client.ts +26 -0
- package/src/server.ts +108 -2
- package/src/srvx.ts +5 -0
- package/src/static-fragment-artifacts.ts +14 -7
- package/src/virtual-remix-routes.d.ts +1 -0
- package/src/vite-options.ts +29 -0
- package/src/vite.ts +130 -12
|
@@ -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
|
+
}
|
package/src/prerender.ts
ADDED
|
@@ -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
|
+
}
|
package/src/remix-route-data.ts
CHANGED
|
@@ -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,
|
package/src/route-data-client.ts
CHANGED
|
@@ -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
|
+
}
|