mikser-io 11.3.1 → 11.4.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 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 at :3001
248
+ npx mikser --server # build + serve on a free port (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.
@@ -58,7 +58,7 @@ These options are part of `runtime.options` and apply to the engine itself.
58
58
  | `logInstall` | `--log-install` | string | — | Set the level on a **running** instance, so its own rebuilds are verbose too — the case a per-request flag cannot serve. Expires after 30 minutes, dies with the process, and is disclosed in the build report under `logLevel`. |
59
59
  | `logReset` | `--log-reset` | boolean | `false` | Return a running instance to its configured level. |
60
60
  | `threads` | — | number | `4` | Worker thread count for the Piscina pools (`renderWorkers`, `postprocessWorkers`). Both pools are lazy (`minThreads: 0` + `idleTimeout: 30_000`) so INLINE-only workloads spin up zero workers. |
61
- | `server` | `-s, --server [port]` | number\|boolean | — | When set, the engine creates a shared Express app on `runtime.options.app` and listens on the given port (default `3001`) after all plugins have mounted their routes. Plugins like `api` attach to it instead of starting their own server. The `outputFolder` is also served as a static catch-all route at `/` (plugin routes match first; anything that doesn't match falls through to the rendered output). Requires `express` to be installed. |
61
+ | `server` | `-s, --server [port]` | number\|boolean | — | When set, the engine creates a shared Express app on `runtime.options.app` and listens on the given port after all plugins have mounted their routes. **Command line only** — the port is read at `initialized`, before the config file is loaded, and `config.server` is the settings object for `cors` / `trustProxy` / `requestTimeout`, not a port. Omit the port (`--server`), or pass `0`, and the OS is asked for one nothing is using — the log line says which. That is the default because a fixed one is a number every account on a shared dev box gets, so the second mikser dies on a collision belonging to neither project. Name a port when something else has to find it (a reverse proxy, a bookmark): `--server 3002` binds 3002 or fails saying who holds it. Plugins like `api` attach to it instead of starting their own server. The `outputFolder` is also served as a static catch-all route at `/` (plugin routes match first; anything that doesn't match falls through to the rendered output). Requires `express` to be installed. |
62
62
  | `junk` | — | array\|false | built-in list | OS and file-manager litter, filtered out of both the scan and the watcher. The dot-prefixed files (`.DS_Store`, `._*`) were already invisible — globby defaults to `dot: false` and the watcher ignores leading dots — but the Windows ones are **not** dotfiles: `Thumbs.db` and `desktop.ini` were measurably scanned *and* watched, and became entities. The list is deliberately conservative (OS/file-manager artifacts and application lock files only, no `*.tmp`, `*.bak` or editor backups), because a filter that silently drops content is worse than the litter it prevents. `false` disables it; an array replaces it. See `isJunkPath` / `JUNK_IGNORE` in `src/utils.js`. Plugins that write metadata next to content add their own patterns with `registerJunk({ ignore, match })` — the engine provides the mechanism and the plugin the knowledge of what its files are called (`mikser-io-drive` registers `*.nephelemeta`). Plugin registrations survive an array override, since narrowing the OS list is not a request to start importing a library's sidecars. |
63
63
  | — (plugin) | — | object | — | `sources({ styles: { folder: 'styles', extensions: ['css'] } })` registers build inputs as catalog entities, one collection per key. A sidecar can then read them with `findEntities()`, whose queries land in the render's `refClosure` — so editing, adding or removing a part re-renders the bundle and nothing else. Reading the same files with `fs` instead works for one build and silently breaks watch, because the engine has no dependency on a file it never saw. Nothing is linked into `outputFolder`: these are inputs, not output. Named `sources` rather than `inputs` because `entity.inputs` already means something adjacent — bytes an output depends on without being entities at all. |
64
64
  | `cors` / `no-cors` | `--cors` / `--no-cors` | boolean | — | Toggle CORS on the engine's shared Express app. See `src/server.js` for the extensible header arrays plugins push onto. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "11.3.1",
3
+ "version": "11.4.0",
4
4
  "files": [
5
5
  "app.js",
6
6
  "index.js",
@@ -122,6 +122,16 @@ onFinalize(async () => {
122
122
  // transaction because better-sqlite3's transaction() callback is
123
123
  // sync-only.
124
124
  const m = sharedManifest
125
+ // Nothing to reconcile against, so nothing below means anything.
126
+ //
127
+ // Every other statement that touches the manifest sits inside a loop over
128
+ // journal entries, so this hook used to survive a null one by never
129
+ // reaching it. The no-output pass reads `_noOutputIds` unconditionally,
130
+ // which turned that latent case into a TypeError — and it is reachable:
131
+ // a plugin can schedule a cycle (createdHook does) in a process where the
132
+ // manifest's own onLoaded never ran, which is exactly how mikser-io-forms'
133
+ // tests found it.
134
+ if (!m) return
125
135
 
126
136
  // 2a. Stage file unlinks for deleted entities + their children.
127
137
  const deleted = new Set(deletedIds)
package/src/server.js CHANGED
@@ -23,6 +23,7 @@
23
23
  import path from 'node:path'
24
24
  import { fileURLToPath } from 'node:url'
25
25
  import { networkInterfaces } from 'node:os'
26
+ import { createServer } from 'node:net'
26
27
 
27
28
  import runtime from './runtime.js'
28
29
  import { useLogger } from './engine/index.js'
@@ -52,12 +53,55 @@ const STREAMING_REQUEST_TIMEOUT = 2 * 60 * 60 * 1000
52
53
 
53
54
  export function attachServerCliOptions(commander) {
54
55
  commander
55
- ?.option('-s --server [port]', 'start an Express server on the given port (defaults to 3001)')
56
+ ?.option('-s --server [port]',
57
+ 'start an Express server on the given port; omit it, or pass 0, for a free one')
56
58
  .option('--cors [origin]', 'restrict server CORS to a specific origin (default *)')
57
59
  .option('--no-cors', 'disable server CORS headers')
58
60
  .option('-u --url <url>', 'public URL where this mikser is reachable (e.g. https://blog.me.com). Webhook-capable plugins use https URLs for push notifications; other plugins use this when generating absolute URLs (email tracking links, forms share links, MCP previews, etc.).')
59
61
  }
60
62
 
63
+ // What a bare `--server` asks for: a port nothing is using.
64
+ //
65
+ // It used to be 3001, and 3001 is a number two people on one machine both
66
+ // get. The second one dies on a collision that has nothing to do with either
67
+ // project, and neither of them can pick a different number without asking
68
+ // the other — while the one thing they both actually want is "a port".
69
+ //
70
+ // A named port stays exactly as it was: `--server 3002` binds 3002 or fails
71
+ // saying why. This only changes what happens when nobody named one.
72
+ const DEFAULT_PORT = 0
73
+
74
+ // The port a given `--server` value asks for, with 0 meaning "a free one".
75
+ //
76
+ // Exported for its own test: the trap here is that 0 is falsy, and the
77
+ // previous `Number(x) || 3001` turned an explicit `--server 0` into 3001 —
78
+ // exactly the collision it was asking to avoid. Testing it through the
79
+ // lifecycle hook would mean importing express, which is not a dependency of
80
+ // this package.
81
+ export function requestedPort(server) {
82
+ const asked = server === true ? DEFAULT_PORT : Number(server)
83
+ return Number.isInteger(asked) && asked >= 0 ? asked : DEFAULT_PORT
84
+ }
85
+
86
+ // A port nothing is listening on, according to the OS.
87
+ //
88
+ // Binds to 0, reads what it was given, and lets it go. There is a gap between
89
+ // releasing it and the real listen below, so this is a strong preference
90
+ // rather than a reservation — if something takes the port in between, the
91
+ // EADDRINUSE handler further down says so plainly instead of pretending.
92
+ // Narrow enough not to matter on the machine this exists for; not narrow
93
+ // enough to claim it cannot happen.
94
+ export async function freePort() {
95
+ return new Promise((resolve, reject) => {
96
+ const probe = createServer()
97
+ probe.once('error', reject)
98
+ probe.listen(0, () => {
99
+ const { port } = probe.address()
100
+ probe.close(() => resolve(port))
101
+ })
102
+ })
103
+ }
104
+
61
105
  // Wire the server lifecycle hooks. Called by engine.js's setup() AFTER
62
106
  // engine's own onInitialized/onLoad registrations so the log-line order
63
107
  // stays "engine folder logs → server bring-up" rather than the reverse.
@@ -80,9 +124,29 @@ export function setupServer() {
80
124
  })
81
125
  runtime.options.app = express()
82
126
  ownsApp = true
83
- runtime.options.port = runtime.options.server === true
84
- ? 3001
85
- : Number(runtime.options.server) || 3001
127
+ runtime.options.port = requestedPort(runtime.options.server)
128
+
129
+ if (runtime.options.port === 0) {
130
+ // Resolved HERE rather than by handing 0 to listen(), even though
131
+ // the OS would assign one either way, because the port is read
132
+ // long before the bind: routes.js builds every operator-facing
133
+ // route URL from it, mikser-io-ngrok reads it to know what to
134
+ // tunnel. Worse, `options.server` itself is tested for
135
+ // truthiness in four places across three packages — the engine's
136
+ // own instance registration, the server bring-up below,
137
+ // mikser-io-ngrok, and mikser-io-post-email, whose delivery drain
138
+ // only runs when `watch || server`. A literal 0 left in place is
139
+ // falsy in all of them, so it would have turned the server off,
140
+ // and stopped form emails, on its way to choosing a port.
141
+ //
142
+ // So it becomes a real number as early as it can, and everything
143
+ // downstream sees exactly what it would have seen from
144
+ // `--server <that number>`.
145
+ const found = await freePort()
146
+ runtime.options.server = found
147
+ runtime.options.port = found
148
+ logger.info('Server port: %d (nothing named one, so this is a free port)', found)
149
+ }
86
150
  logger.debug('Server starting on port %d', runtime.options.port)
87
151
 
88
152
  // Trust-proxy: when mikser is behind a reverse proxy (nginx,
@@ -9,6 +9,7 @@ import _ from 'lodash'
9
9
  import realRuntime from '../src/runtime.js'
10
10
  import { resetServices } from '../src/services.js'
11
11
  import { matchEntity, normalize, changeExtension, getFormatInfo, checksum, AbortError } from '../src/utils/index.js'
12
+ import sift from 'sift'
12
13
 
13
14
  const OPERATION = {
14
15
  CREATE: 'create',
@@ -130,6 +131,23 @@ export function createHarness({
130
131
  }
131
132
  }
132
133
 
134
+ // One matcher for findEntity / findEntities / iterateEntities, and it is
135
+ // sift — the same library catalog.js runs queries through, so a filter
136
+ // that works in a unit test works in a build.
137
+ //
138
+ // These used to compare `e[key] === value` per key, which silently matched
139
+ // NOTHING for any mongo-style operator: `{ collection: { $ne: 'assets' } }`
140
+ // tested a string against an object and every entity failed. A plugin
141
+ // walking the catalog that way got an empty result and no error, so its
142
+ // test either passed vacuously or failed somewhere far from the cause —
143
+ // which is what happened to the assets orphan sweep, whose whole job is
144
+ // deciding what has no source.
145
+ const matching = (query) => {
146
+ if (!query) return [...entities]
147
+ if (typeof query === 'function') return entities.filter(query)
148
+ return entities.filter(sift(query))
149
+ }
150
+
133
151
  const core = {
134
152
  runtime,
135
153
  useLogger: () => logger,
@@ -169,28 +187,19 @@ export function createHarness({
169
187
  findEntity: async (query) => {
170
188
  if (!query) return entities[0]
171
189
  if (typeof query === 'function') return entities.find(query)
172
- return entities.find(e => Object.entries(query).every(([k, v]) => e[k] === v))
190
+ return matching(query)[0]
173
191
  },
174
192
  // Synchronous PK lookup mirroring catalog.js's findById. Layouts'
175
193
  // onBeforeRender hydrates dispatch ids through this — the harness
176
194
  // serves from the same in-memory entities array.
177
195
  findById: (id) => entities.find(e => e?.id === id) ?? catalogStub.byId.get(id),
178
- findEntities: async (query) => {
179
- if (!query) return [...entities]
180
- if (typeof query === 'function') return entities.filter(query)
181
- return entities.filter(e => Object.entries(query).every(([k, v]) => e[k] === v))
182
- },
196
+ findEntities: async (query) => matching(query),
183
197
  iterateEntities: async function* (query) {
184
198
  // Stub — yields the same set findEntities would return,
185
199
  // one entity at a time. Real impl in catalog.js chunks via
186
200
  // sqlite; the harness doesn't need that fidelity because
187
201
  // unit-test corpora are tiny.
188
- const filtered = !query
189
- ? [...entities]
190
- : (typeof query === 'function'
191
- ? entities.filter(query)
192
- : entities.filter(e => Object.entries(query).every(([k, v]) => e[k] === v)))
193
- for (const e of filtered) yield e
202
+ for (const e of matching(query)) yield e
194
203
  },
195
204
 
196
205
  // Rendering & postprocessing — capture for assertions