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 +20 -0
- package/app.js +50 -2
- package/docs/diagnostics.md +31 -0
- package/docs/rendering.md +18 -0
- package/index.js +3 -0
- package/package.json +1 -1
- package/src/engine.js +7 -0
- package/src/instance.js +383 -0
- package/src/plugins/render/asset.js +54 -2
- package/src/report.js +5 -0
- package/src/routes.js +15 -0
- package/src/runtime.js +19 -0
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
|
|
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()
|
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,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.
|
|
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')
|
package/src/instance.js
ADDED
|
@@ -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
|
-
|
|
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/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) {
|