mikser-io 9.7.0 → 9.12.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/docs/configuration.md +1 -0
- package/index.js +1 -0
- package/package.json +1 -1
- package/src/config.js +22 -0
- package/src/database/index.js +44 -10
- package/src/plugins/assets.js +36 -0
- package/src/plugins/sources.js +69 -0
- package/src/source.js +43 -12
- package/src/utils.js +74 -12
package/docs/configuration.md
CHANGED
|
@@ -59,6 +59,7 @@ These options are part of `runtime.options` and apply to the engine itself.
|
|
|
59
59
|
| `threads` | — | number | `4` | Worker thread count for the Piscina pools (`renderWorkers`, `postprocessWorkers`). Both pools are lazy (`minThreads: 0` + `idleTimeout: 30_000`) so INLINE-only workloads spin up zero workers. |
|
|
60
60
|
| `server` | `-s, --server [port]` | number\|boolean | — | When set, the engine creates a shared Express app on `runtime.options.app` and listens on the given port (default `3001`) after all plugins have mounted their routes. Plugins like `api` attach to it instead of starting their own server. The `outputFolder` is also served as a static catch-all route at `/` (plugin routes match first; anything that doesn't match falls through to the rendered output). Requires `express` to be installed. |
|
|
61
61
|
| `junk` | — | array\|false | built-in list | OS and file-manager litter, filtered out of both the scan and the watcher. The dot-prefixed files (`.DS_Store`, `._*`) were already invisible — globby defaults to `dot: false` and the watcher ignores leading dots — but the Windows ones are **not** dotfiles: `Thumbs.db` and `desktop.ini` were measurably scanned *and* watched, and became entities. The list is deliberately conservative (OS/file-manager artifacts and application lock files only, no `*.tmp`, `*.bak` or editor backups), because a filter that silently drops content is worse than the litter it prevents. `false` disables it; an array replaces it. See `isJunkPath` / `JUNK_IGNORE` in `src/utils.js`. Plugins that write metadata next to content add their own patterns with `registerJunk({ ignore, match })` — the engine provides the mechanism and the plugin the knowledge of what its files are called (`mikser-io-webdav` registers `*.nephelemeta`). Plugin registrations survive an array override, since narrowing the OS list is not a request to start importing a library's sidecars. |
|
|
62
|
+
| — (plugin) | — | object | — | `sources({ styles: { folder: 'styles', extensions: ['css'] } })` registers build inputs as catalog entities, one collection per key. A sidecar can then read them with `findEntities()`, whose queries land in the render's `refClosure` — so editing, adding or removing a part re-renders the bundle and nothing else. Reading the same files with `fs` instead works for one build and silently breaks watch, because the engine has no dependency on a file it never saw. Nothing is linked into `outputFolder`: these are inputs, not output. Named `sources` rather than `inputs` because `entity.inputs` already means something adjacent — bytes an output depends on without being entities at all. |
|
|
62
63
|
| `cors` / `no-cors` | `--cors` / `--no-cors` | boolean | — | Toggle CORS on the engine's shared Express app. See `src/server.js` for the extensible header arrays plugins push onto. |
|
|
63
64
|
| `server.requestTimeout` | — | number | node default (`300000`) | Milliseconds a single request may take, set on the underlying `http.Server`. Node's 5-minute default is effectively an **upload size limit expressed in seconds** — a large file over a slow link is indistinguishable from a stalled request, so it is cut off and the caller sees a truncated write rather than a readable error. Only reachable from the `http.Server`, which the engine owns, so a plugin that mounts an upload surface (`mikser-io-webdav`, `forms` with large attachments) cannot raise it for itself. `0` disables the cap: reasonable on a trusted-network build server, bad facing the internet, where it removes the only bound on how long a client can hold a connection open doing nothing. `headersTimeout` is clamped to stay at or below it. The server is also exposed as `runtime.options.httpServer`. |
|
|
64
65
|
| `url` | `-u, --url <url>` | string | — | Public URL where this mikser is reachable (e.g. `https://blog.me.com`). Validated, trailing slash stripped, stamped on `runtime.options.url`. Read by webhook-capable plugins for push-vs-poll gating (`url.startsWith('https://')`); used by anything that surfaces absolute URLs externally — MCP preview URLs returned to agents, forms share links, email tracking pixels. Plugins that just need internal URLs keep using `runtime.options.port`. |
|
package/index.js
CHANGED
|
@@ -24,6 +24,7 @@ export * from './src/routes.js'
|
|
|
24
24
|
// for the v9 plugin shape.
|
|
25
25
|
export { api } from './src/plugins/api.js'
|
|
26
26
|
export { assets } from './src/plugins/assets.js'
|
|
27
|
+
export { sources } from './src/plugins/sources.js'
|
|
27
28
|
export { commands } from './src/plugins/commands.js'
|
|
28
29
|
export { data } from './src/plugins/data.js'
|
|
29
30
|
export { documents } from './src/plugins/documents.js'
|
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -1,12 +1,34 @@
|
|
|
1
1
|
import runtime from './runtime.js'
|
|
2
2
|
import { useLogger } from './engine.js'
|
|
3
3
|
import { onLoad } from './lifecycle.js'
|
|
4
|
+
import { checksum } from './utils.js'
|
|
4
5
|
import path from 'node:path'
|
|
5
6
|
|
|
6
7
|
onLoad(async () => {
|
|
7
8
|
const logger = useLogger()
|
|
8
9
|
const configFile = path.resolve(runtime.options.config)
|
|
9
10
|
logger.info('Config: %s', configFile)
|
|
11
|
+
|
|
12
|
+
// Stamp the config so a change to it can invalidate the derived cache.
|
|
13
|
+
//
|
|
14
|
+
// Without this, editing mikser.config.js invalidated NOTHING: flipping
|
|
15
|
+
// an option that changes every page's destination reported "36 unchanged"
|
|
16
|
+
// and left the previous output in place. The config was genuinely read —
|
|
17
|
+
// --force applied it immediately — it simply took part in no
|
|
18
|
+
// invalidation, so the only symptom was output that did not match the
|
|
19
|
+
// config, with nothing saying so.
|
|
20
|
+
//
|
|
21
|
+
// The file's bytes only. A config that imports other modules will not
|
|
22
|
+
// notice a change in those, which is a real limit worth knowing rather
|
|
23
|
+
// than a reason to hash the whole module graph.
|
|
24
|
+
try {
|
|
25
|
+
runtime.options.configChecksum = await checksum(configFile)
|
|
26
|
+
} catch {
|
|
27
|
+
// No config file is a legitimate state (defaults all the way down);
|
|
28
|
+
// absent stamp means "nothing to compare", not "changed".
|
|
29
|
+
runtime.options.configChecksum = null
|
|
30
|
+
}
|
|
31
|
+
|
|
10
32
|
try {
|
|
11
33
|
const config = await import(configFile)
|
|
12
34
|
if (typeof config.default == 'function') {
|
package/src/database/index.js
CHANGED
|
@@ -247,10 +247,38 @@ export function createSqliteDatabase({
|
|
|
247
247
|
handle = new Database(dbPath)
|
|
248
248
|
setupConnection()
|
|
249
249
|
|
|
250
|
-
const
|
|
251
|
-
|
|
250
|
+
const stmtMeta = handle.prepare('SELECT value FROM mikser_meta WHERE key = ?')
|
|
251
|
+
const recorded = stmtMeta.get('schema_version')?.value
|
|
252
|
+
|
|
253
|
+
// A config change invalidates the cache for the same reason a version
|
|
254
|
+
// change does: the derived state was computed under different rules.
|
|
255
|
+
//
|
|
256
|
+
// Before this, editing mikser.config.js invalidated nothing — flipping
|
|
257
|
+
// an option that changes every page's destination reported "36
|
|
258
|
+
// unchanged" and left the previous output in place. The config was
|
|
259
|
+
// read; it simply took part in no invalidation, so the only symptom
|
|
260
|
+
// was output that did not match the config and nothing saying so.
|
|
261
|
+
//
|
|
262
|
+
// Treated exactly like a version mismatch rather than something
|
|
263
|
+
// narrower: a config edit can change how sources are PARSED (a mapper
|
|
264
|
+
// transform, documents() options) as well as how they are rendered,
|
|
265
|
+
// so invalidating only the render manifest would still leave stale
|
|
266
|
+
// entities. Per ADR-0002 the files are the source of truth, so
|
|
267
|
+
// rebuilding is always safe — just slower.
|
|
268
|
+
const recordedConfig = stmtMeta.get('config_checksum')?.value
|
|
269
|
+
const currentConfig = runtime.options.configChecksum ?? null
|
|
270
|
+
const configChanged = Boolean(recordedConfig && currentConfig && recordedConfig !== currentConfig)
|
|
271
|
+
|
|
252
272
|
let upgradedFromVersion = null
|
|
253
|
-
if (recorded && recorded !== version) {
|
|
273
|
+
if (configChanged && !(recorded && recorded !== version)) {
|
|
274
|
+
logger?.warn(
|
|
275
|
+
'Config changed since the last run. Wiping the cache and rebuilding from sources ' +
|
|
276
|
+
'(files are the source of truth — no source data is affected). Note this tracks the ' +
|
|
277
|
+
'bytes of %s only: a change in a module it imports is not seen.',
|
|
278
|
+
runtime.options.config,
|
|
279
|
+
)
|
|
280
|
+
}
|
|
281
|
+
if ((recorded && recorded !== version) || configChanged) {
|
|
254
282
|
// Schema mismatch on upgrade or downgrade. Per ADR-0002 the
|
|
255
283
|
// files on disk are the source of truth and this database
|
|
256
284
|
// is a derived cache, so the right behavior is to wipe the
|
|
@@ -261,10 +289,12 @@ export function createSqliteDatabase({
|
|
|
261
289
|
// expect a cold-start rebuild on this run. No data loss
|
|
262
290
|
// beyond the cache itself; everything in mikser.sqlite is
|
|
263
291
|
// recoverable from the working folder.
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
292
|
+
if (recorded && recorded !== version) {
|
|
293
|
+
logger?.warn(
|
|
294
|
+
'Database schema mismatch: stored=%s, current=%s. Wiping the cache and rebuilding from sources (files are the source of truth — no source data is affected).',
|
|
295
|
+
recorded, version,
|
|
296
|
+
)
|
|
297
|
+
}
|
|
268
298
|
handle.close()
|
|
269
299
|
handle = null
|
|
270
300
|
|
|
@@ -277,12 +307,16 @@ export function createSqliteDatabase({
|
|
|
277
307
|
}
|
|
278
308
|
}
|
|
279
309
|
|
|
280
|
-
|
|
310
|
+
// Non-null marks the provisioning context as firstRun/upgraded,
|
|
311
|
+
// which is the right shape for a config change too: what the
|
|
312
|
+
// provisioners see is an empty-state database either way.
|
|
313
|
+
upgradedFromVersion = recorded ?? 'config'
|
|
281
314
|
handle = new Database(dbPath)
|
|
282
315
|
setupConnection()
|
|
283
316
|
}
|
|
284
|
-
handle.prepare('INSERT OR REPLACE INTO mikser_meta (key, value) VALUES (?, ?)')
|
|
285
|
-
|
|
317
|
+
const stmtStamp = handle.prepare('INSERT OR REPLACE INTO mikser_meta (key, value) VALUES (?, ?)')
|
|
318
|
+
stmtStamp.run('schema_version', version)
|
|
319
|
+
if (currentConfig) stmtStamp.run('config_checksum', currentConfig)
|
|
286
320
|
|
|
287
321
|
// Build provisioning context. firstRun is true when the file
|
|
288
322
|
// didn't exist before this open OR when the schema mismatch
|
package/src/plugins/assets.js
CHANGED
|
@@ -86,19 +86,53 @@ export function assets(options = {}) {
|
|
|
86
86
|
const type = 'preset'
|
|
87
87
|
const checksumMap = new Set()
|
|
88
88
|
|
|
89
|
+
// A preset that matches nothing builds green and says nothing, which is
|
|
90
|
+
// how a mistyped pattern ships. `files({ outputFolder })` prefixes `name`
|
|
91
|
+
// and `meta.url` but NOT `id`, and `match` runs against `id` — so
|
|
92
|
+
// '/files/media/devices/**' looks right and matches zero. Tallied here,
|
|
93
|
+
// reported once per process at onFinalize.
|
|
94
|
+
const matchTally = { evaluated: 0, matched: new Set(), reported: false }
|
|
95
|
+
|
|
89
96
|
async function getEntityPresets(entity) {
|
|
90
97
|
const entityPresets = []
|
|
98
|
+
matchTally.evaluated++
|
|
91
99
|
for (let preset in (options.presets || {})) {
|
|
92
100
|
const { matches } = normalizePresetConfig(options.presets[preset])
|
|
93
101
|
for (let match of matches) {
|
|
94
102
|
if (matchEntity(entity, match)) {
|
|
95
103
|
entityPresets.push(preset)
|
|
104
|
+
matchTally.matched.add(preset)
|
|
96
105
|
}
|
|
97
106
|
}
|
|
98
107
|
}
|
|
99
108
|
return entityPresets
|
|
100
109
|
}
|
|
101
110
|
|
|
111
|
+
// Report presets that matched none of the entities this run evaluated.
|
|
112
|
+
//
|
|
113
|
+
// Deliberately phrased as what was OBSERVED rather than "this preset is
|
|
114
|
+
// broken": an incremental cycle only re-evaluates changed entities, so a
|
|
115
|
+
// preset can legitimately match nothing in a run of three. The count and
|
|
116
|
+
// the --force hint let the reader tell the two apart, which a bare
|
|
117
|
+
// "matched nothing" would not. Once per process, so watch mode is quiet.
|
|
118
|
+
function reportUnmatchedPresets(logger) {
|
|
119
|
+
if (matchTally.reported) return
|
|
120
|
+
const configured = Object.keys(options.presets || {})
|
|
121
|
+
if (!configured.length || !matchTally.evaluated) return
|
|
122
|
+
matchTally.reported = true
|
|
123
|
+
|
|
124
|
+
for (const preset of configured) {
|
|
125
|
+
if (matchTally.matched.has(preset)) continue
|
|
126
|
+
const { matches } = normalizePresetConfig(options.presets[preset])
|
|
127
|
+
logger.warn(
|
|
128
|
+
'Assets preset %j matched none of the %d entities evaluated (patterns: %s). ' +
|
|
129
|
+
'Patterns run against entity.id, which files({ outputFolder }) does NOT prefix — ' +
|
|
130
|
+
'the prefix appears on name and meta.url only. On an incremental run unchanged ' +
|
|
131
|
+
'entities are not re-evaluated; use --force to check the whole catalog.',
|
|
132
|
+
preset, matchTally.evaluated, matches.join(', '))
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
102
136
|
// Resolve a preset name to an importable module location. Local
|
|
103
137
|
// files in presetsFolder win; names with no local file fall back to
|
|
104
138
|
// an npm package named `mikser-io-preset-<name>`, resolved from the
|
|
@@ -437,6 +471,8 @@ export function assets(options = {}) {
|
|
|
437
471
|
const logger = useLogger()
|
|
438
472
|
const { presets } = runtime.state.assets
|
|
439
473
|
|
|
474
|
+
reportUnmatchedPresets(logger)
|
|
475
|
+
|
|
440
476
|
let revisions = await globby('**/*.md5', { cwd: runtime.options.assetsFolder })
|
|
441
477
|
for (let revision of revisions) {
|
|
442
478
|
const [preset] = revision.split(path.sep)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Register build inputs — styles/, js/ — as catalog entities.
|
|
2
|
+
//
|
|
3
|
+
// The gap this fills: a site that bundles assets needs a layout sidecar to
|
|
4
|
+
// read those files. Reading them with `fs` works for a one-shot build and
|
|
5
|
+
// SILENTLY breaks watch — the engine has no dependency on a file it never
|
|
6
|
+
// saw, so editing styles/sections/hero.css rebuilds nothing and the page is
|
|
7
|
+
// stale with nothing saying so. The first build is correct, which is what
|
|
8
|
+
// makes it expensive: the mistake only surfaces later as "why didn't my edit
|
|
9
|
+
// take effect", and the obvious workaround is the broken one.
|
|
10
|
+
//
|
|
11
|
+
// Registered as entities instead, a sidecar reads them with findEntities(),
|
|
12
|
+
// whose queries are recorded into the render's refClosure — so touching one
|
|
13
|
+
// part re-renders the bundle and nothing else, by the same mechanism that
|
|
14
|
+
// re-renders a page when a document it references changes.
|
|
15
|
+
//
|
|
16
|
+
// Deliberately NOT the `files` plugin: these are inputs, not output. Nothing
|
|
17
|
+
// is linked into out/, no meta.url is stamped, and they take no part in
|
|
18
|
+
// ADR-0011 served-path resolution. A stylesheet part is not a thing the site
|
|
19
|
+
// serves; it is a thing the site is built from.
|
|
20
|
+
//
|
|
21
|
+
// Named `sources` rather than `inputs` because `entity.inputs` already means
|
|
22
|
+
// something adjacent but different — bytes an entity's output depends on
|
|
23
|
+
// without them being entities at all (see inputHashOf). Two spellings of
|
|
24
|
+
// "input" meaning two things would be worse than a slightly generic name.
|
|
25
|
+
import path from 'node:path'
|
|
26
|
+
import { useSource } from '../source.js'
|
|
27
|
+
|
|
28
|
+
export function sources(options = {}) {
|
|
29
|
+
const collections = Object.entries(options)
|
|
30
|
+
return (core) => {
|
|
31
|
+
const { useLogger } = core
|
|
32
|
+
if (!collections.length) {
|
|
33
|
+
// No collections is a legitimate config (a flag turned them all
|
|
34
|
+
// off); nothing to register, and nothing to complain about.
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
for (const [collection, config] of collections) {
|
|
38
|
+
const {
|
|
39
|
+
folder = collection,
|
|
40
|
+
extensions = ['*'],
|
|
41
|
+
ignore = [],
|
|
42
|
+
// Content is the point — a sidecar bundling CSS needs the
|
|
43
|
+
// bytes, not just the path. Overridable for a collection
|
|
44
|
+
// that only needs to be *known* (an image manifest, say).
|
|
45
|
+
content = true,
|
|
46
|
+
load,
|
|
47
|
+
// Code-shaped, so the extension stays in `id` (two parts of
|
|
48
|
+
// the same name in different languages must not collide) and
|
|
49
|
+
// `name` keeps the folder-relative path without it.
|
|
50
|
+
stripExtensionFromId = false,
|
|
51
|
+
} = config ?? {}
|
|
52
|
+
|
|
53
|
+
useSource(core, {
|
|
54
|
+
collection,
|
|
55
|
+
type: 'source',
|
|
56
|
+
folder,
|
|
57
|
+
extensions,
|
|
58
|
+
ignore,
|
|
59
|
+
content,
|
|
60
|
+
stripExtensionFromId,
|
|
61
|
+
load: load ?? (async () => ({})),
|
|
62
|
+
progress: `${collection.replace(/^./, c => c.toUpperCase())} import`,
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
useLogger?.()?.debug('Sources registered: %s', collections.map(([c]) => c).join(', '))
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export default sources
|
package/src/source.js
CHANGED
|
@@ -43,7 +43,7 @@ import { globby } from 'globby'
|
|
|
43
43
|
import pMap from 'p-map'
|
|
44
44
|
import runtime from './runtime.js'
|
|
45
45
|
import { ACTION } from './constants.js'
|
|
46
|
-
import { checksum as fileChecksum, junkIgnore } from './utils.js'
|
|
46
|
+
import { checksum as fileChecksum, checksumOf, junkIgnore } from './utils.js'
|
|
47
47
|
import { findById, findEntities, checksumsByCollection } from './catalog.js'
|
|
48
48
|
import { useDatabase } from './database/index.js'
|
|
49
49
|
|
|
@@ -74,7 +74,14 @@ const SCAN_CONCURRENCY = 16
|
|
|
74
74
|
// no SQL) instead of per-file findById. The single-file chokidar
|
|
75
75
|
// event handler (no scan context) doesn't pass it and falls back to
|
|
76
76
|
// findById, which is fine for low-frequency one-off mutations.
|
|
77
|
-
|
|
77
|
+
// `bytes`, when given, is content the caller has ALREADY read: the checksum
|
|
78
|
+
// is derived from those exact bytes rather than from a second, independent
|
|
79
|
+
// read. That is the whole point — two reads of a file being written can
|
|
80
|
+
// disagree, and the losing combination (empty content + a checksum correct
|
|
81
|
+
// for the finished file) is permanent, because every later sync then
|
|
82
|
+
// short-circuits on "unchanged".
|
|
83
|
+
export async function gateChecksum(file, id, { reload = false, priorChecksums, bytes } = {}) {
|
|
84
|
+
const compute = () => (bytes !== undefined ? checksumOf(bytes) : fileChecksum(file))
|
|
78
85
|
const canGate = !reload
|
|
79
86
|
&& !runtime.options.force
|
|
80
87
|
&& !runtime.catalog?.cacheInvalidated
|
|
@@ -83,12 +90,12 @@ export async function gateChecksum(file, id, { reload = false, priorChecksums }
|
|
|
83
90
|
? priorChecksums.get(id)
|
|
84
91
|
: findById(id)?.checksum
|
|
85
92
|
if (priorChecksum) {
|
|
86
|
-
const current = await
|
|
93
|
+
const current = await compute()
|
|
87
94
|
if (priorChecksum === current) return null
|
|
88
95
|
return current
|
|
89
96
|
}
|
|
90
97
|
}
|
|
91
|
-
return await
|
|
98
|
+
return await compute()
|
|
92
99
|
}
|
|
93
100
|
|
|
94
101
|
// Delete sweep. After a scan, find every catalog entity in `collection`
|
|
@@ -263,9 +270,17 @@ export function useSource(core, options) {
|
|
|
263
270
|
const prefix = idPrefix ?? `/${collection}`
|
|
264
271
|
const cap = collection.replace(/^./, c => c.toUpperCase())
|
|
265
272
|
const progressLabel = progress ?? `${cap} import`
|
|
273
|
+
// A SINGLE extension must not go through brace syntax: `**/*.{css}`
|
|
274
|
+
// matches NOTHING in minimatch/globby — a one-element brace is not
|
|
275
|
+
// expanded — so a source declaring one extension silently imported zero
|
|
276
|
+
// files and reported "Styles loaded: 0" as though the folder were empty.
|
|
277
|
+
// Same shape as the other silent-declaration failures: green build,
|
|
278
|
+
// nothing there.
|
|
266
279
|
const pattern = extensions.includes('*')
|
|
267
280
|
? '**/*'
|
|
268
|
-
:
|
|
281
|
+
: extensions.length === 1
|
|
282
|
+
? `**/*.${extensions[0]}`
|
|
283
|
+
: `**/*.{${extensions.join(',')}}`
|
|
269
284
|
|
|
270
285
|
// Hot-reload — chokidar dispatches into onSync(collection) when
|
|
271
286
|
// a file inside the folder changes.
|
|
@@ -413,7 +428,26 @@ export function useSource(core, options) {
|
|
|
413
428
|
: `${prefix}/${relativePath.replace(/\\/g, '/')}`
|
|
414
429
|
scanned?.add(id)
|
|
415
430
|
|
|
416
|
-
|
|
431
|
+
// When this source stores content, read the file ONCE and let both
|
|
432
|
+
// the checksum and the stored body come from the same bytes. The
|
|
433
|
+
// previous shape hashed the file in gateChecksum and then read it
|
|
434
|
+
// again below — two reads, and a torn write between them persisted
|
|
435
|
+
// an empty body next to a valid checksum, permanently.
|
|
436
|
+
//
|
|
437
|
+
// No extra cost: the gate already read the whole file to hash it.
|
|
438
|
+
// Only for `content` sources; large media goes through files.js,
|
|
439
|
+
// which stores no body and must not be slurped into memory.
|
|
440
|
+
let bytes
|
|
441
|
+
if (content) {
|
|
442
|
+
try {
|
|
443
|
+
bytes = await readFile(file)
|
|
444
|
+
} catch (err) {
|
|
445
|
+
logger.warn('%s read failed for %s: %s', collection, name, err.message)
|
|
446
|
+
return
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const chksum = await gateChecksum(file, id, { reload, priorChecksums, bytes })
|
|
417
451
|
if (chksum === null) {
|
|
418
452
|
if (stats) stats.skipped++
|
|
419
453
|
return
|
|
@@ -430,12 +464,9 @@ export function useSource(core, options) {
|
|
|
430
464
|
checksum: chksum,
|
|
431
465
|
}
|
|
432
466
|
if (content) {
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
logger.warn('%s read failed for %s: %s', collection, name, err.message)
|
|
437
|
-
return
|
|
438
|
-
}
|
|
467
|
+
// Decoded from the bytes the checksum was taken over — not
|
|
468
|
+
// re-read. See the gate above.
|
|
469
|
+
base.content = bytes.toString('utf8')
|
|
439
470
|
}
|
|
440
471
|
try {
|
|
441
472
|
const extra = await load({ file, name, relativePath, entity: base })
|
package/src/utils.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import crypto from 'node:crypto'
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { hashFile } from 'hasha'
|
|
4
|
+
import { stat, readFile, writeFile, mkdir, unlink, open } from 'node:fs/promises'
|
|
4
5
|
import { createRequire } from 'node:module'
|
|
5
|
-
import TruncateStream from 'truncate-stream'
|
|
6
|
-
import { createReadStream } from 'node:fs'
|
|
7
6
|
import _ from 'lodash'
|
|
8
7
|
import { minimatch } from 'minimatch'
|
|
9
8
|
import path from 'path'
|
|
@@ -27,6 +26,18 @@ export function inputHashOf(entity) {
|
|
|
27
26
|
return crypto.createHash('sha1').update(JSON.stringify({
|
|
28
27
|
meta: entity.meta ?? null,
|
|
29
28
|
content: entity.content ?? null,
|
|
29
|
+
// `inputs` is how a plugin declares bytes that are NOT part of the
|
|
30
|
+
// entity's own content but that its output depends on. Whatever is
|
|
31
|
+
// put here participates in the hash, so a change to it invalidates
|
|
32
|
+
// every consumer through the normal refClosure path.
|
|
33
|
+
//
|
|
34
|
+
// The case that needed it: a layout's `.js` sidecar. It is the
|
|
35
|
+
// entity's data layer, it is not its content, and it was in no hash
|
|
36
|
+
// at all — so editing it re-rendered nothing, silently, in a fresh
|
|
37
|
+
// build. Moving the layout's gate `checksum` was not enough, because
|
|
38
|
+
// an entity that HAS content is hashed on {meta, content} and its
|
|
39
|
+
// checksum is ignored. This is the seam that was missing.
|
|
40
|
+
inputs: entity.inputs ?? null,
|
|
30
41
|
})).digest('hex')
|
|
31
42
|
}
|
|
32
43
|
|
|
@@ -301,17 +312,68 @@ export class AbortError extends Error {
|
|
|
301
312
|
}
|
|
302
313
|
}
|
|
303
314
|
|
|
315
|
+
const CHECKSUM_MAX_BYTES = 300 * 1024
|
|
316
|
+
|
|
317
|
+
// Checksum from bytes the CALLER ALREADY HAS — no I/O, and therefore no
|
|
318
|
+
// second read to disagree with the first.
|
|
319
|
+
//
|
|
320
|
+
// The hazard this exists to remove: a plugin that stores content does
|
|
321
|
+
//
|
|
322
|
+
// meta: { body: await readFile(source, 'utf8') },
|
|
323
|
+
// checksum: await checksum(source),
|
|
324
|
+
//
|
|
325
|
+
// which is two independent reads of the same file. A watcher firing on the
|
|
326
|
+
// truncate half of a write gets '' from readFile while checksum(), a moment
|
|
327
|
+
// later, sees the finished file. The entity is stored with an empty body and
|
|
328
|
+
// a checksum that is CORRECT FOR THE FINAL CONTENT — so every later sync
|
|
329
|
+
// short-circuits on "unchanged" and the empty body is permanent. Only
|
|
330
|
+
// --clear recovers it, and nothing anywhere reports a problem.
|
|
331
|
+
//
|
|
332
|
+
// Byte-compatible with checksum(uri) below, deliberately: the two must be
|
|
333
|
+
// interchangeable or swapping a caller over would invalidate its catalog.
|
|
334
|
+
export function checksumOf(content) {
|
|
335
|
+
const buf = Buffer.isBuffer(content) ? content : Buffer.from(String(content), 'utf8')
|
|
336
|
+
if (buf.length < CHECKSUM_MAX_BYTES) {
|
|
337
|
+
return createHash('md5').update(buf).digest('hex')
|
|
338
|
+
}
|
|
339
|
+
const head = createHash('md5').update(buf.subarray(0, CHECKSUM_MAX_BYTES)).digest('hex')
|
|
340
|
+
const tail = createHash('md5').update(buf.subarray(buf.length - CHECKSUM_MAX_BYTES)).digest('hex')
|
|
341
|
+
return `${buf.length}:${head}:${tail}`
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Checksum a file by path.
|
|
345
|
+
//
|
|
346
|
+
// Files under 300 KB are hashed whole. Larger ones are hashed at both ends
|
|
347
|
+
// plus their length, which reads 600 KB instead of gigabytes — the point of
|
|
348
|
+
// the truncation is that a 1.4 GB video must not be streamed on every cycle.
|
|
349
|
+
//
|
|
350
|
+
// The TAIL is new. The previous form was `size + md5(first 300KB)`, which
|
|
351
|
+
// silently misses any change beyond byte 307200 that preserves the file's
|
|
352
|
+
// length: the checksum matches, the sync reports "unchanged", and the edit
|
|
353
|
+
// is dropped exactly as permanently as the torn-read case above. Hashing
|
|
354
|
+
// both ends does not make this collision-proof — nothing short of a full
|
|
355
|
+
// hash does — but it turns "any late edit of the same length" into
|
|
356
|
+
// "a late edit that also collides on 128 bits".
|
|
304
357
|
export async function checksum(uri) {
|
|
305
|
-
const maxBytes = 300 * 1024
|
|
306
358
|
const { size } = await stat(uri)
|
|
307
|
-
if (size <
|
|
359
|
+
if (size < CHECKSUM_MAX_BYTES) {
|
|
308
360
|
return await hashFile(uri, { algorithm: 'md5' })
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
361
|
+
}
|
|
362
|
+
const head = await hashRange(uri, 0, CHECKSUM_MAX_BYTES)
|
|
363
|
+
const tail = await hashRange(uri, size - CHECKSUM_MAX_BYTES, CHECKSUM_MAX_BYTES)
|
|
364
|
+
return `${size}:${head}:${tail}`
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// md5 of `length` bytes starting at `start`. A positional read, so the
|
|
368
|
+
// bytes in between are never touched.
|
|
369
|
+
async function hashRange(uri, start, length) {
|
|
370
|
+
const handle = await open(uri, 'r')
|
|
371
|
+
try {
|
|
372
|
+
const buf = Buffer.allocUnsafe(length)
|
|
373
|
+
const { bytesRead } = await handle.read(buf, 0, length, start)
|
|
374
|
+
return createHash('md5').update(buf.subarray(0, bytesRead)).digest('hex')
|
|
375
|
+
} finally {
|
|
376
|
+
await handle.close()
|
|
315
377
|
}
|
|
316
378
|
}
|
|
317
379
|
|