mikser-io 9.63.0 → 9.66.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,19 @@ 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. Exit code comes from `renderErrorCount()`, not
246
+ `process.exitCode` — the engine suppresses that in watch mode by
247
+ design. Config mismatch is refused by resolved PATH; config drift
248
+ under a running instance is detected by stat over `configCoverage`.
236
249
  - `manager.js` — file watching (chokidar) and cron scheduling. `watch()`
237
250
  turns file events into SYNC events — it is how a source folder becomes
238
251
  entities, so pointing it at the output folder feeds output back in as
@@ -423,6 +436,13 @@ Test coverage: `test/unit/source-sweep.test.js`.
423
436
  camelCase: `import { vector } from 'mikser-io-vector'`,
424
437
  `import { renderHbs } from 'mikser-io'`. Consumer uses
425
438
  `plugins: [vector({...})]` — never the bare string.
439
+ - **Route paths**: a plugin mounts at `/<name>`, with a `base` or `path`
440
+ option to move it — api `/api`, auth `/auth`, drive `/drive`, forms
441
+ `/forms`, mcp `/mcp`, preview `/preview`, vector `/vector`, decap
442
+ `/admin`. The output folder is served from `/`, so a route CAN shadow a
443
+ page; the accepted answer is a predictable name plus a way to change
444
+ it, not a reserved prefix. Follow the eight, do not invent a ninth
445
+ shape.
426
446
  - **Tool names**: the registry (`src/tools.js`) holds BARE names —
427
447
  `explain`, `verify`, `sources`, `search`. The `mikser_` prefix is MCP's
428
448
  namespacing, because its tool names are flat across every connected
package/app.js CHANGED
@@ -1,8 +1,56 @@
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))
28
+ return {
29
+ workingFolder: value('--working-folder', '-i') ?? '.',
30
+ config: value('--config', '-c') ?? 'mikser.config.js',
31
+ clear: has('--clear'),
32
+ // Commander's negated form: `attach` is true unless --no-attach said so.
33
+ attach: has('--no-attach') ? false : true,
34
+ // Report-only runs read; they do not write the catalogue or the output
35
+ // tree, and their handlers exit the process themselves. Left local —
36
+ // the guard in setup() still says an instance is there.
37
+ reportOnly: has('--tool', '--tools', '--verify', '--explain'),
38
+ }
39
+ }
3
40
 
4
41
  async function main() {
42
+ const where = locate(process.argv.slice(2))
43
+ if (where.attach !== false && !where.reportOnly) {
44
+ const code = await forward({
45
+ workingFolder: path.resolve(where.workingFolder),
46
+ config: path.resolve(where.workingFolder, where.config),
47
+ clear: where.clear,
48
+ })
49
+ // null means nobody was listening — carry on exactly as before.
50
+ if (code !== null) process.exit(code)
51
+ }
52
+
5
53
  const mikser = await setup()
6
54
  await mikser.start()
7
55
  }
8
- main()
56
+ 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,36 @@ 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
+ A forwarded build **rescans**; it does not drain what the watcher happened to
763
+ queue. A client that writes a file and immediately asks can beat the file
764
+ event, and draining would then build without the change that prompted the
765
+ request.
766
+
736
767
  ## Faults
737
768
 
738
769
  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.63.0",
