mikser-io 11.3.2 → 11.5.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.2",
3
+ "version": "11.5.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/routes.js CHANGED
@@ -56,6 +56,23 @@ export function routeLocation(displayPath) {
56
56
  return origin ? `${origin}${displayPath}` : displayPath
57
57
  }
58
58
 
59
+ // The registered route a request path falls inside, or null.
60
+ //
61
+ // Longest prefix wins, so a mount nested under another ('/drive/notes' under
62
+ // '/drive') answers for its own requests rather than its parent's. Exported
63
+ // because the CORS middleware needs the same answer Express will reach, and a
64
+ // second implementation of "which mount is this" would drift from this one.
65
+ export function routeFor(requestPath) {
66
+ if (!requestPath) return null
67
+ let best = null
68
+ for (const route of runtime.routes ?? []) {
69
+ const base = route.path
70
+ if (requestPath !== base && !requestPath.startsWith(`${base}/`)) continue
71
+ if (!best || base.length > best.path.length) best = route
72
+ }
73
+ return best
74
+ }
75
+
59
76
  // Declare a mounted route. Records the proxy-relevant descriptor on
60
77
  // runtime.routes AND emits the standard mount log.
61
78
  //
@@ -70,6 +87,14 @@ export function routeLocation(displayPath) {
70
87
  // facade must disable buffering for it (Caddy
71
88
  // flush_interval -1, nginx proxy_buffering off).
72
89
  // Default false.
90
+ // methods the HTTP verbs this mount actually serves. Two
91
+ // consequences, and the second one is load-bearing:
92
+ // CORS advertises these for the route instead of a
93
+ // fixed five, and listing OPTIONS declares that the
94
+ // mount answers OPTIONS ITSELF — so the global
95
+ // preflight steps aside rather than terminating it.
96
+ // Default null, meaning "ordinary REST verbs, and the
97
+ // preflight is CORS's to answer".
73
98
  // label log prefix. Defaults to `plugin`.
74
99
  // detail optional already-formatted log suffix, e.g.
75
100
  // '(ops=[list,subscribe])'.
@@ -99,6 +124,7 @@ export function registerRoute({
99
124
  plugin,
100
125
  reachability = 'public',
101
126
  streaming = false,
127
+ methods = null,
102
128
  label,
103
129
  detail,
104
130
  displayPath,
@@ -112,7 +138,12 @@ export function registerRoute({
112
138
  )
113
139
  }
114
140
 
141
+ if (methods != null && (!Array.isArray(methods) || methods.some(m => typeof m !== 'string'))) {
142
+ throw new Error('registerRoute: `methods` must be an array of verb strings')
143
+ }
144
+
115
145
  const descriptor = { path, plugin, reachability, streaming }
146
+ if (methods) descriptor.methods = methods.map(m => m.toUpperCase())
116
147
 
117
148
  // Dedup by path — a re-register (same path) replaces rather than
118
149
  // duplicates. Mounts happen once per process, but this keeps the
package/src/server.js CHANGED
@@ -23,10 +23,12 @@
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'
29
30
  import { onInitialized, onLoad, onLoaded } from './lifecycle.js'
31
+ import { routeFor } from './routes.js'
30
32
 
31
33
  // Every non-loopback IPv4 address this machine answers on.
32
34
  //
@@ -52,12 +54,55 @@ const STREAMING_REQUEST_TIMEOUT = 2 * 60 * 60 * 1000
52
54
 
53
55
  export function attachServerCliOptions(commander) {
54
56
  commander
55
- ?.option('-s --server [port]', 'start an Express server on the given port (defaults to 3001)')
57
+ ?.option('-s --server [port]',
58
+ 'start an Express server on the given port; omit it, or pass 0, for a free one')
56
59
  .option('--cors [origin]', 'restrict server CORS to a specific origin (default *)')
57
60
  .option('--no-cors', 'disable server CORS headers')
58
61
  .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
62
  }
60
63
 
64
+ // What a bare `--server` asks for: a port nothing is using.
65
+ //
66
+ // It used to be 3001, and 3001 is a number two people on one machine both
67
+ // get. The second one dies on a collision that has nothing to do with either
68
+ // project, and neither of them can pick a different number without asking
69
+ // the other — while the one thing they both actually want is "a port".
70
+ //
71
+ // A named port stays exactly as it was: `--server 3002` binds 3002 or fails
72
+ // saying why. This only changes what happens when nobody named one.
73
+ const DEFAULT_PORT = 0
74
+
75
+ // The port a given `--server` value asks for, with 0 meaning "a free one".
76
+ //
77
+ // Exported for its own test: the trap here is that 0 is falsy, and the
78
+ // previous `Number(x) || 3001` turned an explicit `--server 0` into 3001 —
79
+ // exactly the collision it was asking to avoid. Testing it through the
80
+ // lifecycle hook would mean importing express, which is not a dependency of
81
+ // this package.
82
+ export function requestedPort(server) {
83
+ const asked = server === true ? DEFAULT_PORT : Number(server)
84
+ return Number.isInteger(asked) && asked >= 0 ? asked : DEFAULT_PORT
85
+ }
86
+
87
+ // A port nothing is listening on, according to the OS.
88
+ //
89
+ // Binds to 0, reads what it was given, and lets it go. There is a gap between
90
+ // releasing it and the real listen below, so this is a strong preference
91
+ // rather than a reservation — if something takes the port in between, the
92
+ // EADDRINUSE handler further down says so plainly instead of pretending.
93
+ // Narrow enough not to matter on the machine this exists for; not narrow
94
+ // enough to claim it cannot happen.
95
+ export async function freePort() {
96
+ return new Promise((resolve, reject) => {
97
+ const probe = createServer()
98
+ probe.once('error', reject)
99
+ probe.listen(0, () => {
100
+ const { port } = probe.address()
101
+ probe.close(() => resolve(port))
102
+ })
103
+ })
104
+ }
105
+
61
106
  // Wire the server lifecycle hooks. Called by engine.js's setup() AFTER
62
107
  // engine's own onInitialized/onLoad registrations so the log-line order
63
108
  // stays "engine folder logs → server bring-up" rather than the reverse.
@@ -80,9 +125,29 @@ export function setupServer() {
80
125
  })
81
126
  runtime.options.app = express()
82
127
  ownsApp = true
83
- runtime.options.port = runtime.options.server === true
84
- ? 3001
85
- : Number(runtime.options.server) || 3001
128
+ runtime.options.port = requestedPort(runtime.options.server)
129
+
130
+ if (runtime.options.port === 0) {
131
+ // Resolved HERE rather than by handing 0 to listen(), even though
132
+ // the OS would assign one either way, because the port is read
133
+ // long before the bind: routes.js builds every operator-facing
134
+ // route URL from it, mikser-io-ngrok reads it to know what to
135
+ // tunnel. Worse, `options.server` itself is tested for
136
+ // truthiness in four places across three packages — the engine's
137
+ // own instance registration, the server bring-up below,
138
+ // mikser-io-ngrok, and mikser-io-post-email, whose delivery drain
139
+ // only runs when `watch || server`. A literal 0 left in place is
140
+ // falsy in all of them, so it would have turned the server off,
141
+ // and stopped form emails, on its way to choosing a port.
142
+ //
143
+ // So it becomes a real number as early as it can, and everything
144
+ // downstream sees exactly what it would have seen from
145
+ // `--server <that number>`.
146
+ const found = await freePort()
147
+ runtime.options.server = found
148
+ runtime.options.port = found
149
+ logger.info('Server port: %d (nothing named one, so this is a free port)', found)
150
+ }
86
151
  logger.debug('Server starting on port %d', runtime.options.port)
87
152
 
88
153
  // Trust-proxy: when mikser is behind a reverse proxy (nginx,
@@ -153,11 +218,48 @@ export function setupServer() {
153
218
  // `origin: false` is how the cors package emits no CORS headers at all, which is
154
219
  // what --no-cors / config.server.cors:false asks for.
155
220
  if (!configured) return callback(null, { origin: false })
221
+
222
+ // The mount this request falls inside, if any. Asked per request
223
+ // because routes are registered at onLoaded and this middleware
224
+ // at onLoad — it is mounted before any of them exist, and every
225
+ // request arrives long after they all do.
226
+ const route = routeFor(req.path ?? req.url)
227
+
228
+ // Does that mount answer OPTIONS itself?
229
+ //
230
+ // The cors package defaults to preflightContinue: false, so it
231
+ // ENDS every OPTIONS in the process — 204, CORS headers, no
232
+ // next(). For a browser preflight that is exactly right, and it is
233
+ // what /mcp and /app want: their clients are browsers completing a
234
+ // Streamable HTTP handshake, which is why the mcp plugin pushes
235
+ // mcp-session-id into corsAllowHeaders.
236
+ //
237
+ // WebDAV is not that. The Microsoft WebDAV redirector decides
238
+ // whether a URL is a share at all from the `DAV:` header on
239
+ // OPTIONS — it is DISCOVERY, not a preflight. Answering it here
240
+ // returned 204 with no DAV header and five REST verbs, so Explorer
241
+ // reported ERROR_BAD_NET_NAME (0x80070043) and never even asked
242
+ // for credentials. Every other DAV client works, because Finder,
243
+ // curl, Cyberduck and gvfs do not gate on that header — so this
244
+ // read as "WebDAV is broken on Windows" with the mount logged
245
+ // happily and a 204 that looks like a normal preflight.
246
+ //
247
+ // nephele already computes the right answer and had it thrown
248
+ // away: its OPTIONS builds `DAV: 1, 3, 2` and an Allow list with
249
+ // PROPFIND, LOCK and the rest.
250
+ const ownsPreflight = Boolean(route?.methods?.includes('OPTIONS'))
251
+
156
252
  callback(null, {
157
253
  origin: configured === true ? '*' : String(configured),
158
- methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
254
+ // The verbs the mount actually serves, when it said. A fixed
255
+ // five advertised DELETE on a read-only mount and hid
256
+ // PROPFIND on a DAV one.
257
+ methods: route?.methods ?? ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
159
258
  allowedHeaders: runtime.options.corsAllowHeaders,
160
259
  exposedHeaders: runtime.options.corsExposeHeaders,
260
+ // Set the CORS headers, then hand the request on to the mount
261
+ // instead of ending it here.
262
+ preflightContinue: ownsPreflight,
161
263
  })
162
264
  }))
163
265
  })