@crombolo/render 0.0.0-stage → 0.10.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.
@@ -0,0 +1,398 @@
1
+ /**
2
+ * Content-addressed snapshots of the views tree.
3
+ *
4
+ * A view is a module, and `import()` caches modules in the runtime keyed on the
5
+ * resolved path. There is no eviction, a query string does not bust it on Bun,
6
+ * and the graph beneath a re-imported file stays pinned on every runtime. So a
7
+ * template edited under a running node keeps rendering as it did at first
8
+ * import, and no cache this codebase owns can change that.
9
+ *
10
+ * Only a new path is read fresh. A structure-preserving copy of the whole tree
11
+ * at a new path therefore re-resolves the whole graph — which is why the unit of
12
+ * versioning is the tree and not the file: `homePage.tsx` can be byte-identical
13
+ * while the layout it imports has changed.
14
+ *
15
+ * alias → <cacheDir>/<hash>/<alias>.tsx
16
+ *
17
+ * `sourceDir` stays the truth, for the template editor, `listComponents` and
18
+ * `views check`. A snapshot is only ever an import target.
19
+ */
20
+ import { createHash } from 'node:crypto'
21
+ import {
22
+ existsSync,
23
+ mkdirSync,
24
+ readdirSync,
25
+ renameSync,
26
+ rmSync,
27
+ statSync,
28
+ writeFileSync,
29
+ } from 'node:fs'
30
+ import { join, relative, sep } from 'node:path'
31
+
32
+ export interface SnapshotLimits {
33
+ /** How long a stat gate's answer is trusted before another render re-checks. */
34
+ gateTtlMs: number
35
+ /** How long after an announced write a node keeps checking eagerly. */
36
+ eagerWindowMs: number
37
+ /** The floor between eager checks, so eagerness is frequent rather than per render. */
38
+ eagerGateMs: number
39
+ /** The floor between swaps; a burst of writes collapses to the latest tree. */
40
+ minSwapIntervalMs: number
41
+ /** Distinct generations this process will load before it freezes. */
42
+ maxGenerations: number
43
+ /** Previous generations left on disk, for an instant return to one. */
44
+ keep: number
45
+ maxFiles: number
46
+ maxBytes: number
47
+ }
48
+
49
+ export const SNAPSHOT_LIMITS: SnapshotLimits = {
50
+ gateTtlMs: 5_000,
51
+ eagerWindowMs: 20_000,
52
+ eagerGateMs: 500,
53
+ minSwapIntervalMs: 10_000,
54
+ // At ~88 KB of registry per generation on a small tree, a budget of 250 is
55
+ // about 22 MB — enough that nobody reaches it by working, low enough that a
56
+ // runaway writer is bounded. A bigger tree leaks proportionally more.
57
+ maxGenerations: 250,
58
+ keep: 3,
59
+ maxFiles: 2_000,
60
+ maxBytes: 20 * 1024 * 1024,
61
+ }
62
+
63
+ /**
64
+ * Development wants to see an edit at once and does not need the burst floor.
65
+ *
66
+ * It evicts nothing at runtime: deleting a file `bun --watch` has imported
67
+ * restarts the process, which is the very thing a snapshot exists to avoid.
68
+ * `prepare` clears the lot at boot instead.
69
+ */
70
+ export const DEVELOPMENT_LIMITS: Partial<SnapshotLimits> = {
71
+ gateTtlMs: 500,
72
+ eagerGateMs: 0,
73
+ minSwapIntervalMs: 0,
74
+ keep: Number.POSITIVE_INFINITY,
75
+ }
76
+
77
+ export interface SnapshotStatus {
78
+ /** The generation being rendered, or undefined before the first is taken. */
79
+ hash: string | undefined
80
+ /** Distinct generations loaded by this process. */
81
+ generations: number
82
+ /** The budget is spent: the current generation keeps serving, new ones are refused. */
83
+ frozen: boolean
84
+ /** Why the last snapshot was refused, if one was. */
85
+ refused: string | undefined
86
+ }
87
+
88
+ export interface SnapshotOptions {
89
+ /** Where the views really live; read, never imported. */
90
+ sourceDir: string
91
+ /** Where snapshots are written. Must be outside `sourceDir`. */
92
+ cacheDir: string
93
+ limits?: Partial<SnapshotLimits>
94
+ /** Reported once per distinct problem, not per render. */
95
+ onProblem?: (message: string) => void
96
+ /**
97
+ * A generation came into use, with the one it replaced. Undefined on the first,
98
+ * which is every boot and not an event worth reporting.
99
+ */
100
+ onSwap?: (hash: string, previous: string | undefined) => void
101
+ now?: () => number
102
+ }
103
+
104
+ interface FileEntry {
105
+ /** Path relative to the source root, `/`-separated so a hash is portable. */
106
+ key: string
107
+ absolute: string
108
+ size: number
109
+ mtimeMs: number
110
+ }
111
+
112
+ const PARTIAL = '.partial-'
113
+
114
+ /** `/`-separated, whatever the host platform writes. */
115
+ const toKey = (path: string) => path.split(sep).join('/')
116
+
117
+ function walk(dir: string, root = dir, out: FileEntry[] = []): FileEntry[] {
118
+ // Sorted, so two nodes reading the same tree hash it the same way.
119
+ for (const name of readdirSync(dir).sort()) {
120
+ const absolute = join(dir, name)
121
+ const stats = statSync(absolute)
122
+ if (stats.isDirectory()) walk(absolute, root, out)
123
+ else
124
+ out.push({
125
+ key: toKey(relative(root, absolute)),
126
+ absolute,
127
+ size: stats.size,
128
+ mtimeMs: stats.mtimeMs,
129
+ })
130
+ }
131
+ return out
132
+ }
133
+
134
+ export class ViewSnapshots {
135
+ #sourceDir: string
136
+ #cacheDir: string
137
+ #limits: SnapshotLimits
138
+ #onProblem: (message: string) => void
139
+ #onSwap: (hash: string, previous: string | undefined) => void
140
+ #now: () => number
141
+
142
+ #current: { hash: string; dir: string } | undefined
143
+ /** Every hash this process has imported from: returning to one costs nothing. */
144
+ #loaded = new Set<string>()
145
+ #gateHash: string | undefined
146
+ #gateAt = 0
147
+ #swappedAt = 0
148
+ #eagerUntil = 0
149
+ #announced: string | undefined
150
+ #checking: Promise<void> | undefined
151
+ #forceNext = false
152
+ #frozen = false
153
+ #refused: string | undefined
154
+ #reported = new Set<string>()
155
+
156
+ constructor(options: SnapshotOptions) {
157
+ this.#sourceDir = options.sourceDir
158
+ this.#cacheDir = options.cacheDir
159
+ this.#limits = { ...SNAPSHOT_LIMITS, ...options.limits }
160
+ this.#onProblem = options.onProblem ?? (() => {})
161
+ this.#onSwap = options.onSwap ?? (() => {})
162
+ this.#now = options.now ?? Date.now
163
+ if (!relative(this.#sourceDir, this.#cacheDir).startsWith('..')) {
164
+ throw new Error(
165
+ `The views cache directory must sit outside the views directory; ${this.#cacheDir} is inside ${this.#sourceDir}.`,
166
+ )
167
+ }
168
+ }
169
+
170
+ /** Each distinct problem once: a render path must not fill the log. */
171
+ #problem(message: string): void {
172
+ this.#refused = message
173
+ if (this.#reported.has(message)) return
174
+ this.#reported.add(message)
175
+ this.#onProblem(message)
176
+ }
177
+
178
+ /** Where generations are written; the renderer maps paths out of it for people. */
179
+ get cacheDir(): string {
180
+ return this.#cacheDir
181
+ }
182
+
183
+ status(): SnapshotStatus {
184
+ return {
185
+ hash: this.#current?.hash,
186
+ generations: this.#loaded.size,
187
+ frozen: this.#frozen,
188
+ refused: this.#refused,
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Clears what a previous process left and takes the first generation, so no
194
+ * request pays for it and a crashed partial copy is never imported.
195
+ */
196
+ async prepare(): Promise<void> {
197
+ mkdirSync(this.#cacheDir, { recursive: true })
198
+ // Everything, not just the partials: a generation a previous process
199
+ // imported is not in this one's registry, so keeping it buys nothing and a
200
+ // fresh boot is the only safe moment to delete what `--watch` is watching.
201
+ for (const name of readdirSync(this.#cacheDir))
202
+ rmSync(join(this.#cacheDir, name), { recursive: true, force: true })
203
+ await this.#check()
204
+ await this.settle()
205
+ }
206
+
207
+ /**
208
+ * The directory to import from.
209
+ *
210
+ * Synchronous and cheap: it answers from the current generation and, when the
211
+ * gate has gone stale, starts a check in the background. A render never waits
212
+ * for one, and concurrent renders share the single check in flight.
213
+ */
214
+ directory(): string | undefined {
215
+ const now = this.#now()
216
+ // Eager means often, not every render: a stat walk is a ListObjects call on
217
+ // a mounted bucket, and a render must not pay for one.
218
+ const eager = now < this.#eagerUntil && now - this.#gateAt >= this.#limits.eagerGateMs
219
+ const due = eager || now - this.#gateAt >= this.#limits.gateTtlMs
220
+ if (due && !this.#checking) {
221
+ this.#checking = this.#check()
222
+ .catch((error: unknown) => {
223
+ this.#problem(`Could not refresh the views snapshot: ${String(error)}`)
224
+ })
225
+ .finally(() => {
226
+ this.#checking = undefined
227
+ })
228
+ }
229
+ return this.#current?.dir
230
+ }
231
+
232
+ /**
233
+ * Another node says a template was written.
234
+ *
235
+ * The hash is a hint for when to stop looking, never what to render: this node
236
+ * always snapshots the bytes it reads, so it converges on whatever the tree
237
+ * actually is even when the hash it was handed is already superseded. If it is
238
+ * never reached, the window closing ends the eagerness.
239
+ */
240
+ announce(hash?: string): void {
241
+ this.#announced = hash
242
+ this.#eagerUntil = this.#now() + this.#limits.eagerWindowMs
243
+ this.#gateAt = 0
244
+ // A deliberate write, relayed from the node that made it. The floor exists
245
+ // to absorb a flapping sync, which is by definition unannounced; applying it
246
+ // here would make an editor's save take the floor's length to reach the
247
+ // renderers even though somebody asked for it.
248
+ this.#forceNext = true
249
+ }
250
+
251
+ /**
252
+ * A template was written *here*: take the snapshot now and report its hash.
253
+ *
254
+ * The coalescing floor is skipped, for two reasons. An editor must see their
255
+ * own save rather than the previous template for the next ten seconds; and the
256
+ * floor exists to absorb a flapping sync, which is by definition something no
257
+ * node announced. A runaway announced writer is bounded by the generation
258
+ * budget instead, which is what the budget is for.
259
+ */
260
+ async refresh(): Promise<string | undefined> {
261
+ await this.settle()
262
+ this.#checking = this.#check(true).finally(() => {
263
+ this.#checking = undefined
264
+ })
265
+ await this.#checking
266
+ return this.#current?.hash
267
+ }
268
+
269
+ /** Waits for any check in flight; tests and `prepare` want the settled state. */
270
+ async settle(): Promise<void> {
271
+ while (this.#checking) await this.#checking
272
+ }
273
+
274
+ async #check(force = false): Promise<void> {
275
+ const now = this.#now()
276
+ this.#gateAt = now
277
+ if (!existsSync(this.#sourceDir)) return
278
+
279
+ const files = walk(this.#sourceDir)
280
+ const gate = createHash('sha256')
281
+ for (const file of files) gate.update(`${file.key}\0${file.size}\0${file.mtimeMs}\n`)
282
+ const gateHash = gate.digest('hex')
283
+
284
+ // Nothing the filesystem can see has moved.
285
+ if (gateHash === this.#gateHash && this.#current) {
286
+ if (this.#announced !== undefined && this.#announced === this.#current.hash)
287
+ this.#eagerUntil = 0
288
+ return
289
+ }
290
+
291
+ // A burst of unannounced changes collapses into one generation. The first
292
+ // snapshot is never delayed, or a boot would serve nothing, and an announced
293
+ // write is not a burst — see `refresh`.
294
+ const forced = force || this.#forceNext
295
+ this.#forceNext = false
296
+ if (!forced && this.#current && now - this.#swappedAt < this.#limits.minSwapIntervalMs) return
297
+
298
+ if (files.length > this.#limits.maxFiles) {
299
+ this.#problem(
300
+ `The views tree has ${files.length} files, over the ${this.#limits.maxFiles} a snapshot allows; the current views keep serving.`,
301
+ )
302
+ return
303
+ }
304
+ const bytes = files.reduce((total, file) => total + file.size, 0)
305
+ if (bytes > this.#limits.maxBytes) {
306
+ this.#problem(
307
+ `The views tree is ${Math.round(bytes / 1024)} KB, over the ${Math.round(this.#limits.maxBytes / 1024)} KB a snapshot allows; the current views keep serving.`,
308
+ )
309
+ return
310
+ }
311
+
312
+ this.#gateHash = gateHash
313
+ await this.#materialise(files)
314
+ }
315
+
316
+ /**
317
+ * Copies the tree once, hashing the bytes it writes.
318
+ *
319
+ * One pass, so the name always describes the content: hashing first and
320
+ * copying second could name a directory after bytes it does not hold. The
321
+ * copy lands in a partial directory and is renamed into place, so no render
322
+ * can import a half-written tree.
323
+ */
324
+ async #materialise(files: FileEntry[]): Promise<void> {
325
+ const partial = join(this.#cacheDir, `${PARTIAL}${crypto.randomUUID().slice(0, 8)}`)
326
+ const hasher = createHash('sha256')
327
+ try {
328
+ mkdirSync(partial, { recursive: true })
329
+ for (const file of files) {
330
+ const bytes = await Bun.file(file.absolute).bytes()
331
+ hasher.update(`${file.key}\0`)
332
+ hasher.update(bytes)
333
+ hasher.update('\n')
334
+ const target = join(partial, ...file.key.split('/'))
335
+ mkdirSync(join(target, '..'), { recursive: true })
336
+ writeFileSync(target, bytes)
337
+ }
338
+ const hash = hasher.digest('hex').slice(0, 16)
339
+
340
+ // Already rendering this exact tree: the copy was wasted but harmless.
341
+ if (hash === this.#current?.hash) return
342
+
343
+ // A deploy writing twenty files is not atomic, so a snapshot can catch a
344
+ // half-synced tree. If anything moved while copying, leave it for the next
345
+ // check rather than publish a generation nobody asked for.
346
+ const after = walk(this.#sourceDir)
347
+ if (!sameTree(files, after)) return
348
+
349
+ if (!this.#loaded.has(hash) && this.#loaded.size >= this.#limits.maxGenerations) {
350
+ this.#frozen = true
351
+ this.#problem(
352
+ `This node has loaded ${this.#loaded.size} view generations, its limit. The views it is serving keep serving and newer ones are refused; restart the node to pick them up.`,
353
+ )
354
+ return
355
+ }
356
+
357
+ const dir = join(this.#cacheDir, hash)
358
+ if (existsSync(dir)) rmSync(partial, { recursive: true, force: true })
359
+ else renameSync(partial, dir)
360
+
361
+ const previous = this.#current?.hash
362
+ this.#current = { hash, dir }
363
+ this.#loaded.add(hash)
364
+ this.#swappedAt = this.#now()
365
+ this.#refused = undefined
366
+ if (this.#announced === hash) this.#eagerUntil = 0
367
+ this.#onSwap(hash, previous)
368
+ this.#evict(hash)
369
+ } finally {
370
+ rmSync(partial, { recursive: true, force: true })
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Drops the oldest generations on disk, newest kept.
376
+ *
377
+ * Deleting one is safe even while its modules are loaded — a module lives in
378
+ * the registry, not on disk — and a return to that hash re-creates the path,
379
+ * which `import()` answers from the registry without reading anything.
380
+ */
381
+ #evict(current: string): void {
382
+ if (!Number.isFinite(this.#limits.keep)) return
383
+ const generations = readdirSync(this.#cacheDir)
384
+ .filter((name) => name !== current && !name.startsWith(PARTIAL))
385
+ .map((name) => ({ name, at: statSync(join(this.#cacheDir, name)).mtimeMs }))
386
+ .sort((a, b) => b.at - a.at)
387
+ for (const stale of generations.slice(this.#limits.keep))
388
+ rmSync(join(this.#cacheDir, stale.name), { recursive: true, force: true })
389
+ }
390
+ }
391
+
392
+ function sameTree(before: FileEntry[], after: FileEntry[]): boolean {
393
+ if (before.length !== after.length) return false
394
+ return before.every((file, index) => {
395
+ const other = after[index] as FileEntry
396
+ return file.key === other.key && file.size === other.size && file.mtimeMs === other.mtimeMs
397
+ })
398
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * URL segment generation.
3
+ *
4
+ * Mirrors Umbraco's DefaultUrlSegmentProvider: the `umbracoUrlName` property wins
5
+ * if set, otherwise the name is slugified — diacritics folded, lowercased, and
6
+ * separated with hyphens.
7
+ */
8
+ export const URL_NAME_ALIAS = 'umbracoUrlName'
9
+ /** Extra comma-separated addresses a page also answers on. */
10
+ export const URL_ALIAS_ALIAS = 'umbracoUrlAlias'
11
+ /** A content picker: visiting the page sends the visitor to the picked one. */
12
+ export const REDIRECT_ALIAS = 'umbracoRedirect'
13
+ /** Hides a page from navigation built with `visibleOnly`. */
14
+ export const NAVI_HIDE_ALIAS = 'umbracoNaviHide'
15
+
16
+ export function toUrlSegment(value: string): string {
17
+ return value
18
+ .normalize('NFKD')
19
+ .replace(/[̀-ͯ]/g, '')
20
+ .toLowerCase()
21
+ .replace(/[^a-z0-9]+/g, '-')
22
+ .replace(/^-+|-+$/g, '')
23
+ }
24
+
25
+ export function urlSegmentFor(name: string, urlNameOverride?: string | null): string {
26
+ const source = urlNameOverride && urlNameOverride.trim().length > 0 ? urlNameOverride : name
27
+ return toUrlSegment(source)
28
+ }
29
+
30
+ /** Joins segments into a site-root-relative path, always with a leading slash. */
31
+ export function joinPath(segments: readonly string[]): string {
32
+ const parts = segments.filter((segment) => segment.length > 0)
33
+ return parts.length === 0 ? '/' : `/${parts.join('/')}`
34
+ }