mikser-io 9.73.0 → 9.75.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/app.js CHANGED
@@ -49,8 +49,6 @@ function locate(argv) {
49
49
  longRunning,
50
50
  workingFolder: value('--working-folder', '-i') ?? '.',
51
51
  config: value('--config', '-c') ?? 'mikser.config.js',
52
- // Commander's negated form: `attach` is true unless --no-attach said so.
53
- attach: has('--no-attach') ? false : true,
54
52
  request,
55
53
  }
56
54
  }
@@ -62,18 +60,17 @@ async function main() {
62
60
  // A second server or watcher is the hazard this whole surface exists to
63
61
  // remove, and it is the one shape that cannot be answered by forwarding.
64
62
  // So it stops, rather than silently doing something else.
65
- if (where.attach !== false && where.longRunning) {
63
+ if (where.longRunning) {
66
64
  if (await isInstanceLive(workingFolder)) {
67
65
  process.stderr.write(
68
66
  'mikser: another mikser is already running in this folder, and a server or watcher cannot be '
69
67
  + 'forwarded to it — it would have to open a port on your behalf.\n'
70
- + 'Stop that one, or pass --no-attach to run a second engine here (two engines share the '
71
- + 'catalogue and the output tree, with no lock between them).\n')
68
+ + 'Stop that one first.\n')
72
69
  process.exit(1)
73
70
  }
74
71
  }
75
72
 
76
- if (where.attach !== false && !where.longRunning) {
73
+ if (!where.longRunning) {
77
74
  const code = await forward({
78
75
  workingFolder,
79
76
  config: path.resolve(where.workingFolder, where.config),
@@ -760,8 +760,9 @@ Three things it refuses or reports rather than guessing:
760
760
  to *become* the instance, which is not something a running one can do for
761
761
  you — it would have to open a port in your process. They exit 1 and say so,
762
762
  rather than building and leaving nothing on the port.
763
- - **A folder held by someone else.** `--no-attach` runs a private engine
764
- anyway — for checking that a cold start works — and says the folder is held.
763
+ There is no opt-out. A flag for running a second engine on a held folder
764
+ only ever enabled the accident this surface prevents, and stopping the
765
+ instance serves every case it was reached for.
765
766
 
766
767
  `--tool`, `--tools`, `--verify` and `--explain` forward as well, and for a
767
768
  different reason than builds do. They only read, so running one locally never
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.73.0",
3
+ "version": "9.75.0",
4
4
  "files": [
5
5
  "app.js",
6
6
  "index.js",
package/src/engine.js CHANGED
@@ -386,7 +386,6 @@ export async function setup(options) {
386
386
  runtime.engine.commander?.version(packageInfo.version)
387
387
  .option('-i --working-folder <folder>', 'set mikser working folder', './')
388
388
  .option('-c --config <file>', 'set mikser mikser.config.js location', './mikser.config.js')
389
- .option('--no-attach', 'start a private engine instead of forwarding to the one already running here')
390
389
  .option('-m --mode <mode>', 'set mikser runtime mode', 'development')
391
390
  .option('-r --clear', 'clear current state before execution', false)
392
391
  .option('-o --output-folder <folder>', 'set mikser output folder relative to working folder', 'out')
package/src/instance.js CHANGED
@@ -19,10 +19,12 @@
19
19
  // new to learn and no watermark to reason about — which matters, because a
20
20
  // design that needs discipline from the caller is the one that gets violated.
21
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.
22
+ // There is no opt-out. A flag for "run a second engine here anyway" only ever
23
+ // enabled the accident this file exists to prevent — two engines sharing one
24
+ // catalogue and one output tree with no lock between them — and no caller had
25
+ // a reason to want it that a stopped instance would not serve better. An
26
+ // option whose only use is the wrong one is not an escape hatch, it is a trap
27
+ // with a name.
26
28
 
27
29
  import net from 'node:net'
28
30
  import { createHash } from 'node:crypto'
@@ -280,7 +282,7 @@ function refuseConfig(socket, request, wrongConfig) {
280
282
  type: 'refused',
281
283
  reason: `this instance is running ${wrongConfig}, and you asked for ${path.resolve(request.config)}.`,
282
284
  detail: 'Answering would use the wrong config — the accident this refusal exists to prevent. '
283
- + 'Stop that instance, or pass --no-attach to run your own.',
285
+ + 'Stop that instance and run this again.',
284
286
  })
285
287
  }
286
288
 
@@ -341,7 +343,6 @@ async function serveBuild(socket, request, logger) {
341
343
  export function serveInstance() {
342
344
  onLoaded(async () => {
343
345
  if (!runtime.options.watch && !runtime.options.server) return
344
- if (runtime.options.attach === false) return
345
346
  const logger = runtime.engine?.logger
346
347
  const endpoint = socketPath(runtime.options.workingFolder)
347
348
 
@@ -402,53 +403,7 @@ export function serveInstance() {
402
403
 
403
404
  // Say so when a private engine is starting in a folder someone else holds.
404
405
  //
405
- // The local counterpart of the rule already written down for deployments —
406
- // "never run a one-shot mikser command against the deployment" — which existed
407
- // because there was no alternative. There is one now, so this covers what is
408
- // left: --no-attach, and the report-only runs that stay local by design.
409
- //
410
- // A warning rather than a refusal. --no-attach is how you deliberately check
411
- // that a cold start works, and refusing it would take away the escape hatch
412
- // this design depends on having.
413
- export async function warnIfHeld({ workingFolder, attached }) {
414
- const endpoint = socketPath(workingFolder)
415
- if (process.platform !== 'win32' && !existsSync(endpoint)) return false
416
-
417
- const live = await new Promise((resolve) => {
418
- const probe = net.connect(endpoint)
419
- const done = (answer) => { try { probe.destroy() } catch { /* already gone */ } resolve(answer) }
420
- probe.on('connect', () => done(true))
421
- probe.on('error', () => done(false))
422
- setTimeout(() => done(false), 250).unref?.()
423
- })
424
- if (!live) return false
425
-
426
- const logger = runtime.engine?.logger
427
- const message = attached === false
428
- ? 'Another mikser is already running in this folder, and --no-attach means this one will not talk to '
429
- + 'it. Two engines share the catalogue and the output tree with no lock between them; a --clear from '
430
- + 'either is what produces a cold rebuild that renders nothing.'
431
- : 'Another mikser is already running in this folder. This command reads and does not write, so it is '
432
- + 'safe — but it may see a catalogue mid-cycle.'
433
- logger?.warn?.({ code: 'instance-already-running' }, message)
434
- return true
435
- }
436
-
437
406
  // Registered at setup so both halves are wired from one call.
438
407
  export function instanceControl() {
439
408
  serveInstance()
440
- onLoaded(async () => {
441
- // Only --no-attach reaches this now. Everything else either forwarded
442
- // (a build, a report) or refused before setup ran (a second server or
443
- // watcher), so a process that is still here and not attaching is one
444
- // that deliberately opted out — and that is exactly the case worth
445
- // saying something about, long-running or not. The earlier gate
446
- // skipped watch and server, which excluded `--no-attach --server`:
447
- // the very command that puts two engines on one folder.
448
- if (runtime.options.attach !== false) return
449
- await warnIfHeld({
450
- workingFolder: runtime.options.workingFolder,
451
- attached: false,
452
- })
453
- })
454
409
  }
package/src/manifest.js CHANGED
@@ -1289,7 +1289,36 @@ onFinalize(async () => {
1289
1289
  // That made "resolve the collision by deleting the stub" delete the
1290
1290
  // homepage, which is the opposite of what the operator asked for and the
1291
1291
  // exact operation the new collision reporting invites.
1292
+ // Destinations THIS cycle's surviving renders just wrote.
1293
+ //
1294
+ // Their snapshot rows are not inserted until 3c below, so the
1295
+ // by-destination query cannot see them — the same blind spot 2a already
1296
+ // compensates for in the other direction ("recorded snapshots only
1297
+ // describe PRIOR cycles").
1298
+ //
1299
+ // Without this, renaming a source's extension unlinks the output the new
1300
+ // entity just wrote. `index.md` → `index.yml` keeps the entity NAME and
1301
+ // the destination but changes the id, so one cycle carries a DELETE for
1302
+ // the old id and a RENDER for the new one. The render writes the page,
1303
+ // the delete stages the same destination, nothing surviving claims it
1304
+ // yet, and the file goes — with `Rendered: 1`, a green build and no
1305
+ // warning. A second build does not fix it (nothing changed), nor does
1306
+ // touch (the input hash is the same); only a real content edit re-renders
1307
+ // it. Only --verify ever said so, and renaming an extension is the most
1308
+ // common action there is during a migration.
1309
+ const claimedByThisCycle = new Set()
1310
+ for (const { entity } of renderedEntries) {
1311
+ if (deleted.has(entity.id) || (entity.parent && deleted.has(entity.parent))) continue
1312
+ if (entity.destination) claimedByThisCycle.add(entity.destination)
1313
+ }
1314
+
1292
1315
  for (const { destination, reason } of filesToUnlink) {
1316
+ // Written this cycle by an entity that is staying. Keep it, and say
1317
+ // nothing: the snapshot recorded at 3c is this render's own, so there
1318
+ // is no staleness to report — unlike the surviving-snapshot case
1319
+ // below, where the bytes belong to the entity that went away.
1320
+ if (claimedByThisCycle.has(destination)) continue
1321
+
1293
1322
  // "Still claimed" means by something that SURVIVES this pass. The
1294
1323
  // ids going away here are not just the deleted entities: pagination
1295
1324
  // children staged above are removed too, and counting a child's own
package/src/server.js CHANGED
@@ -220,13 +220,42 @@ export function setupServer() {
220
220
  .sendFile(faviconFile)
221
221
  })
222
222
 
223
- await new Promise(resolve => {
224
- const httpServer = runtime.options.app.listen(runtime.options.port, () => {
223
+ // Why this needs an error handler AND an address() check, rather
224
+ // than trusting the callback:
225
+ //
226
+ // `app.listen(port, cb)` invokes cb even when the bind FAILED —
227
+ // observed with a squatter on the port, cb running with
228
+ // `address() === null` and EADDRINUSE arriving on the error event
229
+ // afterwards. With no error handler, the process logged
230
+ // "Server listening: http://localhost:3779", printed a LAN
231
+ // address, completed the build green, and answered nothing: every
232
+ // request went to whoever actually held the port, which on a dev
233
+ // machine is another project's site. The operator opens the
234
+ // address, sees somebody else's pages, and the log insists they
235
+ // are their own.
236
+ //
237
+ // A server that cannot bind is not a server. --server asks to
238
+ // BECOME the instance, which is exactly the request that cannot be
239
+ // satisfied by carrying on — the same reasoning that makes a second
240
+ // server exit 1 rather than forward.
241
+ await new Promise((resolve, reject) => {
242
+ const httpServer = runtime.options.app.listen(runtime.options.port, function onListening() {
225
243
  // Public URL wins for operator-clickable log lines —
226
244
  // a reverse-proxy/tunnel/ngrok setup binds locally but
227
245
  // is reached externally at runtime.options.url. Fall
228
246
  // back to the bind URL when no public origin is set.
229
- const externalUrl = runtime.options.url ?? `http://localhost:${runtime.options.port}`
247
+ // The bind is the authority on which port answers, not
248
+ // the port that was asked for. A null address means the
249
+ // listen failed and the error handler below owns it — say
250
+ // nothing here, or the log claims a port it does not have.
251
+ // `this`, not the const above: EventEmitter calls a
252
+ // listener with the emitter, and a synchronous listen
253
+ // reaches here while `httpServer` is still in its
254
+ // temporal dead zone.
255
+ const bound = this?.address?.()
256
+ if (!bound) return
257
+
258
+ const externalUrl = runtime.options.url ?? `http://localhost:${bound.port}`
230
259
  logger.info('Server listening: %s', externalUrl)
231
260
 
232
261
  // The addresses that are NOT localhost.
@@ -246,13 +275,25 @@ export function setupServer() {
246
275
  const lan = localAddresses()
247
276
  if (lan.length) {
248
277
  logger.info('Also on: %s', lan
249
- .map(address => `http://${address}:${runtime.options.port}`)
278
+ .map(address => `http://${address}:${bound.port}`)
250
279
  .join(' '))
251
280
  }
252
281
  }
253
282
  resolve()
254
283
  })
255
284
 
285
+ httpServer.on('error', (err) => {
286
+ if (err.code === 'EADDRINUSE') {
287
+ reject(new Error(
288
+ `port ${runtime.options.port} is already in use — something else is listening there, `
289
+ + `and requests to it will reach that instead of this build. `
290
+ + `Stop it, or pass --server <other port>.`))
291
+ return
292
+ }
293
+ reject(new Error(
294
+ `could not listen on port ${runtime.options.port} — ${err.code ?? err.message}`))
295
+ })
296
+
256
297
  // Node caps a single request at 5 minutes (requestTimeout,
257
298
  // default 300_000ms), which is not a timeout in the usual
258
299
  // sense here — it is an upload size limit expressed in