mikser-io 10.9.0 → 10.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.
- package/package.json +1 -1
- package/src/plugins/assets.js +104 -9
- package/src/plugins/render/preset.js +35 -2
package/package.json
CHANGED
package/src/plugins/assets.js
CHANGED
|
@@ -118,6 +118,11 @@ export function assets(options = {}) {
|
|
|
118
118
|
const collection = 'presets'
|
|
119
119
|
const type = 'preset'
|
|
120
120
|
const checksumMap = new Set()
|
|
121
|
+
// Every marker on disk, indexed by the derivative it claims. Built beside
|
|
122
|
+
// checksumMap so forgetting a derivative's markers costs a lookup rather
|
|
123
|
+
// than a scan — this runs once per entity that renders, and a directory
|
|
124
|
+
// walk per entity is quadratic on an assets tree of any size.
|
|
125
|
+
const markersByDestination = new Map()
|
|
121
126
|
|
|
122
127
|
// A preset that matches nothing builds green and says nothing, which is
|
|
123
128
|
// how a mistyped pattern ships. `files({ outputFolder })` prefixes `name`
|
|
@@ -387,6 +392,39 @@ export function assets(options = {}) {
|
|
|
387
392
|
return result
|
|
388
393
|
}
|
|
389
394
|
|
|
395
|
+
// A marker outliving the render it describes is the whole defect.
|
|
396
|
+
//
|
|
397
|
+
// `isPresetRendered` is keyed on the SOURCE checksum, and an interrupted
|
|
398
|
+
// render does not change the source — so a derivative that was being
|
|
399
|
+
// rewritten when the process died keeps a marker that still matches, and
|
|
400
|
+
// every later build reports nothing to do over a half-written file.
|
|
401
|
+
// Measured on a 20KB fixture: delete a derivative, interrupt the
|
|
402
|
+
// re-render, and the site serves 10KB of it indefinitely.
|
|
403
|
+
//
|
|
404
|
+
// The marker means "a completed render produced this file", so it is
|
|
405
|
+
// removed before the render starts and written again by onComplete. An
|
|
406
|
+
// interrupted render then leaves no claim behind and the next ordinary
|
|
407
|
+
// build re-derives. The FILE is deliberately left alone: a preset that
|
|
408
|
+
// fails before writing anything still has a good derivative on disk, and
|
|
409
|
+
// removing it would take a working asset off the site until the next
|
|
410
|
+
// build.
|
|
411
|
+
// Where a preset puts a given entity. One implementation, because the
|
|
412
|
+
// reuse check, the render dispatch and the unmarked-derivative scan all
|
|
413
|
+
// need it and a second copy would drift from `presetUrl` the moment a
|
|
414
|
+
// preset grew an option.
|
|
415
|
+
function presetDestination(entity, preset) {
|
|
416
|
+
const name = preset.format ? changeExtension(entity.name, preset.format) : entity.name
|
|
417
|
+
return path.join(runtime.options.assetsFolder, preset.name, name)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function forgetPresetMarkers(entity) {
|
|
421
|
+
for (const marker of markersByDestination.get(entity.destination) ?? []) {
|
|
422
|
+
await rm(marker, { force: true })
|
|
423
|
+
checksumMap.delete(marker)
|
|
424
|
+
}
|
|
425
|
+
markersByDestination.delete(entity.destination)
|
|
426
|
+
}
|
|
427
|
+
|
|
390
428
|
async function renderPresets(entities, { rendererChanged = new Set() } = {}) {
|
|
391
429
|
const { presets, assetsMap } = runtime.state.assets
|
|
392
430
|
|
|
@@ -409,11 +447,7 @@ export function assets(options = {}) {
|
|
|
409
447
|
const { options: configOptions } = normalizePresetConfig(
|
|
410
448
|
options.presets?.[entityPreset]
|
|
411
449
|
)
|
|
412
|
-
|
|
413
|
-
if (entity.preset.format) {
|
|
414
|
-
destination = changeExtension(destination, entity.preset.format)
|
|
415
|
-
}
|
|
416
|
-
entity.destination = path.join(runtime.options.assetsFolder, entityPreset, destination)
|
|
450
|
+
entity.destination = presetDestination(entity, entity.preset)
|
|
417
451
|
// Two gates sit between a scheduled entity and a render: the
|
|
418
452
|
// manifest's "its source did not change", and this plugin's
|
|
419
453
|
// marker. A forced render clears both — a marker at the
|
|
@@ -421,6 +455,9 @@ export function assets(options = {}) {
|
|
|
421
455
|
// disregard.
|
|
422
456
|
const forced = rendererChanged.has(entityToRender.id)
|
|
423
457
|
const ignore = forced ? false : await isPresetRendered(entity)
|
|
458
|
+
// Asked after isPresetRendered, which reads the markers this
|
|
459
|
+
// removes.
|
|
460
|
+
if (!ignore) await forgetPresetMarkers(entity)
|
|
424
461
|
tasks.push({
|
|
425
462
|
entity,
|
|
426
463
|
options: {
|
|
@@ -661,12 +698,31 @@ export function assets(options = {}) {
|
|
|
661
698
|
})
|
|
662
699
|
|
|
663
700
|
onBeforeRender(async signal => {
|
|
664
|
-
const { assetsMap } = runtime.state.assets
|
|
701
|
+
const { assetsMap, presets } = runtime.state.assets
|
|
665
702
|
|
|
666
703
|
checksumMap.clear()
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
704
|
+
markersByDestination.clear()
|
|
705
|
+
// Scoped to the preset folders rather than the whole assets tree: the
|
|
706
|
+
// assets folder also carries what the files plugin symlinks into it,
|
|
707
|
+
// and those have no markers and never will. Unscoped, every one of
|
|
708
|
+
// them would read as an interrupted render on every cycle.
|
|
709
|
+
const presetFolders = Object.keys(runtime.state.assets.presets).map(name => `${name}/**/*`)
|
|
710
|
+
const files = presetFolders.length
|
|
711
|
+
? await globby(presetFolders, { cwd: runtime.options.assetsFolder, onlyFiles: true })
|
|
712
|
+
: []
|
|
713
|
+
const derivatives = new Set()
|
|
714
|
+
for (let file of files) {
|
|
715
|
+
const absolute = path.join(runtime.options.assetsFolder, file)
|
|
716
|
+
if (!file.endsWith('.md5')) {
|
|
717
|
+
derivatives.add(absolute)
|
|
718
|
+
continue
|
|
719
|
+
}
|
|
720
|
+
checksumMap.add(absolute)
|
|
721
|
+
// `<destination>.<revision>.md5` — the same shape the orphan
|
|
722
|
+
// sweep strips to get back to the derivative.
|
|
723
|
+
const destination = absolute.replace(/\.[^.]+\.md5$/, '')
|
|
724
|
+
if (!markersByDestination.has(destination)) markersByDestination.set(destination, [])
|
|
725
|
+
markersByDestination.get(destination).push(absolute)
|
|
670
726
|
}
|
|
671
727
|
|
|
672
728
|
const entitiesToRender = new Map()
|
|
@@ -674,6 +730,45 @@ export function assets(options = {}) {
|
|
|
674
730
|
// preset moved, or --render-presets asked for them.
|
|
675
731
|
const presetMoved = new Set()
|
|
676
732
|
|
|
733
|
+
// A derivative with no marker beside it is a render that was killed
|
|
734
|
+
// before it could write one.
|
|
735
|
+
//
|
|
736
|
+
// Nothing else notices. The file is present, so the engine's
|
|
737
|
+
// missing-output path does not fire; the source did not change, so
|
|
738
|
+
// the journal never mentions it; and the marker check that WOULD
|
|
739
|
+
// catch it is only consulted for entities something else already
|
|
740
|
+
// scheduled. So the derivative sits there half-written and every
|
|
741
|
+
// build reports nothing to do — which is the whole reason the marker
|
|
742
|
+
// is now removed before a render rather than merely written after
|
|
743
|
+
// one. Removing it only helps if its absence is read.
|
|
744
|
+
//
|
|
745
|
+
// Empty on any healthy build, and the catalog walk is behind that
|
|
746
|
+
// emptiness: this costs one Set lookup per cycle until something has
|
|
747
|
+
// actually gone wrong.
|
|
748
|
+
const unmarked = new Set()
|
|
749
|
+
for (const derivative of derivatives) {
|
|
750
|
+
if (!markersByDestination.has(derivative)) unmarked.add(derivative)
|
|
751
|
+
}
|
|
752
|
+
if (unmarked.size) {
|
|
753
|
+
for await (const candidate of iterateEntities({ collection: { $ne: collection } })) {
|
|
754
|
+
const candidatePresets = await getEntityPresets(candidate)
|
|
755
|
+
const damaged = candidatePresets.filter(name => presets[name]
|
|
756
|
+
&& unmarked.has(presetDestination(candidate, presets[name])))
|
|
757
|
+
if (!damaged.length) continue
|
|
758
|
+
assetsMap[candidate.id] ??= candidatePresets
|
|
759
|
+
// Forced past the reuse check as well as scheduled: the file
|
|
760
|
+
// on disk is exactly what must not be trusted, and
|
|
761
|
+
// isPresetRendered has no way to tell half-written bytes from
|
|
762
|
+
// finished ones.
|
|
763
|
+
presetMoved.add(candidate.id)
|
|
764
|
+
entitiesToRender.set(candidate.id, candidate)
|
|
765
|
+
}
|
|
766
|
+
useLogger().warn({ code: 'preset-unfinished', derivatives: [...unmarked], sources: entitiesToRender.size },
|
|
767
|
+
'Assets: %d derivative(s) have no completed-render marker and are being re-derived. '
|
|
768
|
+
+ 'A preset render did not finish — the file left behind is not trustworthy.',
|
|
769
|
+
unmarked.size)
|
|
770
|
+
}
|
|
771
|
+
|
|
677
772
|
// --render-presets [name]: re-derive though nothing moved.
|
|
678
773
|
//
|
|
679
774
|
// The escape hatch for what the incremental machinery cannot see — a
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir } from 'node:fs/promises'
|
|
1
|
+
import { mkdir, rm, stat } from 'node:fs/promises'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
|
|
4
4
|
// A renderer's `load` runs for EVERY entity in the cycle, not only the ones
|
|
@@ -23,9 +23,42 @@ export async function load({ entity, runtime }) {
|
|
|
23
23
|
runtime.preset = preset.default
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
async function describe(file) {
|
|
27
|
+
try {
|
|
28
|
+
const { mtimeMs, size } = await stat(file)
|
|
29
|
+
return { mtimeMs, size }
|
|
30
|
+
} catch { return null }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// A render that failed leaves nothing half-written behind.
|
|
34
|
+
//
|
|
35
|
+
// This is the one place user code is handed a final output path and left to
|
|
36
|
+
// fill it, and a half-written derivative there is not self-correcting. The
|
|
37
|
+
// marker `isPresetRendered` consults is keyed on the SOURCE checksum, and an
|
|
38
|
+
// interruption does not change the source; the file exists, so the engine's
|
|
39
|
+
// missing-output path does not fire either. Both gates therefore say "nothing
|
|
40
|
+
// to do" over a truncated file, for as long as nobody looks. Measured on a
|
|
41
|
+
// 20KB fixture: interrupt one re-render and every later build serves 10KB of
|
|
42
|
+
// it. Worse where the preset wraps a tool whose non-zero exit it does not
|
|
43
|
+
// check — it RESOLVES, the manifest snapshots the truncated bytes, and
|
|
44
|
+
// --audit-output then reads green, because it compares each output against
|
|
45
|
+
// the hash its own render recorded.
|
|
46
|
+
//
|
|
47
|
+
// Removed only when THIS render is what changed it. A preset that fails
|
|
48
|
+
// before writing anything still has a good derivative on disk, and deleting
|
|
49
|
+
// that would take a working asset off the site until the next build — so the
|
|
50
|
+
// file is measured before and after, and left alone if it did not move.
|
|
26
51
|
export async function render({ entity, options, config, context, plugins, runtime, state, logger }) {
|
|
27
52
|
await mkdir(path.dirname(entity.destination), { recursive: true })
|
|
28
|
-
await
|
|
53
|
+
const before = await describe(entity.destination)
|
|
54
|
+
try {
|
|
55
|
+
await runtime.preset({ entity, options, config, context, plugins, runtime, state, logger })
|
|
56
|
+
} catch (error) {
|
|
57
|
+
const after = await describe(entity.destination)
|
|
58
|
+
const touched = after && (!before || after.size !== before.size || after.mtimeMs !== before.mtimeMs)
|
|
59
|
+
if (touched) await rm(entity.destination, { force: true })
|
|
60
|
+
throw error
|
|
61
|
+
}
|
|
29
62
|
return entity.destination
|
|
30
63
|
}
|
|
31
64
|
|