mikser-io 10.9.0 → 10.10.1
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 +133 -7
- 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,40 @@ 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
|
+
const destination = path.resolve(entity.destination)
|
|
422
|
+
for (const marker of markersByDestination.get(destination) ?? []) {
|
|
423
|
+
await rm(marker, { force: true })
|
|
424
|
+
checksumMap.delete(marker)
|
|
425
|
+
}
|
|
426
|
+
markersByDestination.delete(destination)
|
|
427
|
+
}
|
|
428
|
+
|
|
390
429
|
async function renderPresets(entities, { rendererChanged = new Set() } = {}) {
|
|
391
430
|
const { presets, assetsMap } = runtime.state.assets
|
|
392
431
|
|
|
@@ -409,11 +448,7 @@ export function assets(options = {}) {
|
|
|
409
448
|
const { options: configOptions } = normalizePresetConfig(
|
|
410
449
|
options.presets?.[entityPreset]
|
|
411
450
|
)
|
|
412
|
-
|
|
413
|
-
if (entity.preset.format) {
|
|
414
|
-
destination = changeExtension(destination, entity.preset.format)
|
|
415
|
-
}
|
|
416
|
-
entity.destination = path.join(runtime.options.assetsFolder, entityPreset, destination)
|
|
451
|
+
entity.destination = presetDestination(entity, entity.preset)
|
|
417
452
|
// Two gates sit between a scheduled entity and a render: the
|
|
418
453
|
// manifest's "its source did not change", and this plugin's
|
|
419
454
|
// marker. A forced render clears both — a marker at the
|
|
@@ -421,6 +456,9 @@ export function assets(options = {}) {
|
|
|
421
456
|
// disregard.
|
|
422
457
|
const forced = rendererChanged.has(entityToRender.id)
|
|
423
458
|
const ignore = forced ? false : await isPresetRendered(entity)
|
|
459
|
+
// Asked after isPresetRendered, which reads the markers this
|
|
460
|
+
// removes.
|
|
461
|
+
if (!ignore) await forgetPresetMarkers(entity)
|
|
424
462
|
tasks.push({
|
|
425
463
|
entity,
|
|
426
464
|
options: {
|
|
@@ -661,12 +699,26 @@ export function assets(options = {}) {
|
|
|
661
699
|
})
|
|
662
700
|
|
|
663
701
|
onBeforeRender(async signal => {
|
|
664
|
-
const { assetsMap } = runtime.state.assets
|
|
702
|
+
const { assetsMap, presets } = runtime.state.assets
|
|
665
703
|
|
|
666
704
|
checksumMap.clear()
|
|
705
|
+
markersByDestination.clear()
|
|
667
706
|
const checksumFiles = await globby('**/*.md5', { cwd: runtime.options.assetsFolder })
|
|
668
707
|
for (let checksumFile of checksumFiles) {
|
|
669
|
-
|
|
708
|
+
const marker = path.join(runtime.options.assetsFolder, checksumFile)
|
|
709
|
+
checksumMap.add(marker)
|
|
710
|
+
// `<destination>.<revision>.md5` — the same shape the orphan
|
|
711
|
+
// sweep strips to get back to the derivative.
|
|
712
|
+
//
|
|
713
|
+
// Resolved on both sides of the comparison, because the other
|
|
714
|
+
// side is a manifest destination and these two are built by
|
|
715
|
+
// different subsystems. It is a no-op while assetsFolder is
|
|
716
|
+
// derived from workingFolder (see onLoaded), which is why no test
|
|
717
|
+
// can tell it apart — normalization at the boundary, not a
|
|
718
|
+
// transform anything depends on.
|
|
719
|
+
const destination = path.resolve(marker.replace(/\.[^.]+\.md5$/, ''))
|
|
720
|
+
if (!markersByDestination.has(destination)) markersByDestination.set(destination, [])
|
|
721
|
+
markersByDestination.get(destination).push(marker)
|
|
670
722
|
}
|
|
671
723
|
|
|
672
724
|
const entitiesToRender = new Map()
|
|
@@ -674,6 +726,80 @@ export function assets(options = {}) {
|
|
|
674
726
|
// preset moved, or --render-presets asked for them.
|
|
675
727
|
const presetMoved = new Set()
|
|
676
728
|
|
|
729
|
+
// A derivative with no marker beside it is a render that was killed
|
|
730
|
+
// before it could write one.
|
|
731
|
+
//
|
|
732
|
+
// Nothing else notices. The file is present, so the engine's
|
|
733
|
+
// missing-output path does not fire; the source did not change, so
|
|
734
|
+
// the journal never mentions it; and the marker check that WOULD
|
|
735
|
+
// catch it is only consulted for entities something else already
|
|
736
|
+
// scheduled. So the derivative sits there half-written and every
|
|
737
|
+
// build reports nothing to do — which is the whole reason the marker
|
|
738
|
+
// is now removed before a render rather than merely written after
|
|
739
|
+
// one. Removing it only helps if its absence is read.
|
|
740
|
+
//
|
|
741
|
+
// Empty on any healthy build, and the catalog walk is behind that
|
|
742
|
+
// emptiness: this costs one Set lookup per cycle until something has
|
|
743
|
+
// actually gone wrong.
|
|
744
|
+
// Asked of the MANIFEST, not of the folder.
|
|
745
|
+
//
|
|
746
|
+
// The first version walked every file under each preset folder and
|
|
747
|
+
// called anything without a marker an interrupted render. But only
|
|
748
|
+
// the destination the engine hands the preset gets a marker, so a
|
|
749
|
+
// preset that legitimately writes MORE than one file — a poster frame
|
|
750
|
+
// beside a video — left permanent evidence of a failure that never
|
|
751
|
+
// happened. It warned on every build for ever, said the derivative
|
|
752
|
+
// was "being re-derived", and re-derived nothing, because no entity's
|
|
753
|
+
// destination ever matched the extra file. A warning that cries wolf
|
|
754
|
+
// on every build is worse than the silence it replaced, and it
|
|
755
|
+
// teaches people to ignore the one that matters.
|
|
756
|
+
//
|
|
757
|
+
// A file nothing claims is an ORPHAN, which is a different fault with
|
|
758
|
+
// its own report. This asks only about outputs the manifest recorded,
|
|
759
|
+
// which is also what makes the recovery possible: a snapshot carries
|
|
760
|
+
// the entity id, so there is nothing to reverse-map and no catalog to
|
|
761
|
+
// walk.
|
|
762
|
+
const presetRoots = Object.keys(presets)
|
|
763
|
+
.map(name => path.resolve(runtime.options.assetsFolder, name) + path.sep)
|
|
764
|
+
const unfinished = new Map()
|
|
765
|
+
for (const snapshot of runtime.manifest?.all?.() ?? []) {
|
|
766
|
+
if (!snapshot.destination) continue
|
|
767
|
+
const destination = path.resolve(snapshot.destination)
|
|
768
|
+
if (!presetRoots.some(root => destination.startsWith(root))) continue
|
|
769
|
+
if (markersByDestination.has(destination)) continue
|
|
770
|
+
// Gone entirely is a different fault, and the engine's
|
|
771
|
+
// missing-output path already has it. Reporting it here as well
|
|
772
|
+
// would name the wrong cause.
|
|
773
|
+
if (outputMissing(snapshot.destination)) continue
|
|
774
|
+
unfinished.set(snapshot.id, destination)
|
|
775
|
+
}
|
|
776
|
+
if (unfinished.size) {
|
|
777
|
+
const damaged = []
|
|
778
|
+
for (const [id, destination] of unfinished) {
|
|
779
|
+
const entity = await findEntity({ id })
|
|
780
|
+
// Source gone: the orphan sweep removes the derivative at
|
|
781
|
+
// finalize. Nothing to re-derive and nothing to say.
|
|
782
|
+
if (!entity) continue
|
|
783
|
+
assetsMap[entity.id] ??= await getEntityPresets(entity)
|
|
784
|
+
// Forced past the reuse check as well as scheduled: the file
|
|
785
|
+
// on disk is exactly what must not be trusted, and
|
|
786
|
+
// isPresetRendered cannot tell half-written bytes from
|
|
787
|
+
// finished ones.
|
|
788
|
+
presetMoved.add(entity.id)
|
|
789
|
+
entitiesToRender.set(entity.id, entity)
|
|
790
|
+
damaged.push(destination)
|
|
791
|
+
}
|
|
792
|
+
// Only when something is actually being re-derived. The warning
|
|
793
|
+
// claimed the action rather than reporting it, so a build that
|
|
794
|
+
// scheduled nothing still announced a recovery.
|
|
795
|
+
if (damaged.length) {
|
|
796
|
+
useLogger().warn({ code: 'preset-unfinished', derivatives: damaged },
|
|
797
|
+
'Assets: %d derivative(s) have no completed-render marker and are being re-derived. '
|
|
798
|
+
+ 'A preset render did not finish — the file left behind is not trustworthy.',
|
|
799
|
+
damaged.length)
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
677
803
|
// --render-presets [name]: re-derive though nothing moved.
|
|
678
804
|
//
|
|
679
805
|
// 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
|
|