mikser-io 9.64.0 → 9.67.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/CLAUDE.md +17 -0
- package/app.js +60 -2
- package/docs/diagnostics.md +39 -0
- package/docs/rendering.md +18 -0
- package/index.js +3 -0
- package/package.json +1 -1
- package/src/engine.js +135 -103
- package/src/instance.js +418 -0
- package/src/plugins/render/asset.js +54 -2
- package/src/report.js +5 -0
- package/src/runtime.js +19 -0
package/CLAUDE.md
CHANGED
|
@@ -233,6 +233,23 @@ brevity.
|
|
|
233
233
|
- `{ name, options, postprocess, output?, setup?, teardown? }` →
|
|
234
234
|
postprocessor descriptor; stored in `runtime.postprocessors`.
|
|
235
235
|
Strings produce a v9 migration error pointing at the new shape.
|
|
236
|
+
- `instance.js` — one engine per working folder. A second `mikser` in a
|
|
237
|
+
folder a watcher holds FORWARDS its build over a unix socket and wears
|
|
238
|
+
the instance's log output and exit code; `--no-attach` opts out and
|
|
239
|
+
warns. Socket lives in `os.tmpdir()` keyed by a hash of the resolved
|
|
240
|
+
working folder — NOT under `runtime/`, because sun_path caps a socket
|
|
241
|
+
path at ~107 bytes and a nested working folder fails as `listen
|
|
242
|
+
EINVAL`. chmod 0600, so the filesystem permission is the access
|
|
243
|
+
decision. A forwarded build RESCANS (`runtime.rebuild()`), never
|
|
244
|
+
drains: a client can beat the inotify event for the file it just
|
|
245
|
+
wrote. Report-only commands (`--tool`, `--tools`, `--verify`,
|
|
246
|
+
`--explain`) forward too — they read, so a local run damaged nothing,
|
|
247
|
+
but a catalogue another process is mid-write in is not one anyone can
|
|
248
|
+
answer from. `runReportOnly()` in engine.js is the one implementation
|
|
249
|
+
both paths call. Exit code comes from `renderErrorCount()`, not
|
|
250
|
+
`process.exitCode` — the engine suppresses that in watch mode by
|
|
251
|
+
design. Config mismatch is refused by resolved PATH; config drift
|
|
252
|
+
under a running instance is detected by stat over `configCoverage`.
|
|
236
253
|
- `manager.js` — file watching (chokidar) and cron scheduling. `watch()`
|
|
237
254
|
turns file events into SYNC events — it is how a source folder becomes
|
|
238
255
|
entities, so pointing it at the output folder feeds output back in as
|
package/app.js
CHANGED
|
@@ -1,8 +1,66 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { setup } from './index.js'
|
|
5
|
+
import { forward } from './src/instance.js'
|
|
6
|
+
|
|
7
|
+
// Before anything is imported or read.
|
|
8
|
+
//
|
|
9
|
+
// A second `mikser` in a folder a watcher already holds used to start a second
|
|
10
|
+
// engine: two writers, one catalogue, one output tree, no warning. It forwards
|
|
11
|
+
// the request instead and wears the answer — which also means it never pays to
|
|
12
|
+
// import the config or the plugin graph, most of what a one-shot spends.
|
|
13
|
+
//
|
|
14
|
+
// Only the flags that decide WHERE are parsed here. Everything else is the
|
|
15
|
+
// running instance's business, and parsing it properly is what commander is
|
|
16
|
+
// for once we know we are staying.
|
|
17
|
+
function locate(argv) {
|
|
18
|
+
const value = (...names) => {
|
|
19
|
+
for (const name of names) {
|
|
20
|
+
const i = argv.indexOf(name)
|
|
21
|
+
if (i >= 0 && argv[i + 1] && !argv[i + 1].startsWith('-')) return argv[i + 1]
|
|
22
|
+
const inline = argv.find(a => a.startsWith(`${name}=`))
|
|
23
|
+
if (inline) return inline.slice(name.length + 1)
|
|
24
|
+
}
|
|
25
|
+
return null
|
|
26
|
+
}
|
|
27
|
+
const has = (...names) => names.some(n => argv.includes(n) || argv.some(a => names.some(x => a.startsWith(`${x}=`))))
|
|
28
|
+
|
|
29
|
+
// What to ask the instance for. Report-only commands go over the same
|
|
30
|
+
// socket as a build: they read, so running them locally never damaged
|
|
31
|
+
// anything, but a catalogue being written by another process is not a
|
|
32
|
+
// catalogue anyone can answer from — a --verify against a half-finished
|
|
33
|
+
// cycle reports drift that is not there.
|
|
34
|
+
const tool = value('--tool')
|
|
35
|
+
const explain = value('--explain')
|
|
36
|
+
const request = has('--tools') ? { type: 'report', tools: true, json: has('--json') }
|
|
37
|
+
: tool ? { type: 'report', tool, toolArgs: value('--tool-args'), json: has('--json') }
|
|
38
|
+
: explain ? { type: 'report', explain, json: has('--json') }
|
|
39
|
+
: has('--verify') ? { type: 'report', verify: true, json: has('--json') }
|
|
40
|
+
: { type: 'build', clear: has('--clear') }
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
workingFolder: value('--working-folder', '-i') ?? '.',
|
|
44
|
+
config: value('--config', '-c') ?? 'mikser.config.js',
|
|
45
|
+
// Commander's negated form: `attach` is true unless --no-attach said so.
|
|
46
|
+
attach: has('--no-attach') ? false : true,
|
|
47
|
+
request,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
3
50
|
|
|
4
51
|
async function main() {
|
|
52
|
+
const where = locate(process.argv.slice(2))
|
|
53
|
+
if (where.attach !== false) {
|
|
54
|
+
const code = await forward({
|
|
55
|
+
workingFolder: path.resolve(where.workingFolder),
|
|
56
|
+
config: path.resolve(where.workingFolder, where.config),
|
|
57
|
+
request: where.request,
|
|
58
|
+
})
|
|
59
|
+
// null means nobody was listening — carry on exactly as before.
|
|
60
|
+
if (code !== null) process.exit(code)
|
|
61
|
+
}
|
|
62
|
+
|
|
5
63
|
const mikser = await setup()
|
|
6
64
|
await mikser.start()
|
|
7
65
|
}
|
|
8
|
-
main()
|
|
66
|
+
main()
|
package/docs/diagnostics.md
CHANGED
|
@@ -27,6 +27,7 @@ engine source, the entry point is missing and belongs on this page.
|
|
|
27
27
|
| Did my schema validate anything at all? | [`schemas.names()`](#schemasnames--schemaslookup) |
|
|
28
28
|
| A tool answered emptily — is it broken, or is there nothing to find? | [`faults`](#faults) |
|
|
29
29
|
| I edited the build and nothing rebuilt | `--json` → `config.files` |
|
|
30
|
+
| Is another mikser already holding this folder? | run any command — it forwards, or says so |
|
|
30
31
|
| Why did this build do any work at all? | `--json` → `invalidated` |
|
|
31
32
|
| Did my new pattern get a chance to match? | `--json` → `evaluated` |
|
|
32
33
|
|
|
@@ -733,6 +734,44 @@ content is clean.
|
|
|
733
734
|
get, stats, config }` — the on-demand render cache, useful for asking
|
|
734
735
|
what has been rendered outside a build.
|
|
735
736
|
|
|
737
|
+
## One engine per folder
|
|
738
|
+
|
|
739
|
+
Running `mikser` while `mikser --watch` holds the same folder used to start a
|
|
740
|
+
second engine: two writers over one catalogue and one output tree, with no lock
|
|
741
|
+
and no warning. A `--clear` from either is what produces a cold rebuild that
|
|
742
|
+
reports nothing rendered.
|
|
743
|
+
|
|
744
|
+
A second invocation now forwards its build to the running instance and wears
|
|
745
|
+
its answer — the instance's log output, the instance's exit code. Nothing to
|
|
746
|
+
learn, and no question about *which* cycle covers your edit: you asked, so the
|
|
747
|
+
answer is about your request. It is also faster, because a forwarded command
|
|
748
|
+
never imports the config or the plugin graph, which is most of what a one-shot
|
|
749
|
+
spends its time on.
|
|
750
|
+
|
|
751
|
+
Three things it refuses or reports rather than guessing:
|
|
752
|
+
|
|
753
|
+
- **A different config.** If you resolve `mikser.config.prod.js` and the
|
|
754
|
+
instance is running `mikser.config.js`, it refuses. Building with the wrong
|
|
755
|
+
config is the accident this exists to prevent.
|
|
756
|
+
- **A config that moved under the instance.** It stats every file in
|
|
757
|
+
`config.files` and tells you to restart rather than building with a config
|
|
758
|
+
you have since edited.
|
|
759
|
+
- **A folder held by someone else.** `--no-attach` runs a private engine
|
|
760
|
+
anyway — for checking that a cold start works — and says the folder is held.
|
|
761
|
+
|
|
762
|
+
`--tool`, `--tools`, `--verify` and `--explain` forward as well, and for a
|
|
763
|
+
different reason than builds do. They only read, so running one locally never
|
|
764
|
+
damaged anything — it just could not be trusted: on a large site a local
|
|
765
|
+
`--verify` reads a catalogue the instance is halfway through writing and
|
|
766
|
+
reports drift that is a cycle in progress. The instance has the settled state
|
|
767
|
+
and the config that produced it. Exit codes cross the socket unchanged, so
|
|
768
|
+
`--explain` still answers 3 for an entity that is not there.
|
|
769
|
+
|
|
770
|
+
A forwarded build **rescans**; it does not drain what the watcher happened to
|
|
771
|
+
queue. A client that writes a file and immediately asks can beat the file
|
|
772
|
+
event, and draining would then build without the change that prompted the
|
|
773
|
+
request.
|
|
774
|
+
|
|
736
775
|
## Faults
|
|
737
776
|
|
|
738
777
|
A **fault** is a subsystem saying it cannot do its job — as opposed to an
|
package/docs/rendering.md
CHANGED
|
@@ -113,6 +113,24 @@ Inside a render, a `runtime` object is assembled and passed to all plugins:
|
|
|
113
113
|
|
|
114
114
|
Render plugins extend this object further (e.g. `runtime.href`, `runtime.asset`, `runtime.resource`).
|
|
115
115
|
|
|
116
|
+
`asset(preset, url)` returns the deployed URL of a preset derivative, relative
|
|
117
|
+
to the page asking for it:
|
|
118
|
+
|
|
119
|
+
```hbs
|
|
120
|
+
<img src="{{ (asset 'web' '/media/hero.jpg').url }}">
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The extension comes from the preset — every preset module exports a `format`,
|
|
124
|
+
and the helper uses it, so a template never has to repeat it. Pass a third
|
|
125
|
+
argument to override it, and mikser warns if that contradicts what the preset
|
|
126
|
+
actually produces.
|
|
127
|
+
|
|
128
|
+
It BUILDS the url rather than looking one up, which is worth knowing: nothing
|
|
129
|
+
it returns has been checked against a file. A preset name nothing declares
|
|
130
|
+
warns once, but a derivative that simply was not generated cannot be detected
|
|
131
|
+
here. Where you have the entity rather than a path, `meta.presets` is the
|
|
132
|
+
looked-up answer (ADR-0011).
|
|
133
|
+
|
|
116
134
|
### Logging from templates
|
|
117
135
|
|
|
118
136
|
The runtime exposes five logger functions — `log` (info), `warn`, `error`, `debug`, `trace` — that route through Mikser's central logger (`useLogger()` is resolved at call time, so progress-bar wrappers in `info` mode are honoured). Each renderer's auto-helper loop picks them up:
|
package/index.js
CHANGED
|
@@ -15,6 +15,9 @@ export * from './src/lifecycle.js'
|
|
|
15
15
|
// cache's constraints: main-thread only, so async, so knex, so portable to
|
|
16
16
|
// another engine.
|
|
17
17
|
export * from './src/database/durable.js'
|
|
18
|
+
// One engine per working folder: the control socket a second invocation
|
|
19
|
+
// forwards to, and the guard that says so when it cannot.
|
|
20
|
+
export * from './src/instance.js'
|
|
18
21
|
|
|
19
22
|
export * from './src/database/index.js'
|
|
20
23
|
export * from './src/journal.js'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mikser-io",
|
|
3
|
-
"version": "9.
|
|
3
|
+
"version": "9.67.0",
|
|
4
4
|
"description": "A mixer for content: entities in, configurable render pipelines, outputs of any kind. Static sites are the canonical recipe, not the definition — the same engine renders PDFs, emails and whatever a renderer plugin produces. Files are the source of truth, every lifecycle phase is observable, and the build graph is queryable by an agent.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"exports": {
|
package/src/engine.js
CHANGED
|
@@ -6,6 +6,7 @@ import _ from 'lodash'
|
|
|
6
6
|
import Piscina from 'piscina'
|
|
7
7
|
import runtime from './runtime.js'
|
|
8
8
|
import { onInitialize, onInitialized, onLoad, onImport, onRender, onCancel, onCancelled, onFinalized, onLoaded, onBeforePostprocess, onPostprocess, postprocessEntities } from './lifecycle.js'
|
|
9
|
+
import { instanceControl } from './instance.js'
|
|
9
10
|
import { useJournal, updateEntry } from './journal.js'
|
|
10
11
|
import { globby } from 'globby'
|
|
11
12
|
import { OPERATION, TASKS } from './constants.js'
|
|
@@ -88,6 +89,127 @@ function workerSafeOptions(opts) {
|
|
|
88
89
|
return result
|
|
89
90
|
}
|
|
90
91
|
|
|
92
|
+
// The report-only commands, as functions that RETURN their exit code.
|
|
93
|
+
//
|
|
94
|
+
// They used to be inline here and call process.exit, which is fine for a
|
|
95
|
+
// process whose only job is to answer one question and stop. It is not fine
|
|
96
|
+
// for the instance that has to answer the same question on behalf of a client
|
|
97
|
+
// and stay alive — and answering it there is the point, because a local run
|
|
98
|
+
// reads a catalogue another process is in the middle of writing.
|
|
99
|
+
//
|
|
100
|
+
// `request` carries the CLIENT's arguments. Reading runtime.options here would
|
|
101
|
+
// answer with the instance's own flags, which are whatever it happened to be
|
|
102
|
+
// started with.
|
|
103
|
+
export async function runReportOnly(request = {}) {
|
|
104
|
+
const logger = useLogger()
|
|
105
|
+
const {
|
|
106
|
+
tools = runtime.options.tools,
|
|
107
|
+
tool = runtime.options.tool,
|
|
108
|
+
toolArgs = runtime.options.toolArgs,
|
|
109
|
+
json = runtime.options.json,
|
|
110
|
+
explain = runtime.options.explain,
|
|
111
|
+
verify = runtime.options.verify,
|
|
112
|
+
} = request
|
|
113
|
+
|
|
114
|
+
if (tools) {
|
|
115
|
+
const schemas = toolSchemas()
|
|
116
|
+
if (json) {
|
|
117
|
+
process.stdout.write(JSON.stringify(schemas, null, 2) + '\n')
|
|
118
|
+
} else if (!schemas.length) {
|
|
119
|
+
logger.warn('No tools registered. The mcp plugin registers the standard set; '
|
|
120
|
+
+ 'this flag only lists and invokes what is registered.')
|
|
121
|
+
} else {
|
|
122
|
+
for (const schema of schemas) {
|
|
123
|
+
process.stdout.write(`${schema.name}\n ${String(schema.description).split('\n')[0]}\n`)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return 0
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (tool) {
|
|
130
|
+
// An empty catalog answers every question with a confident nothing —
|
|
131
|
+
// `null`, `total: 0`, "no render claims this destination" — all of
|
|
132
|
+
// which read as "the thing you asked about does not exist" when the
|
|
133
|
+
// truth is "nothing has been built here yet". Said once, before the
|
|
134
|
+
// answer, so it cannot be missed.
|
|
135
|
+
const entityCount = (() => {
|
|
136
|
+
try {
|
|
137
|
+
return useDatabase().handle
|
|
138
|
+
.prepare('SELECT count(*) AS n FROM mikser_entities').get()?.n ?? 0
|
|
139
|
+
} catch { return null }
|
|
140
|
+
})()
|
|
141
|
+
if (entityCount === 0 && !runtime.manifest?.size?.()) {
|
|
142
|
+
logger.warn('The catalog and manifest are empty — no build has run in this working '
|
|
143
|
+
+ 'folder. Tools answer from what the last build recorded, so this one will '
|
|
144
|
+
+ 'report nothing found. Run a build first.')
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let args = {}
|
|
148
|
+
if (toolArgs) {
|
|
149
|
+
try {
|
|
150
|
+
args = JSON.parse(toolArgs)
|
|
151
|
+
} catch (err) {
|
|
152
|
+
logger.error('--tool-args is not valid JSON: %s', err.message)
|
|
153
|
+
return 3
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
let result
|
|
157
|
+
try {
|
|
158
|
+
result = await invokeTool(tool, args)
|
|
159
|
+
} catch (err) {
|
|
160
|
+
logger.error('%s', err.message)
|
|
161
|
+
return 3
|
|
162
|
+
}
|
|
163
|
+
process.stdout.write(toolResultText(result) + '\n')
|
|
164
|
+
// A tool that reports failure must not exit 0 — an agent reading CLI
|
|
165
|
+
// output has only the exit code to branch on.
|
|
166
|
+
return toolResultFailed(result) ? 1 : 0
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (explain) {
|
|
170
|
+
// Exit codes:
|
|
171
|
+
// 0 — the entity was found and described
|
|
172
|
+
// 3 — not in the catalog (distinct from --verify's 1/2, which are
|
|
173
|
+
// about output drift; "no such entity" is neither clean nor
|
|
174
|
+
// corrupt, it is a question that could not be answered)
|
|
175
|
+
const { explain: explainEntity, formatExplain } = await import('./explain.js')
|
|
176
|
+
const report = await explainEntity(explain)
|
|
177
|
+
process.stdout.write((json ? JSON.stringify(report, null, 2) : formatExplain(report)) + '\n')
|
|
178
|
+
return report.found ? 0 : 3
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (verify) {
|
|
182
|
+
if (!runtime.manifest) {
|
|
183
|
+
logger.error('Verify: no manifest available — nothing to check against')
|
|
184
|
+
return 2
|
|
185
|
+
}
|
|
186
|
+
const { verdict, missing, mismatched, unverifiable, orphaned, collisions } =
|
|
187
|
+
await runtime.manifest.verify()
|
|
188
|
+
const total = runtime.manifest.size()
|
|
189
|
+
|
|
190
|
+
for (const e of missing) logger.error('Missing: %s (entity %s)', e.destination, e.id)
|
|
191
|
+
for (const e of mismatched) logger.error('Mismatched: %s (entity %s)%s', e.destination, e.id,
|
|
192
|
+
e.writtenBy ? ` — the bytes on disk are ${e.writtenBy}'s` : '')
|
|
193
|
+
for (const e of unverifiable) logger.warn('No hash: %s (entity %s)', e.destination, e.id)
|
|
194
|
+
for (const e of orphaned) logger.warn('Orphan: %s', e.path)
|
|
195
|
+
// Named per destination: "two entities write here" is only actionable
|
|
196
|
+
// if you know which two.
|
|
197
|
+
for (const c of collisions) logger.warn('Collision: %s ← %s', c.destination, c.entities.join(', '))
|
|
198
|
+
|
|
199
|
+
// Level picked from the verdict, because the level IS the marker in
|
|
200
|
+
// pino-pretty's messageFormat: notice renders 🟢, warn 🟡, error 🔴. A
|
|
201
|
+
// fixed `notice` prints a green tick next to the word FAIL, which
|
|
202
|
+
// reads as success at a glance even though the exit code is right.
|
|
203
|
+
const report = verdict === 'FAIL' ? logger.error : verdict === 'WARN' ? logger.warn : logger.notice
|
|
204
|
+
report.call(logger,
|
|
205
|
+
'Verify %s: %d snapshots, %d missing, %d mismatched, %d unverifiable, %d orphaned, %d collisions',
|
|
206
|
+
verdict, total, missing.length, mismatched.length, unverifiable.length, orphaned.length, collisions.length)
|
|
207
|
+
return verdict === 'FAIL' ? 2 : verdict === 'WARN' ? 1 : 0
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return null // not a report-only request
|
|
211
|
+
}
|
|
212
|
+
|
|
91
213
|
export async function setup(options) {
|
|
92
214
|
runtime.options.threads = options?.threads !== undefined ? options.threads : 4
|
|
93
215
|
runtime.engine = {
|
|
@@ -132,10 +254,16 @@ export async function setup(options) {
|
|
|
132
254
|
// need an agent surface configured when `--verify` does not.
|
|
133
255
|
registerBuiltinTools()
|
|
134
256
|
|
|
257
|
+
// One engine per working folder: publish the control socket when this
|
|
258
|
+
// process is long-running, and warn when a private one starts in a folder
|
|
259
|
+
// somebody else is already holding.
|
|
260
|
+
instanceControl()
|
|
261
|
+
|
|
135
262
|
onInitialize(async () => {
|
|
136
263
|
runtime.engine.commander?.version(packageInfo.version)
|
|
137
264
|
.option('-i --working-folder <folder>', 'set mikser working folder', './')
|
|
138
265
|
.option('-c --config <file>', 'set mikser mikser.config.js location', './mikser.config.js')
|
|
266
|
+
.option('--no-attach', 'start a private engine instead of forwarding to the one already running here')
|
|
139
267
|
.option('-m --mode <mode>', 'set mikser runtime mode', 'development')
|
|
140
268
|
.option('-r --clear', 'clear current state before execution', false)
|
|
141
269
|
.option('-o --output-folder <folder>', 'set mikser output folder relative to working folder', 'out')
|
|
@@ -284,57 +412,8 @@ export async function setup(options) {
|
|
|
284
412
|
// registry is complete. Nothing is imported, because this exits first,
|
|
285
413
|
// the same way --explain and --verify do.
|
|
286
414
|
if (runtime.options.tools || runtime.options.tool) {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
if (runtime.options.json) {
|
|
290
|
-
process.stdout.write(JSON.stringify(schemas, null, 2) + '\n')
|
|
291
|
-
} else if (!schemas.length) {
|
|
292
|
-
logger.warn('No tools registered. The mcp plugin registers the standard set; '
|
|
293
|
-
+ 'this flag only lists and invokes what is registered.')
|
|
294
|
-
} else {
|
|
295
|
-
for (const schema of schemas) {
|
|
296
|
-
process.stdout.write(`${schema.name}\n ${String(schema.description).split('\n')[0]}\n`)
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
process.exit(0)
|
|
300
|
-
}
|
|
301
|
-
// An empty catalog answers every question with a confident
|
|
302
|
-
// nothing — `null`, `total: 0`, "no render claims this
|
|
303
|
-
// destination" — all of which read as "the thing you asked about
|
|
304
|
-
// does not exist" when the truth is "nothing has been built here
|
|
305
|
-
// yet". Said once, before the answer, so it cannot be missed.
|
|
306
|
-
const entityCount = (() => {
|
|
307
|
-
try {
|
|
308
|
-
return useDatabase().handle
|
|
309
|
-
.prepare('SELECT count(*) AS n FROM mikser_entities').get()?.n ?? 0
|
|
310
|
-
} catch { return null }
|
|
311
|
-
})()
|
|
312
|
-
if (entityCount === 0 && !runtime.manifest?.size?.()) {
|
|
313
|
-
logger.warn('The catalog and manifest are empty — no build has run in this working '
|
|
314
|
-
+ 'folder. Tools answer from what the last build recorded, so this one will '
|
|
315
|
-
+ 'report nothing found. Run a build first.')
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
let args = {}
|
|
319
|
-
if (runtime.options.toolArgs) {
|
|
320
|
-
try {
|
|
321
|
-
args = JSON.parse(runtime.options.toolArgs)
|
|
322
|
-
} catch (err) {
|
|
323
|
-
logger.error('--tool-args is not valid JSON: %s', err.message)
|
|
324
|
-
process.exit(3)
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
let result
|
|
328
|
-
try {
|
|
329
|
-
result = await invokeTool(runtime.options.tool, args)
|
|
330
|
-
} catch (err) {
|
|
331
|
-
logger.error('%s', err.message)
|
|
332
|
-
process.exit(3)
|
|
333
|
-
}
|
|
334
|
-
process.stdout.write(toolResultText(result) + '\n')
|
|
335
|
-
// A tool that reports failure must not exit 0 — an agent reading
|
|
336
|
-
// CLI output has only the exit code to branch on.
|
|
337
|
-
process.exit(toolResultFailed(result) ? 1 : 0)
|
|
415
|
+
const code = await runReportOnly()
|
|
416
|
+
if (code !== null) process.exit(code)
|
|
338
417
|
}
|
|
339
418
|
})
|
|
340
419
|
|
|
@@ -361,58 +440,11 @@ export async function setup(options) {
|
|
|
361
440
|
// 3 — not in the catalog (distinct from --verify's 1/2, which are
|
|
362
441
|
// about output drift; "no such entity" is neither clean nor
|
|
363
442
|
// corrupt, it is a question that could not be answered)
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
} else {
|
|
370
|
-
process.stdout.write(formatExplain(report) + '\n')
|
|
371
|
-
}
|
|
372
|
-
process.exit(report.found ? 0 : 3)
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
if (runtime.options.explain) {
|
|
376
|
-
const { explain, formatExplain } = await import('./explain.js')
|
|
377
|
-
const report = await explain(runtime.options.explain)
|
|
378
|
-
if (runtime.options.json) {
|
|
379
|
-
process.stdout.write(JSON.stringify(report, null, 2) + '\n')
|
|
380
|
-
} else {
|
|
381
|
-
process.stdout.write(formatExplain(report) + '\n')
|
|
382
|
-
}
|
|
383
|
-
process.exit(report.found ? 0 : 3)
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
if (runtime.options.verify) {
|
|
388
|
-
if (!runtime.manifest) {
|
|
389
|
-
logger.error('Verify: no manifest available — nothing to check against')
|
|
390
|
-
process.exit(2)
|
|
391
|
-
}
|
|
392
|
-
const { verdict, missing, mismatched, unverifiable, orphaned, collisions } =
|
|
393
|
-
await runtime.manifest.verify()
|
|
394
|
-
const total = runtime.manifest.size()
|
|
395
|
-
|
|
396
|
-
for (const e of missing) logger.error('Missing: %s (entity %s)', e.destination, e.id)
|
|
397
|
-
for (const e of mismatched) logger.error('Mismatched: %s (entity %s)%s', e.destination, e.id,
|
|
398
|
-
e.writtenBy ? ` — the bytes on disk are ${e.writtenBy}'s` : '')
|
|
399
|
-
for (const e of unverifiable) logger.warn('No hash: %s (entity %s)', e.destination, e.id)
|
|
400
|
-
for (const e of orphaned) logger.warn('Orphan: %s', e.path)
|
|
401
|
-
// Named per destination: "two entities write here" is only
|
|
402
|
-
// actionable if you know which two.
|
|
403
|
-
for (const c of collisions) logger.warn('Collision: %s ← %s', c.destination, c.entities.join(', '))
|
|
404
|
-
|
|
405
|
-
// Level picked from the verdict, because the level IS the marker
|
|
406
|
-
// in pino-pretty's messageFormat: notice renders 🟢, warn 🟡,
|
|
407
|
-
// error 🔴. A fixed `notice` prints a green tick next to the word
|
|
408
|
-
// FAIL, which reads as success at a glance even though the exit
|
|
409
|
-
// code is right.
|
|
410
|
-
const report = verdict === 'FAIL' ? logger.error : verdict === 'WARN' ? logger.warn : logger.notice
|
|
411
|
-
report.call(logger,
|
|
412
|
-
'Verify %s: %d snapshots, %d missing, %d mismatched, %d unverifiable, %d orphaned, %d collisions',
|
|
413
|
-
verdict, total, missing.length, mismatched.length, unverifiable.length, orphaned.length, collisions.length)
|
|
414
|
-
process.exit(verdict === 'FAIL' ? 2 : verdict === 'WARN' ? 1 : 0)
|
|
415
|
-
}
|
|
443
|
+
// The same three commands the instance answers over the socket —
|
|
444
|
+
// one implementation, so a forwarded --verify cannot disagree with a
|
|
445
|
+
// local one about what it checked.
|
|
446
|
+
const code = await runReportOnly()
|
|
447
|
+
if (code !== null) process.exit(code)
|
|
416
448
|
})
|
|
417
449
|
|
|
418
450
|
onRender(async (signal) => {
|
package/src/instance.js
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
// One engine per working folder.
|
|
2
|
+
//
|
|
3
|
+
// Running `mikser` next to a live `mikser --watch` used to start a second
|
|
4
|
+
// engine on the same folder: two writers, one sqlite catalogue, one output
|
|
5
|
+
// tree, no lock and no warning. WAL keeps the pages intact and does nothing
|
|
6
|
+
// for the logical state — a `--clear` from one process while the other holds
|
|
7
|
+
// the folder produces a cold rebuild reporting nothing rendered, and output
|
|
8
|
+
// that does not match source.
|
|
9
|
+
//
|
|
10
|
+
// The fix is not a lock alone, because a lock only forbids the thing people
|
|
11
|
+
// were doing for a reason. A one-shot build is not faster than the watcher —
|
|
12
|
+
// the watcher has usually already done the work — it is started because
|
|
13
|
+
// PROCESS EXIT IS THE ONLY COMPLETION SIGNAL. Everything else in mikser
|
|
14
|
+
// hot-reloads; nothing else says "the tree is settled, assert against it now".
|
|
15
|
+
//
|
|
16
|
+
// So a second invocation forwards its request to the running instance and
|
|
17
|
+
// wears its result: the instance's log output, the instance's exit code. The
|
|
18
|
+
// caller asked for a build and gets an answer about that build, with nothing
|
|
19
|
+
// new to learn and no watermark to reason about — which matters, because a
|
|
20
|
+
// design that needs discipline from the caller is the one that gets violated.
|
|
21
|
+
//
|
|
22
|
+
// `--no-attach` opts out, for when a fresh process IS the point: checking that
|
|
23
|
+
// a cold start works, that startup ordering hides nothing. Named for what it
|
|
24
|
+
// switches off rather than for a property of the process — the default is to
|
|
25
|
+
// attach, and the flag should say which behaviour is being declined.
|
|
26
|
+
|
|
27
|
+
import net from 'node:net'
|
|
28
|
+
import { createHash } from 'node:crypto'
|
|
29
|
+
import { tmpdir } from 'node:os'
|
|
30
|
+
import path from 'node:path'
|
|
31
|
+
import { existsSync, unlinkSync } from 'node:fs'
|
|
32
|
+
import { chmod } from 'node:fs/promises'
|
|
33
|
+
|
|
34
|
+
import runtime from './runtime.js'
|
|
35
|
+
import { onLoaded } from './lifecycle.js'
|
|
36
|
+
import { renderErrorCount } from './report.js'
|
|
37
|
+
import { runReportOnly } from './engine.js'
|
|
38
|
+
|
|
39
|
+
// Where the endpoint lives.
|
|
40
|
+
//
|
|
41
|
+
// A unix socket rather than a port: no allocation, no auth decision, no
|
|
42
|
+
// network exposure. The obvious home is under the working folder, and it does
|
|
43
|
+
// not work — sun_path caps a socket path at about 107 bytes, and a working
|
|
44
|
+
// folder nested a few levels deep blows through that. It fails as
|
|
45
|
+
// `listen EINVAL`, which is not a phrase that suggests "your path is long",
|
|
46
|
+
// so it would have been diagnosed as forwarding simply not working.
|
|
47
|
+
//
|
|
48
|
+
// So: a short, fixed-length name in the system temp directory, derived from
|
|
49
|
+
// the resolved working folder. One rule, no length to exceed, and it survives
|
|
50
|
+
// an `rm -rf runtime`.
|
|
51
|
+
//
|
|
52
|
+
// The permissions argument survives the move. The socket is chmod 0600 after
|
|
53
|
+
// listen, so it is the owner's alone — /tmp being world-writable buys an
|
|
54
|
+
// attacker the ability to create their own socket, not to talk to this one.
|
|
55
|
+
//
|
|
56
|
+
// Windows has no unix sockets; Node maps this name shape onto a named pipe,
|
|
57
|
+
// which has no such length limit.
|
|
58
|
+
export function socketPath(workingFolder) {
|
|
59
|
+
const key = createHash('sha1').update(path.resolve(workingFolder ?? '.')).digest('hex').slice(0, 16)
|
|
60
|
+
return process.platform === 'win32'
|
|
61
|
+
? `\\\\.\\pipe\\mikser-${key}`
|
|
62
|
+
: path.join(tmpdir(), `mikser-${key}.sock`)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── protocol ────────────────────────────────────────────────────────────
|
|
66
|
+
//
|
|
67
|
+
// Newline-delimited JSON, one object per line. Deliberately boring: both ends
|
|
68
|
+
// ship together, so there is nothing to negotiate and no version to carry.
|
|
69
|
+
//
|
|
70
|
+
// → { type: 'build', config, clear }
|
|
71
|
+
// → { type: 'report', config, tool, tools, toolArgs, explain, verify, json }
|
|
72
|
+
// ← { type: 'log', chunk } (zero or more, in order)
|
|
73
|
+
// ← { type: 'done', code }
|
|
74
|
+
// ← { type: 'refused', reason, detail }
|
|
75
|
+
|
|
76
|
+
function frame(socket, object) {
|
|
77
|
+
try { socket.write(JSON.stringify(object) + '\n') } catch { /* peer gone */ }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function readFrames(socket, onFrame) {
|
|
81
|
+
let buffer = ''
|
|
82
|
+
socket.on('data', (chunk) => {
|
|
83
|
+
buffer += chunk.toString()
|
|
84
|
+
let index
|
|
85
|
+
while ((index = buffer.indexOf('\n')) >= 0) {
|
|
86
|
+
const line = buffer.slice(0, index)
|
|
87
|
+
buffer = buffer.slice(index + 1)
|
|
88
|
+
if (!line.trim()) continue
|
|
89
|
+
try { onFrame(JSON.parse(line)) } catch { /* not ours */ }
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── client ──────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
// Ask the running instance to build, and wear its answer.
|
|
97
|
+
//
|
|
98
|
+
// Returns the exit code to use, or null when there is nobody to ask — in
|
|
99
|
+
// which case the caller proceeds exactly as it always did. Called before
|
|
100
|
+
// setup(), so a forwarded command never pays for importing the config or the
|
|
101
|
+
// plugin graph, which is most of what a one-shot spends its time on.
|
|
102
|
+
export function forward({ workingFolder, config, request }) {
|
|
103
|
+
const endpoint = socketPath(workingFolder)
|
|
104
|
+
|
|
105
|
+
return new Promise((resolve) => {
|
|
106
|
+
const socket = net.connect(endpoint)
|
|
107
|
+
let answered = false
|
|
108
|
+
|
|
109
|
+
// Nobody home. A crash leaves the socket file behind, so "connect
|
|
110
|
+
// failed" has to mean "no instance — clean up and carry on" rather
|
|
111
|
+
// than a hang: this is the way the pattern usually breaks.
|
|
112
|
+
socket.on('error', () => {
|
|
113
|
+
if (answered) return
|
|
114
|
+
answered = true
|
|
115
|
+
if (process.platform !== 'win32' && existsSync(endpoint)) {
|
|
116
|
+
try { unlinkSync(endpoint) } catch { /* another client won the race */ }
|
|
117
|
+
}
|
|
118
|
+
resolve(null)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
socket.on('connect', () => frame(socket, { ...request, config }))
|
|
122
|
+
|
|
123
|
+
readFrames(socket, (message) => {
|
|
124
|
+
if (message.type === 'log') {
|
|
125
|
+
// The instance's output for THIS request, on the stream it
|
|
126
|
+
// would have used locally.
|
|
127
|
+
process.stderr.write(message.chunk)
|
|
128
|
+
} else if (message.type === 'refused') {
|
|
129
|
+
answered = true
|
|
130
|
+
process.stderr.write(`mikser: ${message.reason}\n`)
|
|
131
|
+
if (message.detail) process.stderr.write(`${message.detail}\n`)
|
|
132
|
+
socket.end()
|
|
133
|
+
resolve(1)
|
|
134
|
+
} else if (message.type === 'done') {
|
|
135
|
+
answered = true
|
|
136
|
+
socket.end()
|
|
137
|
+
resolve(message.code ?? 0)
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
// The instance went away mid-request. Not "success with no output":
|
|
142
|
+
// the build this was asked about has no known outcome.
|
|
143
|
+
socket.on('close', () => {
|
|
144
|
+
if (answered) return
|
|
145
|
+
answered = true
|
|
146
|
+
process.stderr.write('mikser: the running instance closed the connection before finishing.\n')
|
|
147
|
+
resolve(1)
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── server ──────────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
// Requests run one at a time, to completion.
|
|
155
|
+
//
|
|
156
|
+
// Not because the engine could not interleave them, but because "which cycle
|
|
157
|
+
// covers my edit" is the question this whole thing exists to remove. Serialised,
|
|
158
|
+
// a client's answer is about a cycle that started after its request arrived,
|
|
159
|
+
// and the log output during that window belongs to exactly one request.
|
|
160
|
+
let chain = Promise.resolve()
|
|
161
|
+
let server = null
|
|
162
|
+
|
|
163
|
+
// Tee the process's own output to the client for the duration of its request.
|
|
164
|
+
//
|
|
165
|
+
// The engine logs through pino to stdout/stderr; capturing there rather than
|
|
166
|
+
// adding a log transport means the client sees precisely what it would have
|
|
167
|
+
// seen locally, formatting and all, with no second rendering of the same
|
|
168
|
+
// records to keep in step.
|
|
169
|
+
function captureOutput(onChunk) {
|
|
170
|
+
const originals = [process.stdout.write, process.stderr.write]
|
|
171
|
+
const patch = (stream, original) => function (chunk, encoding, callback) {
|
|
172
|
+
try { onChunk(typeof chunk === 'string' ? chunk : chunk.toString()) } catch { /* client gone */ }
|
|
173
|
+
return original.call(stream, chunk, encoding, callback)
|
|
174
|
+
}
|
|
175
|
+
process.stdout.write = patch(process.stdout, originals[0])
|
|
176
|
+
process.stderr.write = patch(process.stderr, originals[1])
|
|
177
|
+
return () => {
|
|
178
|
+
process.stdout.write = originals[0]
|
|
179
|
+
process.stderr.write = originals[1]
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Is the config the client resolved the one this instance is running?
|
|
184
|
+
//
|
|
185
|
+
// The accident this prevents is the one already written down: a command
|
|
186
|
+
// resolving mikser.config.prod.js reaching an instance running the dev config
|
|
187
|
+
// executes against the wrong one, silently. Compared by resolved PATH rather
|
|
188
|
+
// than by content hash — the hash would require the client to import its
|
|
189
|
+
// config, which is most of the startup forwarding exists to skip, and it is
|
|
190
|
+
// not what tells the two apart. Different configs are different files.
|
|
191
|
+
function configMismatch(theirs) {
|
|
192
|
+
if (!theirs) return null
|
|
193
|
+
const mine = path.resolve(runtime.options.config ?? 'mikser.config.js')
|
|
194
|
+
return path.resolve(theirs) === mine ? null : mine
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Has this instance's own config changed under it since it started?
|
|
198
|
+
//
|
|
199
|
+
// The other half of the same question, and the half a client cannot answer.
|
|
200
|
+
// configCoverage lists every local module the config graph pulled in, so a
|
|
201
|
+
// stat over that list catches an edit to an imported module — which a
|
|
202
|
+
// client-side checksum of the entry file would miss entirely.
|
|
203
|
+
async function configStale() {
|
|
204
|
+
const covered = runtime.options.configCoverage?.files ?? []
|
|
205
|
+
if (!covered.length) return null
|
|
206
|
+
const { stat } = await import('node:fs/promises')
|
|
207
|
+
const stamps = runtime.options.configStamps
|
|
208
|
+
if (!stamps) {
|
|
209
|
+
// First call: record, do not judge. Nothing to compare against yet.
|
|
210
|
+
runtime.options.configStamps = Object.fromEntries(
|
|
211
|
+
await Promise.all(covered.map(async (file) => {
|
|
212
|
+
try { return [file, (await stat(file)).mtimeMs] } catch { return [file, 0] }
|
|
213
|
+
})))
|
|
214
|
+
return null
|
|
215
|
+
}
|
|
216
|
+
for (const file of covered) {
|
|
217
|
+
let now = 0
|
|
218
|
+
try { now = (await stat(file)).mtimeMs } catch { /* deleted counts as changed */ }
|
|
219
|
+
if (stamps[file] !== now) return file
|
|
220
|
+
}
|
|
221
|
+
return null
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Report-only commands, answered from the live catalogue.
|
|
225
|
+
//
|
|
226
|
+
// These read; they do not write, so running them locally was safe for the
|
|
227
|
+
// FILES. It was not safe for the ANSWER. A local --verify at ten thousand
|
|
228
|
+
// pages reads a catalogue the instance is in the middle of writing and reports
|
|
229
|
+
// drift that is a half-finished cycle, and a local --tool answers from
|
|
230
|
+
// whatever the last build left rather than from what is true now.
|
|
231
|
+
//
|
|
232
|
+
// The instance has the settled state and the config that produced it, so it is
|
|
233
|
+
// the only process that can answer correctly. Same guards as a build: wrong
|
|
234
|
+
// config refuses, drifted config refuses.
|
|
235
|
+
async function serveReport(socket, request, logger) {
|
|
236
|
+
const restore = captureOutput((chunk) => frame(socket, { type: 'log', chunk }))
|
|
237
|
+
let code = 0
|
|
238
|
+
try {
|
|
239
|
+
code = await runReportOnly(request) ?? 0
|
|
240
|
+
} catch (err) {
|
|
241
|
+
logger?.error('instance: forwarded report failed — %s', err.message)
|
|
242
|
+
code = 3
|
|
243
|
+
} finally {
|
|
244
|
+
restore()
|
|
245
|
+
}
|
|
246
|
+
frame(socket, { type: 'done', code })
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function refuseConfig(socket, request, wrongConfig) {
|
|
250
|
+
frame(socket, {
|
|
251
|
+
type: 'refused',
|
|
252
|
+
reason: `this instance is running ${wrongConfig}, and you asked for ${path.resolve(request.config)}.`,
|
|
253
|
+
detail: 'Answering would use the wrong config — the accident this refusal exists to prevent. '
|
|
254
|
+
+ 'Stop that instance, or pass --no-attach to run your own.',
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function refuseStale(socket, movedFile) {
|
|
259
|
+
frame(socket, {
|
|
260
|
+
type: 'refused',
|
|
261
|
+
reason: `this instance's config changed on disk since it started (${movedFile}).`,
|
|
262
|
+
detail: 'It is still running the old one. Restart it, and this command will reach an instance that '
|
|
263
|
+
+ 'matches what you edited.',
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function serveBuild(socket, request, logger) {
|
|
268
|
+
const restore = captureOutput((chunk) => frame(socket, { type: 'log', chunk }))
|
|
269
|
+
let code = 0
|
|
270
|
+
try {
|
|
271
|
+
// Fire the pending debounce rather than waiting it out.
|
|
272
|
+
//
|
|
273
|
+
// The 1000ms window exists to INFER that editing has stopped. A client
|
|
274
|
+
// asking for a build has STATED it, so inference is not needed and the
|
|
275
|
+
// timer is pure latency. The accumulated events are not discarded by
|
|
276
|
+
// this: the watcher already wrote them into the journal, so clearing
|
|
277
|
+
// the timer and building now covers exactly what it would have.
|
|
278
|
+
clearTimeout(runtime.engine?.processTimeout)
|
|
279
|
+
|
|
280
|
+
// RESCAN, not drain.
|
|
281
|
+
//
|
|
282
|
+
// A watch cycle processes what the watcher reported. A client that
|
|
283
|
+
// writes a file and immediately asks for a build can beat the inotify
|
|
284
|
+
// event, so draining what is already queued would build without the
|
|
285
|
+
// change that prompted the request — the watermark bug wearing a
|
|
286
|
+
// different hat. Rescanning makes a forwarded build mean what a
|
|
287
|
+
// one-shot means, which is what every existing caller assumes.
|
|
288
|
+
await runtime.rebuild()
|
|
289
|
+
|
|
290
|
+
// From the render-error count, NOT from process.exitCode.
|
|
291
|
+
//
|
|
292
|
+
// The engine deliberately leaves exitCode alone in watch mode — a
|
|
293
|
+
// failed render there is a state to fix on the next cycle, not a
|
|
294
|
+
// reason to tear the watcher down — and the instance is always in
|
|
295
|
+
// watch or server mode. So the signal a one-shot would have exited
|
|
296
|
+
// with does not exist here and has to be read from the report, which
|
|
297
|
+
// is where it came from in the first place.
|
|
298
|
+
code = renderErrorCount() ? 1 : 0
|
|
299
|
+
} catch (err) {
|
|
300
|
+
logger?.error('instance: forwarded build failed — %s', err.message)
|
|
301
|
+
code = 1
|
|
302
|
+
} finally {
|
|
303
|
+
restore()
|
|
304
|
+
}
|
|
305
|
+
frame(socket, { type: 'done', code })
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Listen, if this process is one that sticks around.
|
|
309
|
+
//
|
|
310
|
+
// A one-shot build has nothing to offer a client — it is already exiting — so
|
|
311
|
+
// only a watcher or a server publishes an endpoint.
|
|
312
|
+
export function serveInstance() {
|
|
313
|
+
onLoaded(async () => {
|
|
314
|
+
if (!runtime.options.watch && !runtime.options.server) return
|
|
315
|
+
if (runtime.options.attach === false) return
|
|
316
|
+
const logger = runtime.engine?.logger
|
|
317
|
+
const endpoint = socketPath(runtime.options.workingFolder)
|
|
318
|
+
|
|
319
|
+
// A socket left by a crash. Nothing is listening, so removing it is
|
|
320
|
+
// safe — and a live one would have refused this process's own startup
|
|
321
|
+
// long before here, in the client check.
|
|
322
|
+
if (process.platform !== 'win32' && existsSync(endpoint)) {
|
|
323
|
+
try { unlinkSync(endpoint) } catch { /* nothing to remove */ }
|
|
324
|
+
}
|
|
325
|
+
await configStale() // record the baseline stamps
|
|
326
|
+
|
|
327
|
+
server = net.createServer((socket) => {
|
|
328
|
+
socket.on('error', () => { /* client vanished mid-request */ })
|
|
329
|
+
readFrames(socket, (request) => {
|
|
330
|
+
if (request.type !== 'build' && request.type !== 'report') return
|
|
331
|
+
chain = chain.then(async () => {
|
|
332
|
+
// Both kinds answer for the client's config, not the
|
|
333
|
+
// instance's — a report against the wrong config is the
|
|
334
|
+
// original incident, and it is wrong whether or not it
|
|
335
|
+
// writes anything.
|
|
336
|
+
const wrongConfig = configMismatch(request.config)
|
|
337
|
+
if (wrongConfig) return refuseConfig(socket, request, wrongConfig)
|
|
338
|
+
const movedFile = await configStale()
|
|
339
|
+
if (movedFile) return refuseStale(socket, movedFile)
|
|
340
|
+
return request.type === 'build'
|
|
341
|
+
? serveBuild(socket, request, logger)
|
|
342
|
+
: serveReport(socket, request, logger)
|
|
343
|
+
}).catch(() => {})
|
|
344
|
+
})
|
|
345
|
+
})
|
|
346
|
+
server.on('error', (err) => {
|
|
347
|
+
logger?.warn({ code: 'instance-listen-failed' },
|
|
348
|
+
'Could not open the control socket (%s). Other mikser commands in this folder will start their '
|
|
349
|
+
+ 'own engine instead of talking to this one.', err.message)
|
|
350
|
+
})
|
|
351
|
+
server.listen(endpoint, async () => {
|
|
352
|
+
// The owner's, and nobody else's — the filesystem permission IS
|
|
353
|
+
// the access decision, which is why this needs no token.
|
|
354
|
+
if (process.platform !== 'win32') {
|
|
355
|
+
try { await chmod(endpoint, 0o600) } catch { /* best effort */ }
|
|
356
|
+
}
|
|
357
|
+
logger?.info('Instance socket: %s', endpoint)
|
|
358
|
+
})
|
|
359
|
+
server.unref?.()
|
|
360
|
+
|
|
361
|
+
const close = () => {
|
|
362
|
+
try { server?.close() } catch { /* already closed */ }
|
|
363
|
+
if (process.platform !== 'win32' && existsSync(endpoint)) {
|
|
364
|
+
try { unlinkSync(endpoint) } catch { /* gone */ }
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
process.on('exit', close)
|
|
368
|
+
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
369
|
+
process.on(signal, () => { close(); process.exit(0) })
|
|
370
|
+
}
|
|
371
|
+
})
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Say so when a private engine is starting in a folder someone else holds.
|
|
375
|
+
//
|
|
376
|
+
// The local counterpart of the rule already written down for deployments —
|
|
377
|
+
// "never run a one-shot mikser command against the deployment" — which existed
|
|
378
|
+
// because there was no alternative. There is one now, so this covers what is
|
|
379
|
+
// left: --no-attach, and the report-only runs that stay local by design.
|
|
380
|
+
//
|
|
381
|
+
// A warning rather than a refusal. --no-attach is how you deliberately check
|
|
382
|
+
// that a cold start works, and refusing it would take away the escape hatch
|
|
383
|
+
// this design depends on having.
|
|
384
|
+
export async function warnIfHeld({ workingFolder, attached }) {
|
|
385
|
+
const endpoint = socketPath(workingFolder)
|
|
386
|
+
if (process.platform !== 'win32' && !existsSync(endpoint)) return false
|
|
387
|
+
|
|
388
|
+
const live = await new Promise((resolve) => {
|
|
389
|
+
const probe = net.connect(endpoint)
|
|
390
|
+
const done = (answer) => { try { probe.destroy() } catch { /* already gone */ } resolve(answer) }
|
|
391
|
+
probe.on('connect', () => done(true))
|
|
392
|
+
probe.on('error', () => done(false))
|
|
393
|
+
setTimeout(() => done(false), 250).unref?.()
|
|
394
|
+
})
|
|
395
|
+
if (!live) return false
|
|
396
|
+
|
|
397
|
+
const logger = runtime.engine?.logger
|
|
398
|
+
const message = attached === false
|
|
399
|
+
? 'Another mikser is already running in this folder, and --no-attach means this one will not talk to '
|
|
400
|
+
+ 'it. Two engines share the catalogue and the output tree with no lock between them; a --clear from '
|
|
401
|
+
+ 'either is what produces a cold rebuild that renders nothing.'
|
|
402
|
+
: 'Another mikser is already running in this folder. This command reads and does not write, so it is '
|
|
403
|
+
+ 'safe — but it may see a catalogue mid-cycle.'
|
|
404
|
+
logger?.warn?.({ code: 'instance-already-running' }, message)
|
|
405
|
+
return true
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Registered at setup so both halves are wired from one call.
|
|
409
|
+
export function instanceControl() {
|
|
410
|
+
serveInstance()
|
|
411
|
+
onLoaded(async () => {
|
|
412
|
+
if (runtime.options.watch || runtime.options.server) return
|
|
413
|
+
await warnIfHeld({
|
|
414
|
+
workingFolder: runtime.options.workingFolder,
|
|
415
|
+
attached: runtime.options.attach,
|
|
416
|
+
})
|
|
417
|
+
})
|
|
418
|
+
}
|
|
@@ -1,9 +1,61 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
import { changeExtension } from '../../utils.js'
|
|
4
|
+
|
|
5
|
+
// `{{asset 'web' '/media/hero.jpg'}}` — the deployed URL of a preset
|
|
6
|
+
// derivative, relative to the page asking for it.
|
|
7
|
+
//
|
|
8
|
+
// It BUILDS the URL rather than looking one up, which is the source of every
|
|
9
|
+
// way it used to go wrong: nothing it returns has been checked against
|
|
10
|
+
// anything, so a mistake produces a perfectly well-formed link to a file that
|
|
11
|
+
// does not exist. The page renders, the build is green, and the image is
|
|
12
|
+
// missing — noticed by a person, later.
|
|
13
|
+
//
|
|
14
|
+
// Three of those are closed here. The remaining one is structural: the
|
|
15
|
+
// derivative may simply not have been generated, and this cannot tell.
|
|
16
|
+
// `meta.presets` (ADR-0011) is the looked-up answer where a caller has the
|
|
17
|
+
// entity; this helper exists for the case where they have a path.
|
|
18
|
+
export function load({ runtime, entity, state, options, logger }) {
|
|
19
|
+
const presets = state?.assets?.presets ?? {}
|
|
20
|
+
const warned = new Set()
|
|
21
|
+
const warnOnce = (key, code, message, ...args) => {
|
|
22
|
+
if (warned.has(key)) return
|
|
23
|
+
warned.add(key)
|
|
24
|
+
logger?.warn?.({ code }, message, ...args)
|
|
25
|
+
}
|
|
26
|
+
|
|
4
27
|
runtime.asset = (preset, url, format) => {
|
|
5
28
|
if (url[0] != '/') url = `/${url}`
|
|
6
|
-
|
|
29
|
+
|
|
30
|
+
const declared = presets[preset]?.format
|
|
31
|
+
|
|
32
|
+
// A preset name nothing declares. The URL still gets built — it is a
|
|
33
|
+
// string operation and cannot fail — so without this a typo is a
|
|
34
|
+
// missing image and no other symptom anywhere.
|
|
35
|
+
if (!presets[preset]) {
|
|
36
|
+
warnOnce(`preset:${preset}`, 'asset-unknown-preset',
|
|
37
|
+
'asset() was asked for preset %j, which is not configured. The URL it returns points at a '
|
|
38
|
+
+ 'derivative nothing generates. Configured: %s',
|
|
39
|
+
preset, Object.keys(presets).sort().join(', ') || '(none)')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The format the preset itself declares, unless the caller overrode
|
|
43
|
+
// it. Every preset module exports one, so requiring the template to
|
|
44
|
+
// repeat it made the extension a thing two places had to agree about
|
|
45
|
+
// — and when they disagreed the link was wrong with nothing said.
|
|
46
|
+
const effective = format ?? declared
|
|
47
|
+
|
|
48
|
+
if (format && declared && format !== declared) {
|
|
49
|
+
warnOnce(`format:${preset}:${format}`, 'asset-format-mismatch',
|
|
50
|
+
'asset() was given format %j for preset %j, which produces %j. One of the two is wrong and '
|
|
51
|
+
+ 'the URL follows the argument.', format, preset, declared)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// changeExtension, not a local split-and-rejoin. The inline version
|
|
55
|
+
// returned "webp" for a source with no extension — dropping the path
|
|
56
|
+
// and yielding a relative URL that resolves against whatever page
|
|
57
|
+
// happened to be rendering.
|
|
58
|
+
const relative = `${state.assets.assetsFolder}/${preset}${effective ? changeExtension(url, effective) : url}`
|
|
7
59
|
const destination = '/' + relative
|
|
8
60
|
const from = path.dirname(entity.destination || '/')
|
|
9
61
|
return { url: path.relative(from, destination) }
|
package/src/report.js
CHANGED
|
@@ -117,6 +117,11 @@ export function resetReport() {
|
|
|
117
117
|
runtime.state.changed = { ids: [], count: 0 }
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
// Published on the runtime so runtime.js can start a fresh cycle for a
|
|
121
|
+
// forwarded build without importing this module — runtime.js loads first, and
|
|
122
|
+
// an import here would close the cycle.
|
|
123
|
+
runtime.resetReport = resetReport
|
|
124
|
+
|
|
120
125
|
// End of a cycle: stamp it, file it, and wake anyone waiting on it.
|
|
121
126
|
export function finishCycle() {
|
|
122
127
|
if (!runtime.state?.cycle || runtime.state.cycle.finishedAt) return
|
package/src/runtime.js
CHANGED
|
@@ -111,6 +111,25 @@ const runtime = {
|
|
|
111
111
|
if (!this.options?.watch && !this.options?.server) await this.closeDurable?.()
|
|
112
112
|
},
|
|
113
113
|
|
|
114
|
+
// A build on request, from a process that is already running.
|
|
115
|
+
//
|
|
116
|
+
// start() runs the import hooks once and every cycle after that only
|
|
117
|
+
// processes what the watcher reported. A forwarded build has to RESCAN:
|
|
118
|
+
// a client that writes a file and immediately asks can beat the inotify
|
|
119
|
+
// event, and draining the queue would then build without the change that
|
|
120
|
+
// prompted the request. Scanning makes it mean what a one-shot means.
|
|
121
|
+
//
|
|
122
|
+
// The gate makes the rescan cheap — an unchanged file is a checksum
|
|
123
|
+
// comparison, not a re-import.
|
|
124
|
+
async rebuild() {
|
|
125
|
+
// Its own cycle in the report, like any other — otherwise a forwarded
|
|
126
|
+
// build's counts are added to whatever the watcher last did.
|
|
127
|
+
this.resetReport?.()
|
|
128
|
+
await this.callHooks(this.hooks.import, undefined, 'import')
|
|
129
|
+
await this.callHooks(this.hooks.imported, undefined, 'imported')
|
|
130
|
+
await this.process()
|
|
131
|
+
},
|
|
132
|
+
|
|
114
133
|
async process() {
|
|
115
134
|
if (this.abortController?.signal.aborted) return
|
|
116
135
|
else if (this.abortController) {
|