mikser-io 11.12.1 → 11.14.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/README.md +1 -1
- package/package.json +1 -1
- package/src/manager.js +68 -1
- package/src/runtime.js +18 -0
- package/src/server.js +90 -14
package/README.md
CHANGED
|
@@ -245,7 +245,7 @@ npm install mikser-io
|
|
|
245
245
|
```bash
|
|
246
246
|
npx mikser # one-shot build
|
|
247
247
|
npx mikser --watch # incremental dev loop
|
|
248
|
-
npx mikser --server # build + serve on
|
|
248
|
+
npx mikser --server # build + serve on the port it used last (or --server 3001 to name one)
|
|
249
249
|
```
|
|
250
250
|
|
|
251
251
|
For a working starter — config with a real plugin set, sample `documents/`, expected output — see [Getting Started](./docs/getting-started.md). Or skip straight to "add mikser to this app" via the [Claude Code plugin](#built-for-ai-assisted-development) above.
|
package/package.json
CHANGED
package/src/manager.js
CHANGED
|
@@ -21,12 +21,52 @@ const tasks = []
|
|
|
21
21
|
// Not done inside runtime.process(): the first cycle's `gated` count is
|
|
22
22
|
// recorded during import, which runs BEFORE process(), so resetting there
|
|
23
23
|
// would wipe it out of a one-shot build's report.
|
|
24
|
+
// File events that arrived while a cycle was running, waiting for it to end.
|
|
25
|
+
//
|
|
26
|
+
// Keyed by nothing and deduplicated by nothing: a file touched three times
|
|
27
|
+
// mid-cycle is three entries, and the source gate collapses them — it compares
|
|
28
|
+
// checksums and skips what has not moved. Deduplicating here would mean
|
|
29
|
+
// deciding that a CREATE and a DELETE for one path cancel out, which they do
|
|
30
|
+
// not.
|
|
31
|
+
const pending = []
|
|
32
|
+
|
|
33
|
+
// Replay them, now that the cycle is over.
|
|
34
|
+
//
|
|
35
|
+
// Each delivery journals and schedules on its own, exactly as it would have
|
|
36
|
+
// if it had arrived a moment later — which, as far as the engine is now
|
|
37
|
+
// concerned, it did.
|
|
38
|
+
async function flushPending() {
|
|
39
|
+
if (!pending.length || runtime.processing) return
|
|
40
|
+
const waiting = pending.splice(0, pending.length)
|
|
41
|
+
for (const { hook, name, relativePath, fullPath } of waiting) {
|
|
42
|
+
try {
|
|
43
|
+
await hook(name, { relativePath })
|
|
44
|
+
} catch (err) {
|
|
45
|
+
reportWatchFailure(err, fullPath)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Every cycle ends here, whichever started it — the watcher, `start()`, or a
|
|
51
|
+
// forwarded rebuild — so this is the one place that catches them all.
|
|
52
|
+
//
|
|
53
|
+
// `cycled` and not `onFinalized`: a finalize hook runs INSIDE the cycle, with
|
|
54
|
+
// `runtime.processing` still true, so the flush would defer the events it was
|
|
55
|
+
// called to deliver and the queue would sit there until something unrelated
|
|
56
|
+
// moved. Measured — the first version of this fix did exactly that and
|
|
57
|
+
// imported nothing.
|
|
58
|
+
runtime.hooks.cycled.push(flushPending)
|
|
59
|
+
|
|
24
60
|
function scheduleProcess() {
|
|
25
61
|
clearTimeout(runtime.engine.processTimeout)
|
|
26
62
|
runtime.engine.processTimeout = setTimeout(async () => {
|
|
27
63
|
await warnConfigStale()
|
|
28
64
|
resetReport()
|
|
29
|
-
runtime.process()
|
|
65
|
+
await runtime.process()
|
|
66
|
+
// A cancelled cycle runs no finalize hooks, so the flush above never
|
|
67
|
+
// fires for it and anything that arrived mid-cycle would wait for a
|
|
68
|
+
// later one that may never come.
|
|
69
|
+
await flushPending()
|
|
30
70
|
}, 1000)
|
|
31
71
|
}
|
|
32
72
|
|
|
@@ -223,6 +263,33 @@ export function watch(name, folder, options = {}) {
|
|
|
223
263
|
// not the process.
|
|
224
264
|
const deliver = async (hook, fullPath) => {
|
|
225
265
|
const relativePath = fullPath.replace(`${folder}/`, '')
|
|
266
|
+
// A file that changes WHILE a cycle is running belongs to the next
|
|
267
|
+
// one, so it waits.
|
|
268
|
+
//
|
|
269
|
+
// Delivered immediately, the sync journals the entity behind a cycle
|
|
270
|
+
// that has already passed its source gate and its dispatch. Later
|
|
271
|
+
// phases still walk the journal and the catalog is updated, so the
|
|
272
|
+
// change is half-applied — and then onFinalized clears the journal,
|
|
273
|
+
// so the next cycle starts with nothing to do and the entity never
|
|
274
|
+
// renders. No error anywhere; the build is green twice.
|
|
275
|
+
//
|
|
276
|
+
// Reported from production, and it is the shape a plugin reaches for
|
|
277
|
+
// on purpose: one that re-derives pages by touching its sources from
|
|
278
|
+
// `onProcess`. 47 PDFs touched, every mtime updated, two cycles
|
|
279
|
+
// completed, not one file re-imported. Worse than a missed rebuild,
|
|
280
|
+
// because the plugin had already recorded the work as handled — so
|
|
281
|
+
// nothing retried, and 46 pages served four-day-old prices with
|
|
282
|
+
// nothing in the log to say so.
|
|
283
|
+
//
|
|
284
|
+
// Deferring is the honest reading of what a cycle is: it processes
|
|
285
|
+
// the state as of its start. The alternative — keeping those journal
|
|
286
|
+
// entries for the next cycle — does not work, because the engine's
|
|
287
|
+
// own later walks legitimately consume them first; measured before
|
|
288
|
+
// choosing this.
|
|
289
|
+
if (runtime.processing) {
|
|
290
|
+
pending.push({ hook, name, relativePath, fullPath })
|
|
291
|
+
return
|
|
292
|
+
}
|
|
226
293
|
try {
|
|
227
294
|
await hook(name, { relativePath })
|
|
228
295
|
} catch (err) {
|
package/src/runtime.js
CHANGED
|
@@ -60,6 +60,11 @@ const runtime = {
|
|
|
60
60
|
finalized: [],
|
|
61
61
|
sync: [],
|
|
62
62
|
completed: [],
|
|
63
|
+
// Fires once a cycle is fully over and `processing` is false — see
|
|
64
|
+
// process(). The watcher uses it to replay file events it held back
|
|
65
|
+
// while the cycle ran; anything that must act BETWEEN cycles rather
|
|
66
|
+
// than inside one belongs here.
|
|
67
|
+
cycled: [],
|
|
63
68
|
},
|
|
64
69
|
|
|
65
70
|
// What each phase COST, not only what it did.
|
|
@@ -190,6 +195,11 @@ const runtime = {
|
|
|
190
195
|
await this.cancel()
|
|
191
196
|
}
|
|
192
197
|
await this.mutex.use(async () => {
|
|
198
|
+
// True for the whole cycle, including the gaps between phases —
|
|
199
|
+
// `phase` goes null between them, so it cannot answer "is a cycle
|
|
200
|
+
// running". The watcher reads this to decide whether a file event
|
|
201
|
+
// belongs to this cycle or the next one; see src/manager.js.
|
|
202
|
+
this.processing = true
|
|
193
203
|
try {
|
|
194
204
|
this.abortController = new AbortController()
|
|
195
205
|
const { signal } = this.abortController
|
|
@@ -205,8 +215,16 @@ const runtime = {
|
|
|
205
215
|
this.phase = 'cancelled'
|
|
206
216
|
for (let hook of this.hooks.cancelled) await hook()
|
|
207
217
|
this.phase = null
|
|
218
|
+
} finally {
|
|
219
|
+
this.processing = false
|
|
208
220
|
}
|
|
209
221
|
})
|
|
222
|
+
|
|
223
|
+
// After the mutex, so `processing` is already false and a hook that
|
|
224
|
+
// starts work of its own is not told a cycle is still running. Inside
|
|
225
|
+
// the cycle — at onFinalized, say — it would be, which is exactly the
|
|
226
|
+
// mistake this placement exists to avoid.
|
|
227
|
+
for (const hook of this.hooks.cycled) await hook()
|
|
210
228
|
},
|
|
211
229
|
|
|
212
230
|
async render(signal) {
|
package/src/server.js
CHANGED
|
@@ -24,6 +24,7 @@ import path from 'node:path'
|
|
|
24
24
|
import { fileURLToPath } from 'node:url'
|
|
25
25
|
import { networkInterfaces } from 'node:os'
|
|
26
26
|
import { createServer } from 'node:net'
|
|
27
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises'
|
|
27
28
|
|
|
28
29
|
import runtime from './runtime.js'
|
|
29
30
|
import { useLogger } from './engine/index.js'
|
|
@@ -84,23 +85,80 @@ export function requestedPort(server) {
|
|
|
84
85
|
return Number.isInteger(asked) && asked >= 0 ? asked : DEFAULT_PORT
|
|
85
86
|
}
|
|
86
87
|
|
|
88
|
+
// Where the LAST USED port is remembered, per working folder.
|
|
89
|
+
//
|
|
90
|
+
// A bare `--server` picks a free port, which solved two people colliding on
|
|
91
|
+
// 3001 and introduced a smaller annoyance: the number changed on every
|
|
92
|
+
// restart, so a bookmark, a tab, a terminal scrollback and anything else
|
|
93
|
+
// holding the old one went stale several times an afternoon.
|
|
94
|
+
//
|
|
95
|
+
// Last USED, not last auto-chosen: `--server 3002` once and a bare `--server`
|
|
96
|
+
// after it keeps 3002. Naming a port is the strongest statement available
|
|
97
|
+
// about which one is wanted, and forgetting it the moment the flag is dropped
|
|
98
|
+
// would make the memory useless exactly where it is most deliberate.
|
|
99
|
+
//
|
|
100
|
+
// The runtime folder is the right lifetime. It is per working folder, so two
|
|
101
|
+
// projects still get different ports and two users still do not collide; it
|
|
102
|
+
// survives a restart, which is the whole point; and `--clear` removes the
|
|
103
|
+
// cache database rather than this, so asking for a cold rebuild does not also
|
|
104
|
+
// move the server. Deleting the folder forgets it, which is the correct
|
|
105
|
+
// answer to "give me a different port".
|
|
106
|
+
const PORT_FILE = 'server-port'
|
|
107
|
+
|
|
108
|
+
const portFile = () => path.join(runtime.options.runtimeFolder ?? '.', PORT_FILE)
|
|
109
|
+
|
|
110
|
+
async function rememberedPort() {
|
|
111
|
+
try {
|
|
112
|
+
const port = Number((await readFile(portFile(), 'utf8')).trim())
|
|
113
|
+
// A file someone edited by hand, or a half-written one. Anything that
|
|
114
|
+
// is not a usable port is treated as no preference rather than as an
|
|
115
|
+
// error — the fallback is exactly what this feature replaced.
|
|
116
|
+
return Number.isInteger(port) && port > 0 && port < 65536 ? port : null
|
|
117
|
+
} catch {
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function rememberPort(port) {
|
|
123
|
+
try {
|
|
124
|
+
await mkdir(path.dirname(portFile()), { recursive: true })
|
|
125
|
+
await writeFile(portFile(), `${port}\n`)
|
|
126
|
+
} catch (err) {
|
|
127
|
+
// Not worth failing a build over. The cost is the port moving again
|
|
128
|
+
// next time, which is where this started.
|
|
129
|
+
useLogger()?.debug?.('Could not remember the server port: %s', err.message)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
87
133
|
// A port nothing is listening on, according to the OS.
|
|
88
134
|
//
|
|
89
|
-
// Binds to
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
135
|
+
// Binds to `preferred` when one is given and takes it if it is free —
|
|
136
|
+
// which is how a restart keeps the port it had. Falls back to binding 0,
|
|
137
|
+
// which is the OS choosing.
|
|
138
|
+
//
|
|
139
|
+
// Either way it lets the port go again. There is a gap between releasing it
|
|
140
|
+
// and the real listen below, so this is a strong preference rather than a
|
|
141
|
+
// reservation — if something takes the port in between, the EADDRINUSE
|
|
142
|
+
// handler further down says so plainly instead of pretending. Narrow enough
|
|
143
|
+
// not to matter on the machine this exists for; not narrow enough to claim it
|
|
144
|
+
// cannot happen.
|
|
145
|
+
export async function freePort(preferred = 0) {
|
|
146
|
+
const bind = (port) => new Promise((resolve) => {
|
|
97
147
|
const probe = createServer()
|
|
98
|
-
probe.once('error',
|
|
99
|
-
probe.listen(
|
|
100
|
-
const { port } = probe.address()
|
|
101
|
-
probe.close(() => resolve(
|
|
148
|
+
probe.once('error', () => resolve(null))
|
|
149
|
+
probe.listen(port, () => {
|
|
150
|
+
const { port: got } = probe.address()
|
|
151
|
+
probe.close(() => resolve(got))
|
|
102
152
|
})
|
|
103
153
|
})
|
|
154
|
+
|
|
155
|
+
if (preferred) {
|
|
156
|
+
const kept = await bind(preferred)
|
|
157
|
+
if (kept) return kept
|
|
158
|
+
}
|
|
159
|
+
const found = await bind(0)
|
|
160
|
+
if (found) return found
|
|
161
|
+
throw new Error('Could not find a free port to listen on')
|
|
104
162
|
}
|
|
105
163
|
|
|
106
164
|
// Wire the server lifecycle hooks. Called by engine.js's setup() AFTER
|
|
@@ -143,11 +201,29 @@ export function setupServer() {
|
|
|
143
201
|
// So it becomes a real number as early as it can, and everything
|
|
144
202
|
// downstream sees exactly what it would have seen from
|
|
145
203
|
// `--server <that number>`.
|
|
146
|
-
const
|
|
204
|
+
const kept = await rememberedPort()
|
|
205
|
+
const found = await freePort(kept)
|
|
147
206
|
runtime.options.server = found
|
|
148
207
|
runtime.options.port = found
|
|
149
|
-
|
|
208
|
+
// Written BEFORE it is announced. Anything reading the log and
|
|
209
|
+
// acting on the port — a wrapper script, a test harness, a person
|
|
210
|
+
// with a fast Ctrl-C — can otherwise beat the write and leave the
|
|
211
|
+
// port unremembered, which looks exactly like the feature not
|
|
212
|
+
// working.
|
|
213
|
+
await rememberPort(found)
|
|
214
|
+
logger.info(found === kept
|
|
215
|
+
? 'Server port: %d (the same one as last time)'
|
|
216
|
+
: kept
|
|
217
|
+
? 'Server port: %d (last time it was %d, which is taken)'
|
|
218
|
+
: 'Server port: %d (nothing named one, so this is a free port)', found, kept)
|
|
219
|
+
}
|
|
220
|
+
// A NAMED port is remembered too — that is what "last used" means,
|
|
221
|
+
// and it is the case where the number was chosen on purpose. The auto
|
|
222
|
+
// branch above has already written its own.
|
|
223
|
+
if (requestedPort(runtime.options.server) !== 0) {
|
|
224
|
+
await rememberPort(runtime.options.port)
|
|
150
225
|
}
|
|
226
|
+
|
|
151
227
|
logger.debug('Server starting on port %d', runtime.options.port)
|
|
152
228
|
|
|
153
229
|
// Trust-proxy: when mikser is behind a reverse proxy (nginx,
|