mikser-io 9.74.0 → 9.76.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.74.0",
3
+ "version": "9.76.0",
4
4
  "files": [
5
5
  "app.js",
6
6
  "index.js",
package/src/engine.js CHANGED
@@ -668,6 +668,15 @@ export async function setup(options) {
668
668
  // A postprocessor consumes the intermediate rendered
669
669
  // file, so skipping would leave its input missing.
670
670
  ? { skip: false, reason: 'postprocessor' }
671
+ // Scheduled BECAUSE the thing that renders it moved. The
672
+ // manifest cannot see that: an asset's input hash is its
673
+ // own source, and a preset's revision is not part of it,
674
+ // so the skip was correct about the entity and wrong about
675
+ // the render. Bumping a preset's `revision` — the
676
+ // documented way to force a rebuild — therefore deleted
677
+ // the stale marker and left the derivative untouched.
678
+ : options.rendererChanged
679
+ ? { skip: false, reason: 'renderer-changed' }
671
680
  : runtime.manifest?.skipDecision(entity, mutatedRefs, currentHashes, mutatedEntities)
672
681
  ?? { skip: false, reason: 'no-manifest' }
673
682
  if (decision.skip) {
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
@@ -81,6 +81,7 @@ export function assets(options = {}) {
81
81
  onSync,
82
82
  onFinalize,
83
83
  findEntity,
84
+ iterateEntities,
84
85
  matchEntity,
85
86
  changeExtension,
86
87
  constants: { ACTION, OPERATION },
@@ -223,7 +224,7 @@ export function assets(options = {}) {
223
224
  return result
224
225
  }
225
226
 
226
- async function renderPresets(entities) {
227
+ async function renderPresets(entities, { rendererChanged = new Set() } = {}) {
227
228
  const { presets, assetsMap } = runtime.state.assets
228
229
 
229
230
  const tasks = []
@@ -257,6 +258,10 @@ export function assets(options = {}) {
257
258
  ...entity.preset.options, // module defaults
258
259
  ...configOptions, // config overrides
259
260
  renderer: 'preset',
261
+ // The preset itself moved this cycle, so the manifest's
262
+ // "its source is unchanged" is true and beside the
263
+ // point. See the skip decision in engine.js.
264
+ rendererChanged: rendererChanged.has(entityToRender.id),
260
265
  ignore
261
266
  }
262
267
  })
@@ -453,16 +458,41 @@ export function assets(options = {}) {
453
458
  }
454
459
 
455
460
  const entitiesToRender = new Map()
461
+ // Entities scheduled because their PRESET changed, not their source.
462
+ const presetMoved = new Set()
456
463
  await map(useJournal('Assets provision', [OPERATION.CREATE, OPERATION.UPDATE], signal), async ({ entity }) => {
457
464
  if (entity.collection == collection) {
458
- for (let entityId in assetsMap) {
459
- if (assetsMap[entityId].find(preset => preset == entity.name)) {
460
- const entityToRender = await findEntity({
461
- id: entityId
462
- })
463
- if (!entitiesToRender.has(entityToRender.id)) {
464
- entitiesToRender.set(entityToRender.id, entityToRender)
465
- }
465
+ // A preset moved its `revision` was bumped, or its module
466
+ // changed. Everything that uses it has to re-render.
467
+ //
468
+ // Asked of the CATALOG, not of assetsMap. That map is built
469
+ // from this cycle's journal, so on a build where only the
470
+ // preset moved it is empty and this fan-out reached nothing:
471
+ // no asset was scheduled, the reuse check that would have
472
+ // said "the marker is too old" was never consulted, and the
473
+ // startup sweep deleted the old markers anyway because it
474
+ // walks the folder rather than the entities.
475
+ //
476
+ // The result was the worst available shape. Bumping `revision`
477
+ // — the documented way to force a rebuild — removed the marker
478
+ // and left the derivative at its old bytes. It looked like it
479
+ // had worked, nothing in the report said otherwise, and where
480
+ // the derived folder is gitignored the deployment served stale
481
+ // images until someone deleted the folder by hand.
482
+ //
483
+ // getEntityPresets is the same matcher the per-entity path
484
+ // uses, so there is one implementation of "does this entity
485
+ // use this preset" rather than a second one here that can
486
+ // drift.
487
+ for await (const candidate of iterateEntities({ collection: { $ne: collection } })) {
488
+ const candidatePresets = await getEntityPresets(candidate)
489
+ if (!candidatePresets.includes(entity.name)) continue
490
+ // renderPresets reads the presets to render off this map,
491
+ // and on a preset-only build nothing else populated it.
492
+ assetsMap[candidate.id] ??= candidatePresets
493
+ presetMoved.add(candidate.id)
494
+ if (!entitiesToRender.has(candidate.id)) {
495
+ entitiesToRender.set(candidate.id, candidate)
466
496
  }
467
497
  }
468
498
  } else {
@@ -472,7 +502,7 @@ export function assets(options = {}) {
472
502
  }
473
503
  }, { concurrency: 10, signal })
474
504
 
475
- await renderPresets(entitiesToRender.values())
505
+ await renderPresets(entitiesToRender.values(), { rendererChanged: presetMoved })
476
506
  })
477
507
 
478
508
  onComplete(async ({ entity, options }) => {
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