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 +1 -1
- package/docs/configuration.md +1 -1
- package/package.json +1 -1
- package/src/manifest/cycle.js +10 -0
- package/src/routes.js +31 -0
- package/src/server.js +107 -5
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
|
|
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.
|
package/docs/configuration.md
CHANGED
|
@@ -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
|
|
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
package/src/manifest/cycle.js
CHANGED
|
@@ -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]',
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
})
|