mikser-io 11.6.0 → 11.8.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/docs/diagnostics.md +2 -0
- package/package.json +1 -1
- package/src/instance.js +1 -1
- package/src/manager.js +42 -1
- package/src/routes.js +24 -4
- package/src/server.js +31 -1
- package/src/source.js +48 -1
package/docs/diagnostics.md
CHANGED
|
@@ -1044,6 +1044,7 @@ distinction is the whole of [Faults](#faults) above.
|
|
|
1044
1044
|
| Code | Severity | Means |
|
|
1045
1045
|
| --- | --- | --- |
|
|
1046
1046
|
| `config-coverage-partial` | warn | This Node build has no module loader hooks, so the config stamp covers the entry file alone. Editing an imported module will not invalidate the cache. |
|
|
1047
|
+
| `config-stale-in-process` | warn | A config file changed on disk while `--watch` is running. Node has the module cached and config is only read at startup, so this rebuild and every one after it uses the logic the process booted with. Restart to pick it up. |
|
|
1047
1048
|
| `durable-open` | error | The durable store could not be opened. Auth grants and the change-set log are unavailable. |
|
|
1048
1049
|
| `durable-migration` | error | A registered migration failed. |
|
|
1049
1050
|
| `durable-gitignore` | error | The durable store could not be added to `.gitignore` — it holds credentials and the working folder is usually a repo. |
|
|
@@ -1063,6 +1064,7 @@ distinction is the whole of [Faults](#faults) above.
|
|
|
1063
1064
|
|
|
1064
1065
|
| Code | Severity | Means |
|
|
1065
1066
|
| --- | --- | --- |
|
|
1067
|
+
| `source-content-not-text` | warn | A `sources()` collection with `content: true` loaded a file whose bytes are not text. They are decoded as UTF-8 and stored mangled, and every consumer inherits that. Set `content: false` to catalogue the files by path instead — `entity.uri` still points at them. Once per collection. |
|
|
1066
1068
|
| `observer-bad-uri` | warn | An observer's `uri` is not an absolute URL, so no webhook can be routed to it. |
|
|
1067
1069
|
| `untracked-file-read` | warn | A template read a file outside every folder mikser takes entities from, so it has no entity and changing it invalidates nothing. |
|
|
1068
1070
|
| `progress` | info | A long phase reporting where it has got to. See [Progress](#progress). |
|
package/package.json
CHANGED
package/src/instance.js
CHANGED
|
@@ -288,7 +288,7 @@ function configMismatch(theirs) {
|
|
|
288
288
|
// configCoverage lists every local module the config graph pulled in, so a
|
|
289
289
|
// stat over that list catches an edit to an imported module — which a
|
|
290
290
|
// client-side checksum of the entry file would miss entirely.
|
|
291
|
-
async function configStale() {
|
|
291
|
+
export async function configStale() {
|
|
292
292
|
const covered = runtime.options.configCoverage?.files ?? []
|
|
293
293
|
if (!covered.length) return null
|
|
294
294
|
const { stat } = await import('node:fs/promises')
|
package/src/manager.js
CHANGED
|
@@ -4,6 +4,7 @@ import cron from 'node-cron'
|
|
|
4
4
|
import { onProcess, onFinalized } from './lifecycle.js'
|
|
5
5
|
import { resetReport } from './report.js'
|
|
6
6
|
import { useLogger } from './engine/index.js'
|
|
7
|
+
import { configStale } from './instance.js'
|
|
7
8
|
import { ACTION } from './constants.js'
|
|
8
9
|
import { junkFilter } from './utils/index.js'
|
|
9
10
|
|
|
@@ -22,12 +23,52 @@ const tasks = []
|
|
|
22
23
|
// would wipe it out of a one-shot build's report.
|
|
23
24
|
function scheduleProcess() {
|
|
24
25
|
clearTimeout(runtime.engine.processTimeout)
|
|
25
|
-
runtime.engine.processTimeout = setTimeout(() => {
|
|
26
|
+
runtime.engine.processTimeout = setTimeout(async () => {
|
|
27
|
+
await warnConfigStale()
|
|
26
28
|
resetReport()
|
|
27
29
|
runtime.process()
|
|
28
30
|
}, 1000)
|
|
29
31
|
}
|
|
30
32
|
|
|
33
|
+
// Editing config/*.js while watching changes nothing, and says nothing.
|
|
34
|
+
//
|
|
35
|
+
// Node has the module cached, and the config stamp is only compared at
|
|
36
|
+
// startup — that is where "Config changed since the last run. Wiping the
|
|
37
|
+
// cache…" comes from. So a watch process keeps running the logic it booted
|
|
38
|
+
// with, rebuilds happily, and reports green. Reported after it cost a
|
|
39
|
+
// consumer several rounds: a derivation plugin they had already fixed kept
|
|
40
|
+
// producing the old output, every symptom pointed at the data rather than at
|
|
41
|
+
// the process, and they reported it as broken twice before finding it.
|
|
42
|
+
//
|
|
43
|
+
// The check already exists — instance.js compares mtimes across
|
|
44
|
+
// configCoverage.files to refuse a forwarded command against drifted config.
|
|
45
|
+
// It just never ran on this path, where no client is asking.
|
|
46
|
+
//
|
|
47
|
+
// A warning, not a reload: re-importing a config module does not re-run the
|
|
48
|
+
// plugin registration that happened at boot, so a "reload" that only
|
|
49
|
+
// refreshed the module would be a more convincing version of the same lie.
|
|
50
|
+
// Restarting is the honest fix, and mikser_ping already reports `stale` for
|
|
51
|
+
// packages installed since boot on exactly this reasoning.
|
|
52
|
+
async function warnConfigStale() {
|
|
53
|
+
if (runtime.options.watch !== true) return
|
|
54
|
+
let changed = null
|
|
55
|
+
try {
|
|
56
|
+
changed = await configStale()
|
|
57
|
+
} catch { return } // never let a diagnostic break the rebuild
|
|
58
|
+
if (!changed || changed === configStaleReported) return
|
|
59
|
+
configStaleReported = changed
|
|
60
|
+
useLogger().warn(
|
|
61
|
+
{ code: 'config-stale-in-process', file: changed },
|
|
62
|
+
'Config changed on disk (%s) but this process is still running the version it started with — '
|
|
63
|
+
+ 'Node has the module cached and config is only re-read at startup. This rebuild, and every one '
|
|
64
|
+
+ 'after it, uses the OLD logic. Restart to pick it up.',
|
|
65
|
+
changed)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// One warning per file, so a watch session editing the same module in a loop
|
|
69
|
+
// does not print it on every save — and a DIFFERENT file still speaks up.
|
|
70
|
+
let configStaleReported = null
|
|
71
|
+
|
|
31
72
|
export async function createdHook(name, context) {
|
|
32
73
|
if (!runtime.started) return
|
|
33
74
|
|
package/src/routes.js
CHANGED
|
@@ -50,7 +50,12 @@ const DEFAULT_AUTH_LABEL = {
|
|
|
50
50
|
// an origin is known — public --url / config.url wins (clickable,
|
|
51
51
|
// shareable), localhost:port is the dev fallback — bare path when the
|
|
52
52
|
// engine doesn't own a listener (external-app embedding, no url set).
|
|
53
|
-
export function routeLocation(displayPath) {
|
|
53
|
+
export function routeLocation(displayPath, host) {
|
|
54
|
+
// A host-scoped route HAS an origin, and it is not this server's. Printing
|
|
55
|
+
// `http://localhost:3001//drive.example.com/` names a place nothing
|
|
56
|
+
// answers. Protocol-relative because the scheme belongs to whatever
|
|
57
|
+
// terminates TLS in front, which this process does not know.
|
|
58
|
+
if (host) return `//${host}${displayPath === '/' ? '/' : displayPath}`
|
|
54
59
|
const origin = runtime.options.url
|
|
55
60
|
?? (runtime.options.port ? `http://localhost:${runtime.options.port}` : null)
|
|
56
61
|
return origin ? `${origin}${displayPath}` : displayPath
|
|
@@ -62,12 +67,21 @@ export function routeLocation(displayPath) {
|
|
|
62
67
|
// '/drive') answers for its own requests rather than its parent's. Exported
|
|
63
68
|
// because the CORS middleware needs the same answer Express will reach, and a
|
|
64
69
|
// second implementation of "which mount is this" would drift from this one.
|
|
65
|
-
export function routeFor(requestPath) {
|
|
70
|
+
export function routeFor(requestPath, host) {
|
|
66
71
|
if (!requestPath) return null
|
|
67
72
|
let best = null
|
|
68
73
|
for (const route of runtime.routes ?? []) {
|
|
74
|
+
// A host-scoped route answers for its own domain and no other.
|
|
75
|
+
if (route.host && route.host !== host) continue
|
|
69
76
|
const base = route.path
|
|
70
|
-
|
|
77
|
+
// A host-scoped route mounted at `/` owns every path on that host —
|
|
78
|
+
// it IS the server there. Prefix matching cannot express that, since
|
|
79
|
+
// nothing starts with `//`. An unscoped route at `/` is left alone:
|
|
80
|
+
// it would otherwise swallow the whole site.
|
|
81
|
+
const owns = base === '/' && route.host
|
|
82
|
+
? true
|
|
83
|
+
: requestPath === base || requestPath.startsWith(`${base}/`)
|
|
84
|
+
if (!owns) continue
|
|
71
85
|
if (!best || base.length > best.path.length) best = route
|
|
72
86
|
}
|
|
73
87
|
return best
|
|
@@ -87,6 +101,10 @@ export function routeFor(requestPath) {
|
|
|
87
101
|
// facade must disable buffering for it (Caddy
|
|
88
102
|
// flush_interval -1, nginx proxy_buffering off).
|
|
89
103
|
// Default false.
|
|
104
|
+
// host when set, the mount answers only for this Host. Used
|
|
105
|
+
// by a route that takes a domain of its own — a WebDAV
|
|
106
|
+
// share at `/` on `drive.example.com`, say, which would
|
|
107
|
+
// otherwise shadow every page on the site.
|
|
90
108
|
// cors `false` to emit no CORS headers for this mount. The
|
|
91
109
|
// same word as the global `--no-cors` /
|
|
92
110
|
// `config.server.cors: false`, meaning the same thing,
|
|
@@ -138,6 +156,7 @@ export function registerRoute({
|
|
|
138
156
|
streaming = false,
|
|
139
157
|
methods = null,
|
|
140
158
|
cors,
|
|
159
|
+
host,
|
|
141
160
|
label,
|
|
142
161
|
detail,
|
|
143
162
|
displayPath,
|
|
@@ -162,6 +181,7 @@ export function registerRoute({
|
|
|
162
181
|
const descriptor = { path, plugin, reachability, streaming }
|
|
163
182
|
if (methods) descriptor.methods = methods.map(m => m.toUpperCase())
|
|
164
183
|
if (cors === false) descriptor.cors = false
|
|
184
|
+
if (host) descriptor.host = host
|
|
165
185
|
|
|
166
186
|
// Dedup by path — a re-register (same path) replaces rather than
|
|
167
187
|
// duplicates. Mounts happen once per process, but this keeps the
|
|
@@ -172,7 +192,7 @@ export function registerRoute({
|
|
|
172
192
|
|
|
173
193
|
const logger = useLogger()
|
|
174
194
|
if (logger) {
|
|
175
|
-
const location = routeLocation(displayPath ?? path)
|
|
195
|
+
const location = routeLocation(displayPath ?? path, host)
|
|
176
196
|
const bracket = authLabel ?? DEFAULT_AUTH_LABEL[reachability]
|
|
177
197
|
logger.info('%s mounted: %s [%s]%s',
|
|
178
198
|
label ?? plugin, location, bracket, detail ? ` ${detail}` : '')
|
package/src/server.js
CHANGED
|
@@ -223,7 +223,7 @@ export function setupServer() {
|
|
|
223
223
|
// because routes are registered at onLoaded and this middleware
|
|
224
224
|
// at onLoad — it is mounted before any of them exist, and every
|
|
225
225
|
// request arrives long after they all do.
|
|
226
|
-
const route = routeFor(req.path ?? req.url)
|
|
226
|
+
const route = routeFor(req.path ?? req.url, req.hostname)
|
|
227
227
|
|
|
228
228
|
// Does that mount answer OPTIONS itself?
|
|
229
229
|
//
|
|
@@ -249,6 +249,36 @@ export function setupServer() {
|
|
|
249
249
|
// PROPFIND, LOCK and the rest.
|
|
250
250
|
const ownsPreflight = Boolean(route?.methods?.includes('OPTIONS'))
|
|
251
251
|
|
|
252
|
+
// A bare OPTIONS is not a preflight, and must not be answered as
|
|
253
|
+
// one.
|
|
254
|
+
//
|
|
255
|
+
// A CORS preflight is defined by its headers: the browser sends
|
|
256
|
+
// `Access-Control-Request-Method`, and without it the request is
|
|
257
|
+
// something else entirely. This middleware used to answer ALL of
|
|
258
|
+
// them — so every path on the site, routed or not, replied 204 to
|
|
259
|
+
// any OPTIONS with a list of five REST verbs and no `DAV:` header.
|
|
260
|
+
//
|
|
261
|
+
// That is a manufactured "I exist and I am not WebDAV", and it is
|
|
262
|
+
// a stronger negative than the 404 a static path would otherwise
|
|
263
|
+
// give. The Microsoft WebDAV redirector establishes a session by
|
|
264
|
+
// walking up from the path it was given, and on a mikser site
|
|
265
|
+
// every level of that walk answered no — including `/` and
|
|
266
|
+
// `/drive`, neither of which is anything. Deployments that DO map
|
|
267
|
+
// on Windows (Nextcloud at /remote.php/dav, SharePoint) sit behind
|
|
268
|
+
// hosts that simply have nothing to say at those paths.
|
|
269
|
+
//
|
|
270
|
+
// Falling through costs nothing a browser wanted: a real preflight
|
|
271
|
+
// still carries the header and is still answered below, and a
|
|
272
|
+
// non-preflight OPTIONS now reaches whatever owns the path — the
|
|
273
|
+
// mount's own discovery response, or the static handler's 404.
|
|
274
|
+
//
|
|
275
|
+
// `origin: false` is how the package steps aside: with a falsy
|
|
276
|
+
// origin it never builds an originCallback, so it calls next()
|
|
277
|
+
// without touching the response (cors/lib/index.js:210-228).
|
|
278
|
+
if (req.method === 'OPTIONS' && !req.headers['access-control-request-method']) {
|
|
279
|
+
return callback(null, { origin: false })
|
|
280
|
+
}
|
|
281
|
+
|
|
252
282
|
// A route that wants no CORS headers at all.
|
|
253
283
|
//
|
|
254
284
|
// `origin: false` is the cors package's way of emitting none — the
|
package/src/source.js
CHANGED
|
@@ -44,7 +44,7 @@ import pMap from 'p-map'
|
|
|
44
44
|
import runtime from './runtime.js'
|
|
45
45
|
import { useLogger } from './engine/index.js'
|
|
46
46
|
import { ACTION } from './constants.js'
|
|
47
|
-
import { checksum as fileChecksum, checksumOf, junkIgnore } from './utils/index.js'
|
|
47
|
+
import { checksum as fileChecksum, checksumOf, junkIgnore, looksTextual } from './utils/index.js'
|
|
48
48
|
import { reportGated, reportChanged } from './report.js'
|
|
49
49
|
import { findById, findEntities, checksumsByCollection } from './catalog.js'
|
|
50
50
|
import { bypassReason } from './invalidation.js'
|
|
@@ -293,6 +293,39 @@ export function scanSummary({ cap, loaded, emitted = 0, skipped = 0, deleted = 0
|
|
|
293
293
|
* called per file; returned object is merged onto the base entity.
|
|
294
294
|
* Return null to skip a file.
|
|
295
295
|
*/
|
|
296
|
+
// Collections already warned about a `content: true` binary. Module-level so
|
|
297
|
+
// the warning survives across cycles — a watch rebuild re-reads the same
|
|
298
|
+
// files, and repeating it every cycle is how a warning gets filtered out.
|
|
299
|
+
const binaryWarned = new Set()
|
|
300
|
+
|
|
301
|
+
// `content: true` is right for the CSS and template parts useSource was built
|
|
302
|
+
// for, and wrong in silence for a binary. `toString('utf8')` never fails: it
|
|
303
|
+
// substitutes U+FFFD and returns a string, so a 4 MB PDF becomes 4 MB of
|
|
304
|
+
// mangled text and every consumer downstream inherits it.
|
|
305
|
+
//
|
|
306
|
+
// Reported by mikser-io-ocr, which sent one as a prompt and got back "Your
|
|
307
|
+
// input exceeds the context window of this model" — an error that names the
|
|
308
|
+
// document, so the reader goes and looks at the PDF rather than at the
|
|
309
|
+
// collection's config.
|
|
310
|
+
//
|
|
311
|
+
// Returns whether it warned, so the decision is testable without reading logs.
|
|
312
|
+
export function warnIfNotText(collection, name, bytes, logger) {
|
|
313
|
+
if (binaryWarned.has(collection)) return false
|
|
314
|
+
if (looksTextual(bytes.subarray(0, SNIFF_BYTES))) return false
|
|
315
|
+
binaryWarned.add(collection)
|
|
316
|
+
logger?.warn(
|
|
317
|
+
{ code: 'source-content-not-text', collection, sample: name },
|
|
318
|
+
'Source %j has `content: true` but %j is not text — its bytes are being decoded as UTF-8 and stored ' +
|
|
319
|
+
'mangled. Set `content: false` on the collection to catalogue these files by path instead ' +
|
|
320
|
+
'(entity.uri still points at them).',
|
|
321
|
+
collection, name)
|
|
322
|
+
return true
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Same prefix the filesystem provider sniffs (src/utils/entity.js). Enough
|
|
326
|
+
// to see a file header without decoding a whole document twice.
|
|
327
|
+
const SNIFF_BYTES = 8 * 1024
|
|
328
|
+
|
|
296
329
|
export function useSource(core, options) {
|
|
297
330
|
const {
|
|
298
331
|
runtime,
|
|
@@ -559,6 +592,20 @@ export function useSource(core, options) {
|
|
|
559
592
|
// Decoded from the bytes the checksum was taken over — not
|
|
560
593
|
// re-read. See the gate above.
|
|
561
594
|
base.content = bytes.toString('utf8')
|
|
595
|
+
|
|
596
|
+
// `content: true` is right for the CSS and template parts this
|
|
597
|
+
// was built for, and wrong in silence for a binary. toString
|
|
598
|
+
// never fails: it substitutes U+FFFD and hands back a string, so
|
|
599
|
+
// a 4 MB PDF becomes 4 MB of mangled text and every consumer
|
|
600
|
+
// downstream inherits it. Reported by mikser-io-ocr, which sent
|
|
601
|
+
// one as a prompt and got back "Your input exceeds the context
|
|
602
|
+
// window of this model" — an error that names the document and
|
|
603
|
+
// sends the reader to the PDF rather than to this line.
|
|
604
|
+
//
|
|
605
|
+
// Once per collection, not per file: a media folder catalogued
|
|
606
|
+
// this way is all binaries, and the fix is one setting either
|
|
607
|
+
// way.
|
|
608
|
+
warnIfNotText(collection, name, bytes, logger)
|
|
562
609
|
}
|
|
563
610
|
try {
|
|
564
611
|
const extra = await load({ file, name, relativePath, entity: base })
|