mikser-io 11.11.0 → 11.12.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/app.js +38 -0
- package/docs/diagnostics.md +1 -0
- package/package.json +1 -1
- package/src/auth.js +21 -10
- package/src/manager.js +86 -18
- package/src/plugins/files.js +30 -3
package/app.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
|
|
4
4
|
import { setup } from './index.js'
|
|
5
|
+
import runtime from './src/runtime.js'
|
|
5
6
|
import { forward, isInstanceLive } from './src/instance.js'
|
|
6
7
|
|
|
7
8
|
// Before anything is imported or read.
|
|
@@ -132,6 +133,43 @@ async function main() {
|
|
|
132
133
|
}
|
|
133
134
|
|
|
134
135
|
const mikser = await setup()
|
|
136
|
+
guardAgainstSilentDeath()
|
|
135
137
|
await mikser.start()
|
|
136
138
|
}
|
|
139
|
+
|
|
140
|
+
// A long-running process must not die of an asynchronous error nobody caught.
|
|
141
|
+
//
|
|
142
|
+
// Node's default for an unhandled rejection is to print it and exit 1. For a
|
|
143
|
+
// one-shot build that is right — the build failed, and the exit code is how a
|
|
144
|
+
// deploy finds out. For a WATCHER it is the worst available behaviour: the
|
|
145
|
+
// process is gone, the only evidence is an exit code, and whoever is using it
|
|
146
|
+
// finds out because the site stopped updating. Measured on a live site: four
|
|
147
|
+
// deaths in one afternoon, each from a file that moved between the event and
|
|
148
|
+
// the read, and each time the entire signal was "the server is not there any
|
|
149
|
+
// more".
|
|
150
|
+
//
|
|
151
|
+
// So the handler is honest about the difference. It always says what happened,
|
|
152
|
+
// with the stack, because a swallowed fault is the same silence in a different
|
|
153
|
+
// costume. It only keeps the process alive where staying alive is the correct
|
|
154
|
+
// answer.
|
|
155
|
+
//
|
|
156
|
+
// The specific rejections that caused those deaths are now caught where they
|
|
157
|
+
// happen (src/manager.js), which is the better fix. This is the floor under
|
|
158
|
+
// the ones nobody has found yet — a timer callback that rejects, a plugin's
|
|
159
|
+
// detached promise — and there is no shortage of those in an engine this size.
|
|
160
|
+
function guardAgainstSilentDeath() {
|
|
161
|
+
const longRunning = Boolean(runtime.options?.watch || runtime.options?.server)
|
|
162
|
+
process.on('unhandledRejection', (reason) => {
|
|
163
|
+
const logger = runtime.engine?.logger
|
|
164
|
+
const detail = reason?.stack ?? reason?.message ?? String(reason)
|
|
165
|
+
if (!longRunning) {
|
|
166
|
+
// Same outcome as the default, with a line saying so first.
|
|
167
|
+
logger?.fatal?.('Unhandled rejection: %s', detail)
|
|
168
|
+
process.exitCode = 1
|
|
169
|
+
throw reason
|
|
170
|
+
}
|
|
171
|
+
logger?.error('Unhandled rejection (the watcher is still running): %s', detail)
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
|
|
137
175
|
main()
|
package/docs/diagnostics.md
CHANGED
|
@@ -1072,6 +1072,7 @@ distinction is the whole of [Faults](#faults) above.
|
|
|
1072
1072
|
| --- | --- | --- |
|
|
1073
1073
|
| `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. |
|
|
1074
1074
|
| `front-matter-unreadable` | warn | A file's front-matter block is present but not valid YAML. Its attributes are not available and the text is left as written. Previously this threw and ended the whole run with a parser stack that never named the file. |
|
|
1075
|
+
| `source-vanished` | warn | A watched file disappeared between the event and the read. Nothing was imported for it. This is what an upload that writes a temporary file and renames it looks like, and it used to kill the process. |
|
|
1075
1076
|
| `observer-bad-uri` | warn | An observer's `uri` is not an absolute URL, so no webhook can be routed to it. |
|
|
1076
1077
|
| `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. |
|
|
1077
1078
|
| `progress` | info | A long phase reporting where it has got to. See [Progress](#progress). |
|
package/package.json
CHANGED
package/src/auth.js
CHANGED
|
@@ -136,17 +136,28 @@ export function reachabilityOf({ auth, token, allowRemote } = {}) {
|
|
|
136
136
|
// a non-ASCII password from a client that would otherwise send latin-1, which
|
|
137
137
|
// this server cannot match because it decodes the header as UTF-8.
|
|
138
138
|
//
|
|
139
|
-
//
|
|
140
|
-
// Windows WebDAV mini-redirector appears not to parse the parameter, and a
|
|
141
|
-
// challenge carrying it leaves a mapped drive unable to reconnect from stored
|
|
142
|
-
// credentials at all: before/after on a live deployment, with the parameter
|
|
143
|
-
// stripped at the proxy as the only variable, the client went from never
|
|
144
|
-
// sending an Authorization header to offering Basic unprompted. Interactive
|
|
145
|
-
// entry worked either way — that path sends Basic blind and never reads the
|
|
146
|
-
// challenge, which is the asymmetry that identified it.
|
|
139
|
+
// ON by default, which is where it started and where the evidence leaves it.
|
|
147
140
|
//
|
|
148
|
-
//
|
|
149
|
-
|
|
141
|
+
// It briefly defaulted off, on a report that stripping the parameter at the
|
|
142
|
+
// gate let a Windows client reconnect from stored credentials. That
|
|
143
|
+
// measurement did not hold: the improvement turned out to be a mapping
|
|
144
|
+
// reconnect being compared against an ad-hoc UNC access, and with the rewrite
|
|
145
|
+
// still in place the mount still fails. The third theory about that failure
|
|
146
|
+
// to look convincing and not survive contact.
|
|
147
|
+
//
|
|
148
|
+
// What is left is the RFC's own reason, and it points the other way. The
|
|
149
|
+
// clients that fail — the Windows mini-redirector and macOS WebDAVFS — both
|
|
150
|
+
// fail when replaying a credential from a STORE while a hand-typed one works,
|
|
151
|
+
// and a credential store is exactly where a non-ASCII password gets re-encoded
|
|
152
|
+
// on the way out. This server decodes the header as UTF-8, so a client that
|
|
153
|
+
// emits its platform codepage instead does not match. That is the case
|
|
154
|
+
// `charset` exists to prevent, so removing it is at best neutral and at worst
|
|
155
|
+
// the wrong direction.
|
|
156
|
+
//
|
|
157
|
+
// `charset: false` for a deployment that has measured a client which cannot
|
|
158
|
+
// parse the parameter. The point of the option is that this is a deployment's
|
|
159
|
+
// call, not that either answer is known to be right everywhere.
|
|
160
|
+
export function basicChallenge({ realm = 'mikser', charset = true } = {}) {
|
|
150
161
|
// A realm is a quoted-string, so a quote or a backslash inside one would
|
|
151
162
|
// end the field early and hand the rest to the parser as garbage. Cheap
|
|
152
163
|
// to prevent here, and impossible to prevent at five call sites.
|
package/src/manager.js
CHANGED
|
@@ -131,6 +131,44 @@ export async function deletedHook(name, context) {
|
|
|
131
131
|
// `ignored` and a function is the one form that has stayed stable.
|
|
132
132
|
const ignoreJunk = (filePath) => /[/\\]\./.test(filePath) || junkFilter()(filePath)
|
|
133
133
|
|
|
134
|
+
// Wait for the bytes to stop moving before saying a file arrived.
|
|
135
|
+
//
|
|
136
|
+
// Without this an event fires the moment a file appears, and the tools people
|
|
137
|
+
// actually upload with do not write in one go. A WebDAV client, Finder and
|
|
138
|
+
// `rsync` without --inplace all write a temporary file into the watched folder
|
|
139
|
+
// and rename it afterwards, so the sequence is: `add` for a name that is about
|
|
140
|
+
// to stop existing, a checksum read of a file that is still growing, and a
|
|
141
|
+
// rename out from under both.
|
|
142
|
+
//
|
|
143
|
+
// Measured on a live site during a video upload. Four server deaths in one
|
|
144
|
+
// afternoon from the vanishing half, and — worse, because nothing stopped —
|
|
145
|
+
// one derivative built from a file read at ZERO BYTES: its checksum note held
|
|
146
|
+
// d41d8cd98f00b204e9800998ecf8427e, the md5 of nothing, where a file that size
|
|
147
|
+
// should carry `size:head:tail`. The build called that current and the next
|
|
148
|
+
// one agreed with it.
|
|
149
|
+
//
|
|
150
|
+
// 500ms is the compromise the two failures point at from opposite sides. It is
|
|
151
|
+
// long enough to swallow a temp-file rename and a local tear, short enough
|
|
152
|
+
// that an incremental rebuild of a saved document still feels immediate — the
|
|
153
|
+
// engine's own budget for one is about 100ms. A big upload over a slow link
|
|
154
|
+
// needs more, and the files plugin asks for more; anything can, through
|
|
155
|
+
// `options`.
|
|
156
|
+
const AWAIT_WRITE_FINISH = { stabilityThreshold: 500, pollInterval: 100 }
|
|
157
|
+
|
|
158
|
+
// `interval` and `binaryInterval` are NOT here, and their absence is the fix.
|
|
159
|
+
//
|
|
160
|
+
// They were set to 1000 and 3000, which reads as a debounce and is not one:
|
|
161
|
+
// chokidar consumes both only inside `if (opts.usePolling)` (handler.js), and
|
|
162
|
+
// polling is off. They described an intention that never ran, which is the
|
|
163
|
+
// most expensive kind of setting to leave lying around — somebody reads them
|
|
164
|
+
// and stops looking for the debounce that is actually missing. Polling can be
|
|
165
|
+
// asked for explicitly through `options`, and then they would apply.
|
|
166
|
+
const WATCH_DEFAULTS = {
|
|
167
|
+
ignored: ignoreJunk,
|
|
168
|
+
ignoreInitial: true,
|
|
169
|
+
awaitWriteFinish: AWAIT_WRITE_FINISH,
|
|
170
|
+
}
|
|
171
|
+
|
|
134
172
|
// Watch a folder, with mikser's own settings and none of its lifecycle.
|
|
135
173
|
//
|
|
136
174
|
// `watch()` below turns file events into SYNC events — it is how a source
|
|
@@ -148,35 +186,65 @@ const ignoreJunk = (filePath) => /[/\\]\./.test(filePath) || junkFilter()(filePa
|
|
|
148
186
|
export function watchFolder(folder, handler, options = {}) {
|
|
149
187
|
return chokidar
|
|
150
188
|
.watch(folder, {
|
|
151
|
-
|
|
152
|
-
binaryInterval: 3000,
|
|
153
|
-
ignored: ignoreJunk,
|
|
154
|
-
ignoreInitial: true,
|
|
189
|
+
...WATCH_DEFAULTS,
|
|
155
190
|
followSymlinks: true,
|
|
156
191
|
...options,
|
|
157
192
|
})
|
|
158
193
|
.on('all', (event, fullPath) => handler(event, fullPath))
|
|
159
194
|
}
|
|
160
195
|
|
|
161
|
-
export function watch(name, folder, options = {
|
|
196
|
+
export function watch(name, folder, options = {}) {
|
|
162
197
|
if (runtime.options.watch !== true) return
|
|
163
198
|
|
|
164
|
-
|
|
199
|
+
// Every hook is AWAITED and its failure caught here.
|
|
200
|
+
//
|
|
201
|
+
// They used to be called and dropped: `createdHook(name, …)` with no
|
|
202
|
+
// await inside an async listener, which makes a rejection nobody is
|
|
203
|
+
// holding. There is no unhandledRejection handler in a plugin's reach, so
|
|
204
|
+
// the default applied — Node printed the error and exited 1. A watcher
|
|
205
|
+
// that dies of an asynchronous error from a file that moved is the worst
|
|
206
|
+
// available failure mode, because the only evidence is an exit code: it
|
|
207
|
+
// happened four times in one afternoon on a live site and each time the
|
|
208
|
+
// symptom was "the server is gone".
|
|
209
|
+
//
|
|
210
|
+
// Caught per event, not per watcher, so one bad file costs that file and
|
|
211
|
+
// not the process.
|
|
212
|
+
const deliver = async (hook, fullPath) => {
|
|
213
|
+
const relativePath = fullPath.replace(`${folder}/`, '')
|
|
214
|
+
try {
|
|
215
|
+
await hook(name, { relativePath })
|
|
216
|
+
} catch (err) {
|
|
217
|
+
reportWatchFailure(err, fullPath)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
chokidar.watch(folder, { ...WATCH_DEFAULTS, ...options })
|
|
165
222
|
.on('all', () => {
|
|
166
223
|
clearTimeout(runtime.engine.processTimeout)
|
|
167
224
|
})
|
|
168
|
-
.on('add',
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
225
|
+
.on('add', fullPath => deliver(createdHook, fullPath))
|
|
226
|
+
.on('change', fullPath => deliver(updatedHook, fullPath))
|
|
227
|
+
.on('unlink', fullPath => deliver(deletedHook, fullPath))
|
|
228
|
+
.on('error', err => reportWatchFailure(err))
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// A file that is gone is not an error; anything else is.
|
|
232
|
+
//
|
|
233
|
+
// The vanishing case is ordinary for a watcher — the event describes a moment
|
|
234
|
+
// that has already passed — so it is a warning naming the path, and the build
|
|
235
|
+
// carries on. Everything else keeps its stack, because a watcher that
|
|
236
|
+
// swallows real faults is the same silence in a different costume.
|
|
237
|
+
function reportWatchFailure(err, fullPath) {
|
|
238
|
+
const logger = useLogger()
|
|
239
|
+
if (err?.code === 'ENOENT') {
|
|
240
|
+
logger?.warn(
|
|
241
|
+
{ code: 'source-vanished', path: err.path ?? fullPath },
|
|
242
|
+
'Watched file disappeared before it could be read: %s. Nothing was imported for it — '
|
|
243
|
+
+ 'this is what an upload that writes a temporary file and renames it looks like.',
|
|
244
|
+
err.path ?? fullPath ?? 'unknown')
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
logger?.error('Watcher failed for %s: %s', fullPath ?? 'the watched folder', err?.stack ?? err?.message ?? err)
|
|
180
248
|
}
|
|
181
249
|
|
|
182
250
|
export function schedule(name, expression, context) {
|
package/src/plugins/files.js
CHANGED
|
@@ -103,7 +103,21 @@ export function files(options = {}) {
|
|
|
103
103
|
|
|
104
104
|
let synced = true
|
|
105
105
|
switch (action) {
|
|
106
|
-
case ACTION.CREATE:
|
|
106
|
+
case ACTION.CREATE: {
|
|
107
|
+
// The checksum FIRST, and the link only once it answered.
|
|
108
|
+
//
|
|
109
|
+
// Linking first left a symlink pointing at nothing when
|
|
110
|
+
// the source moved between the event and the read: the
|
|
111
|
+
// link was made, `checksum` threw ENOENT, the entity was
|
|
112
|
+
// never created, and out/ kept a dangling link that no
|
|
113
|
+
// catalog row admits to. It then broke whatever walked
|
|
114
|
+
// the output with stat — a consumer's own scope check,
|
|
115
|
+
// twice.
|
|
116
|
+
//
|
|
117
|
+
// Reading first makes the vanishing case leave nothing
|
|
118
|
+
// behind: the throw happens before anything is written,
|
|
119
|
+
// and the watcher logs it as the ordinary event it is.
|
|
120
|
+
const sourceChecksum = await checksum(source)
|
|
107
121
|
await ensureLink(relativePath)
|
|
108
122
|
await createEntity({
|
|
109
123
|
id,
|
|
@@ -118,10 +132,11 @@ export function files(options = {}) {
|
|
|
118
132
|
// $-ref to this entity expands to it; consumers read
|
|
119
133
|
// meta.url for the served location (ADR-0011).
|
|
120
134
|
meta: { url: '/' + name },
|
|
121
|
-
checksum:
|
|
135
|
+
checksum: sourceChecksum,
|
|
122
136
|
link: await link(source)
|
|
123
137
|
})
|
|
124
138
|
break
|
|
139
|
+
}
|
|
125
140
|
case ACTION.UPDATE: {
|
|
126
141
|
const current = await findEntity({ id })
|
|
127
142
|
// `checksum` is the source-checksum FUNCTION from the
|
|
@@ -177,7 +192,19 @@ export function files(options = {}) {
|
|
|
177
192
|
logger.debug('Files folder: %s', runtime.options.filesFolder)
|
|
178
193
|
await mkdir(runtime.options.filesFolder, { recursive: true })
|
|
179
194
|
|
|
180
|
-
|
|
195
|
+
// A longer settle than the engine's default, because this is the
|
|
196
|
+
// collection large binaries land in and they are the case that
|
|
197
|
+
// bit: a video uploaded over WebDAV grows for as long as the link
|
|
198
|
+
// takes, and an event fired mid-upload gets a checksum of a
|
|
199
|
+
// partial file — once, of a file read at zero bytes, which was
|
|
200
|
+
// then published as current.
|
|
201
|
+
//
|
|
202
|
+
// Three seconds of stability costs nothing here (nobody edits a
|
|
203
|
+
// 300 MB video and waits for the page) and buys the margin a slow
|
|
204
|
+
// or stalling link needs.
|
|
205
|
+
watch(collection, runtime.options.filesFolder, {
|
|
206
|
+
awaitWriteFinish: { stabilityThreshold: 3000, pollInterval: 200 },
|
|
207
|
+
})
|
|
181
208
|
})
|
|
182
209
|
|
|
183
210
|
onImport(async () => {
|