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 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 { setup } from "./index.js"
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()
@@ -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.64.0",
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
- if (runtime.options.tools) {
288
- const schemas = toolSchemas()
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
- if (runtime.options.explain) {
365
- const { explain, formatExplain } = await import('./explain.js')
366
- const report = await explain(runtime.options.explain)
367
- if (runtime.options.json) {
368
- process.stdout.write(JSON.stringify(report, null, 2) + '\n')
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) => {
@@ -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
- export function load({ runtime, entity, state, options }) {
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
- const relative = `${state.assets.assetsFolder}/${preset}${format ? url.split('.').slice(0, -1).concat(format).join('.') : url}`
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) {