3
+ "version": "9.66.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'
@@ -132,10 +133,16 @@ export async function setup(options) {
132
133
  // need an agent surface configured when `--verify` does not.
133
134
  registerBuiltinTools()
134
135
 
136
+ // One engine per working folder: publish the control socket when this
137
+ // process is long-running, and warn when a private one starts in a folder
138
+ // somebody else is already holding.
139
+ instanceControl()
140
+
135
141
  onInitialize(async () => {
136
142
  runtime.engine.commander?.version(packageInfo.version)
137
143
  .option('-i --working-folder <folder>', 'set mikser working folder', './')
138
144
  .option('-c --config <file>', 'set mikser mikser.config.js location', './mikser.config.js')
145
+ .option('--no-attach', 'start a private engine instead of forwarding to the one already running here')
139
146
  .option('-m --mode <mode>', 'set mikser runtime mode', 'development')
140
147
  .option('-r --clear', 'clear current state before execution', false)
141
148
  .option('-o --output-folder <folder>', 'set mikser output folder relative to working folder', 'out')
@@ -0,0 +1,383 @@
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
+
38
+ // Where the endpoint lives.
39
+ //
40
+ // A unix socket rather than a port: no allocation, no auth decision, no
41
+ // network exposure. The obvious home is under the working folder, and it does
42
+ // not work — sun_path caps a socket path at about 107 bytes, and a working
43
+ // folder nested a few levels deep blows through that. It fails as
44
+ // `listen EINVAL`, which is not a phrase that suggests "your path is long",
45
+ // so it would have been diagnosed as forwarding simply not working.
46
+ //
47
+ // So: a short, fixed-length name in the system temp directory, derived from
48
+ // the resolved working folder. One rule, no length to exceed, and it survives
49
+ // an `rm -rf runtime`.
50
+ //
51
+ // The permissions argument survives the move. The socket is chmod 0600 after
52
+ // listen, so it is the owner's alone — /tmp being world-writable buys an
53
+ // attacker the ability to create their own socket, not to talk to this one.
54
+ //
55
+ // Windows has no unix sockets; Node maps this name shape onto a named pipe,
56
+ // which has no such length limit.
57
+ export function socketPath(workingFolder) {
58
+ const key = createHash('sha1').update(path.resolve(workingFolder ?? '.')).digest('hex').slice(0, 16)
59
+ return process.platform === 'win32'
60
+ ? `\\\\.\\pipe\\mikser-${key}`
61
+ : path.join(tmpdir(), `mikser-${key}.sock`)
62
+ }
63
+
64
+ // ── protocol ────────────────────────────────────────────────────────────
65
+ //
66
+ // Newline-delimited JSON, one object per line. Deliberately boring: both ends
67
+ // ship together, so there is nothing to negotiate and no version to carry.
68
+ //
69
+ // → { type: 'build', config, clear }
70
+ // ← { type: 'log', chunk } (zero or more, in order)
71
+ // ← { type: 'done', code }
72
+ // ← { type: 'refused', reason, detail }
73
+
74
+ function frame(socket, object) {
75
+ try { socket.write(JSON.stringify(object) + '\n') } catch { /* peer gone */ }
76
+ }
77
+
78
+ function readFrames(socket, onFrame) {
79
+ let buffer = ''
80
+ socket.on('data', (chunk) => {
81
+ buffer += chunk.toString()
82
+ let index
83
+ while ((index = buffer.indexOf('\n')) >= 0) {
84
+ const line = buffer.slice(0, index)
85
+ buffer = buffer.slice(index + 1)
86
+ if (!line.trim()) continue
87
+ try { onFrame(JSON.parse(line)) } catch { /* not ours */ }
88
+ }
89
+ })
90
+ }
91
+
92
+ // ── client ──────────────────────────────────────────────────────────────
93
+
94
+ // Ask the running instance to build, and wear its answer.
95
+ //
96
+ // Returns the exit code to use, or null when there is nobody to ask — in
97
+ // which case the caller proceeds exactly as it always did. Called before
98
+ // setup(), so a forwarded command never pays for importing the config or the
99
+ // plugin graph, which is most of what a one-shot spends its time on.
100
+ export function forward({ workingFolder, config, clear }) {
101
+ const endpoint = socketPath(workingFolder)
102
+
103
+ return new Promise((resolve) => {
104
+ const socket = net.connect(endpoint)
105
+ let answered = false
106
+
107
+ // Nobody home. A crash leaves the socket file behind, so "connect
108
+ // failed" has to mean "no instance — clean up and carry on" rather
109
+ // than a hang: this is the way the pattern usually breaks.
110
+ socket.on('error', () => {
111
+ if (answered) return
112
+ answered = true
113
+ if (process.platform !== 'win32' && existsSync(endpoint)) {
114
+ try { unlinkSync(endpoint) } catch { /* another client won the race */ }
115
+ }
116
+ resolve(null)
117
+ })
118
+
119
+ socket.on('connect', () => frame(socket, { type: 'build', config, clear }))
120
+
121
+ readFrames(socket, (message) => {
122
+ if (message.type === 'log') {
123
+ // The instance's output for THIS request, on the stream it
124
+ // would have used locally.
125
+ process.stderr.write(message.chunk)
126
+ } else if (message.type === 'refused') {
127
+ answered = true
128
+ process.stderr.write(`mikser: ${message.reason}\n`)
129
+ if (message.detail) process.stderr.write(`${message.detail}\n`)
130
+ socket.end()
131
+ resolve(1)
132
+ } else if (message.type === 'done') {
133
+ answered = true
134
+ socket.end()
135
+ resolve(message.code ?? 0)
136
+ }
137
+ })
138
+
139
+ // The instance went away mid-request. Not "success with no output":
140
+ // the build this was asked about has no known outcome.
141
+ socket.on('close', () => {
142
+ if (answered) return
143
+ answered = true
144
+ process.stderr.write('mikser: the running instance closed the connection before finishing.\n')
145
+ resolve(1)
146
+ })
147
+ })
148
+ }
149
+
150
+ // ── server ──────────────────────────────────────────────────────────────
151
+
152
+ // Requests run one at a time, to completion.
153
+ //
154
+ // Not because the engine could not interleave them, but because "which cycle
155
+ // covers my edit" is the question this whole thing exists to remove. Serialised,
156
+ // a client's answer is about a cycle that started after its request arrived,
157
+ // and the log output during that window belongs to exactly one request.
158
+ let chain = Promise.resolve()
159
+ let server = null
160
+
161
+ // Tee the process's own output to the client for the duration of its request.
162
+ //
163
+ // The engine logs through pino to stdout/stderr; capturing there rather than
164
+ // adding a log transport means the client sees precisely what it would have
165
+ // seen locally, formatting and all, with no second rendering of the same
166
+ // records to keep in step.
167
+ function captureOutput(onChunk) {
168
+ const originals = [process.stdout.write, process.stderr.write]
169
+ const patch = (stream, original) => function (chunk, encoding, callback) {
170
+ try { onChunk(typeof chunk === 'string' ? chunk : chunk.toString()) } catch { /* client gone */ }
171
+ return original.call(stream, chunk, encoding, callback)
172
+ }
173
+ process.stdout.write = patch(process.stdout, originals[0])
174
+ process.stderr.write = patch(process.stderr, originals[1])
175
+ return () => {
176
+ process.stdout.write = originals[0]
177
+ process.stderr.write = originals[1]
178
+ }
179
+ }
180
+
181
+ // Is the config the client resolved the one this instance is running?
182
+ //
183
+ // The accident this prevents is the one already written down: a command
184
+ // resolving mikser.config.prod.js reaching an instance running the dev config
185
+ // executes against the wrong one, silently. Compared by resolved PATH rather
186
+ // than by content hash — the hash would require the client to import its
187
+ // config, which is most of the startup forwarding exists to skip, and it is
188
+ // not what tells the two apart. Different configs are different files.
189
+ function configMismatch(theirs) {
190
+ if (!theirs) return null
191
+ const mine = path.resolve(runtime.options.config ?? 'mikser.config.js')
192
+ return path.resolve(theirs) === mine ? null : mine
193
+ }
194
+
195
+ // Has this instance's own config changed under it since it started?
196
+ //
197
+ // The other half of the same question, and the half a client cannot answer.
198
+ // configCoverage lists every local module the config graph pulled in, so a
199
+ // stat over that list catches an edit to an imported module — which a
200
+ // client-side checksum of the entry file would miss entirely.
201
+ async function configStale() {
202
+ const covered = runtime.options.configCoverage?.files ?? []
203
+ if (!covered.length) return null
204
+ const { stat } = await import('node:fs/promises')
205
+ const stamps = runtime.options.configStamps
206
+ if (!stamps) {
207
+ // First call: record, do not judge. Nothing to compare against yet.
208
+ runtime.options.configStamps = Object.fromEntries(
209
+ await Promise.all(covered.map(async (file) => {
210
+ try { return [file, (await stat(file)).mtimeMs] } catch { return [file, 0] }
211
+ })))
212
+ return null
213
+ }
214
+ for (const file of covered) {
215
+ let now = 0
216
+ try { now = (await stat(file)).mtimeMs } catch { /* deleted counts as changed */ }
217
+ if (stamps[file] !== now) return file
218
+ }
219
+ return null
220
+ }
221
+
222
+ async function serveBuild(socket, request, logger) {
223
+ const wrongConfig = configMismatch(request.config)
224
+ if (wrongConfig) {
225
+ frame(socket, {
226
+ type: 'refused',
227
+ reason: `this instance is running ${wrongConfig}, and you asked for ${path.resolve(request.config)}.`,
228
+ detail: 'Forwarding would build with the wrong config — the accident this refusal exists to prevent. '
229
+ + 'Stop that instance, or pass --no-attach to run your own.',
230
+ })
231
+ return
232
+ }
233
+
234
+ const movedFile = await configStale()
235
+ if (movedFile) {
236
+ frame(socket, {
237
+ type: 'refused',
238
+ reason: `this instance's config changed on disk since it started (${movedFile}).`,
239
+ detail: 'It is still building with the old one. Restart it, and this command will reach an '
240
+ + 'instance that matches what you edited.',
241
+ })
242
+ return
243
+ }
244
+
245
+ const restore = captureOutput((chunk) => frame(socket, { type: 'log', chunk }))
246
+ let code = 0
247
+ try {
248
+ // Fire the pending debounce rather than waiting it out.
249
+ //
250
+ // The 1000ms window exists to INFER that editing has stopped. A client
251
+ // asking for a build has STATED it, so inference is not needed and the
252
+ // timer is pure latency. The accumulated events are not discarded by
253
+ // this: the watcher already wrote them into the journal, so clearing
254
+ // the timer and building now covers exactly what it would have.
255
+ clearTimeout(runtime.engine?.processTimeout)
256
+
257
+ // RESCAN, not drain.
258
+ //
259
+ // A watch cycle processes what the watcher reported. A client that
260
+ // writes a file and immediately asks for a build can beat the inotify
261
+ // event, so draining what is already queued would build without the
262
+ // change that prompted the request — the watermark bug wearing a
263
+ // different hat. Rescanning makes a forwarded build mean what a
264
+ // one-shot means, which is what every existing caller assumes.
265
+ await runtime.rebuild()
266
+
267
+ // From the render-error count, NOT from process.exitCode.
268
+ //
269
+ // The engine deliberately leaves exitCode alone in watch mode — a
270
+ // failed render there is a state to fix on the next cycle, not a
271
+ // reason to tear the watcher down — and the instance is always in
272
+ // watch or server mode. So the signal a one-shot would have exited
273
+ // with does not exist here and has to be read from the report, which
274
+ // is where it came from in the first place.
275
+ code = renderErrorCount() ? 1 : 0
276
+ } catch (err) {
277
+ logger?.error('instance: forwarded build failed — %s', err.message)
278
+ code = 1
279
+ } finally {
280
+ restore()
281
+ }
282
+ frame(socket, { type: 'done', code })
283
+ }
284
+
285
+ // Listen, if this process is one that sticks around.
286
+ //
287
+ // A one-shot build has nothing to offer a client — it is already exiting — so
288
+ // only a watcher or a server publishes an endpoint.
289
+ export function serveInstance() {
290
+ onLoaded(async () => {
291
+ if (!runtime.options.watch && !runtime.options.server) return
292
+ if (runtime.options.attach === false) return
293
+ const logger = runtime.engine?.logger
294
+ const endpoint = socketPath(runtime.options.workingFolder)
295
+
296
+ // A socket left by a crash. Nothing is listening, so removing it is
297
+ // safe — and a live one would have refused this process's own startup
298
+ // long before here, in the client check.
299
+ if (process.platform !== 'win32' && existsSync(endpoint)) {
300
+ try { unlinkSync(endpoint) } catch { /* nothing to remove */ }
301
+ }
302
+ await configStale() // record the baseline stamps
303
+
304
+ server = net.createServer((socket) => {
305
+ socket.on('error', () => { /* client vanished mid-request */ })
306
+ readFrames(socket, (request) => {
307
+ if (request.type !== 'build') return
308
+ chain = chain.then(() => serveBuild(socket, request, logger)).catch(() => {})
309
+ })
310
+ })
311
+ server.on('error', (err) => {
312
+ logger?.warn({ code: 'instance-listen-failed' },
313
+ 'Could not open the control socket (%s). Other mikser commands in this folder will start their '
314
+ + 'own engine instead of talking to this one.', err.message)
315
+ })
316
+ server.listen(endpoint, async () => {
317
+ // The owner's, and nobody else's — the filesystem permission IS
318
+ // the access decision, which is why this needs no token.
319
+ if (process.platform !== 'win32') {
320
+ try { await chmod(endpoint, 0o600) } catch { /* best effort */ }
321
+ }
322
+ logger?.info('Instance socket: %s', endpoint)
323
+ })
324
+ server.unref?.()
325
+
326
+ const close = () => {
327
+ try { server?.close() } catch { /* already closed */ }
328
+ if (process.platform !== 'win32' && existsSync(endpoint)) {
329
+ try { unlinkSync(endpoint) } catch { /* gone */ }
330
+ }
331
+ }
332
+ process.on('exit', close)
333
+ for (const signal of ['SIGINT', 'SIGTERM']) {
334
+ process.on(signal, () => { close(); process.exit(0) })
335
+ }
336
+ })
337
+ }
338
+
339
+ // Say so when a private engine is starting in a folder someone else holds.
340
+ //
341
+ // The local counterpart of the rule already written down for deployments —
342
+ // "never run a one-shot mikser command against the deployment" — which existed
343
+ // because there was no alternative. There is one now, so this covers what is
344
+ // left: --no-attach, and the report-only runs that stay local by design.
345
+ //
346
+ // A warning rather than a refusal. --no-attach is how you deliberately check
347
+ // that a cold start works, and refusing it would take away the escape hatch
348
+ // this design depends on having.
349
+ export async function warnIfHeld({ workingFolder, attached }) {
350
+ const endpoint = socketPath(workingFolder)
351
+ if (process.platform !== 'win32' && !existsSync(endpoint)) return false
352
+
353
+ const live = await new Promise((resolve) => {
354
+ const probe = net.connect(endpoint)
355
+ const done = (answer) => { try { probe.destroy() } catch { /* already gone */ } resolve(answer) }
356
+ probe.on('connect', () => done(true))
357
+ probe.on('error', () => done(false))
358
+ setTimeout(() => done(false), 250).unref?.()
359
+ })
360
+ if (!live) return false
361
+
362
+ const logger = runtime.engine?.logger
363
+ const message = attached === false
364
+ ? 'Another mikser is already running in this folder, and --no-attach means this one will not talk to '
365
+ + 'it. Two engines share the catalogue and the output tree with no lock between them; a --clear from '
366
+ + 'either is what produces a cold rebuild that renders nothing.'
367
+ : 'Another mikser is already running in this folder. This command reads and does not write, so it is '
368
+ + 'safe — but it may see a catalogue mid-cycle.'
369
+ logger?.warn?.({ code: 'instance-already-running' }, message)
370
+ return true
371
+ }
372
+
373
+ // Registered at setup so both halves are wired from one call.
374
+ export function instanceControl() {
375
+ serveInstance()
376
+ onLoaded(async () => {
377
+ if (runtime.options.watch || runtime.options.server) return
378
+ await warnIfHeld({
379
+ workingFolder: runtime.options.workingFolder,
380
+ attached: runtime.options.attach,
381
+ })
382
+ })
383
+ }
@@ -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/routes.js CHANGED
@@ -79,6 +79,21 @@ export function routeLocation(displayPath) {
79
79
  // authLabel bracketed reachability text override for the log.
80
80
  //
81
81
  // Returns the recorded descriptor.
82
+ // WHERE a plugin mounts: `/<name>`, overridable.
83
+ //
84
+ // Not a new rule — the one every plugin already follows. api at /api, auth at
85
+ // /auth, drive at /drive, forms at /forms, mcp at /mcp, preview at /preview,
86
+ // vector at /vector, decap at /admin. Each takes a `base` or `path` option so
87
+ // a project whose content wants that path can move it.
88
+ //
89
+ // The output folder is served from `/`, so a plugin route can shadow a page.
90
+ // That is a real cost and this is the accepted answer to it: a predictable
91
+ // name, and a way to change it. A reserved prefix like `/$name` would make
92
+ // the collision impossible, and would also make this plugin the only one
93
+ // shaped differently from the other eight — which is a worse trade than the
94
+ // collision it avoids, because the collision is rare and visible and the
95
+ // inconsistency is permanent.
96
+ //
82
97
  export function registerRoute({
83
98
  path,
84
99
  plugin,
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) {