dsh-instruction-memory 1.0.13 → 1.0.15
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/CHANGELOG.md +265 -0
- package/README.md +145 -20
- package/lib/client.js +617 -78
- package/lib/index.js +505 -46
- package/package.json +7 -2
package/lib/index.js
CHANGED
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
* plugin's fiber, so unmounting the row removes all of them.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { copyFile, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
|
13
|
+
import { copyFile, mkdir, open, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
|
|
14
|
+
import { watch } from 'node:fs'
|
|
14
15
|
import { homedir } from 'node:os'
|
|
15
|
-
import { dirname, join } from 'node:path'
|
|
16
|
+
import { basename, dirname, join } from 'node:path'
|
|
16
17
|
|
|
17
18
|
export const name = 'instruction-memory'
|
|
18
19
|
|
|
@@ -105,6 +106,13 @@ const MAX_WHEN = 200
|
|
|
105
106
|
*/
|
|
106
107
|
export const MAX_BODY_CHARS = MAX_ENTRIES * (MAX_CONTENT + MAX_TITLE + MAX_WHEN + 200) * 2 + 100_000
|
|
107
108
|
|
|
109
|
+
/** How long a crashed holder's `memory.json.lock` may block writes before it is stolen. */
|
|
110
|
+
const LOCK_STALE_MS = 10_000
|
|
111
|
+
/** How long a write waits for the cross-process lock before proceeding unlocked. */
|
|
112
|
+
const LOCK_WAIT_MS = 2_000
|
|
113
|
+
/** Coalescing window for store-directory watcher events before a re-read. */
|
|
114
|
+
const WATCH_DEBOUNCE_MS = 250
|
|
115
|
+
|
|
108
116
|
/* ------------------------------------------------------------------ *
|
|
109
117
|
* Pure helpers (no ctx, fully unit-testable)
|
|
110
118
|
* ------------------------------------------------------------------ */
|
|
@@ -187,18 +195,64 @@ export function truncatedFields(raw) {
|
|
|
187
195
|
return cut
|
|
188
196
|
}
|
|
189
197
|
|
|
198
|
+
/** JSON.stringify keeps quotes/type visible; the String() tail is for values JSON cannot encode. */
|
|
199
|
+
const describeValue = (value) => {
|
|
200
|
+
const text = JSON.stringify(value)
|
|
201
|
+
return typeof text === 'string' ? text : String(value)
|
|
202
|
+
}
|
|
203
|
+
|
|
190
204
|
/**
|
|
191
205
|
* Load a parsed memory file. Returns the sanitized data plus how many array
|
|
192
206
|
* items were discarded (blank entries, or valid ones past the cap). The count
|
|
193
207
|
* is surfaced rather than swallowed because the next save rewrites the file
|
|
194
208
|
* without those items — silently destroying user data is exactly what this
|
|
195
209
|
* plugin must never do.
|
|
210
|
+
*
|
|
211
|
+
* `rev` is the store revision, bumped on every committed write and persisted
|
|
212
|
+
* in the file. Clients echo it back as `baseRev` so a write built on a stale
|
|
213
|
+
* snapshot (another window, another process) can be refused instead of
|
|
214
|
+
* silently overwriting it. Missing (pre-rev files) reads as 0.
|
|
215
|
+
*
|
|
216
|
+
* A numeric `version` other than 1 sets `rejectedVersion` and leaves the data
|
|
217
|
+
* untouched: a future format's fields cannot be best-effort parsed as v1, and
|
|
218
|
+
* adopting a misread would let the next save rewrite the file in the wrong
|
|
219
|
+
* shape. A MISSING version still loads as v1 — the field has been written as
|
|
220
|
+
* 1 since the first release, so absence is an old/hand-made file, not a
|
|
221
|
+
* foreign format.
|
|
222
|
+
*
|
|
223
|
+
* `optionFixes` lists options the file carried but the loader had to correct
|
|
224
|
+
* (a non-boolean `enabled`, an out-of-range or non-numeric `budgetChars`).
|
|
225
|
+
* Each is a human-readable "field 原值 → 修正值 (原因)" string, surfaced the
|
|
226
|
+
* same way as `dropped`: the next save persists the corrected values, so the
|
|
227
|
+
* user must be told their hand edit did not survive verbatim.
|
|
196
228
|
*/
|
|
197
229
|
function normalizeData(raw) {
|
|
198
|
-
const data = { version: 1, enabled: true, budgetChars: DEFAULT_BUDGET, entries: [] }
|
|
199
|
-
if (raw === null || typeof raw !== 'object') return { data, dropped: 0 }
|
|
200
|
-
if (typeof raw.
|
|
201
|
-
|
|
230
|
+
const data = { version: 1, rev: 0, enabled: true, budgetChars: DEFAULT_BUDGET, entries: [] }
|
|
231
|
+
if (raw === null || typeof raw !== 'object') return { data, dropped: 0, optionFixes: [] }
|
|
232
|
+
if (typeof raw.version === 'number' && raw.version !== 1) {
|
|
233
|
+
return { data, dropped: 0, optionFixes: [], rejectedVersion: raw.version }
|
|
234
|
+
}
|
|
235
|
+
if (typeof raw.rev === 'number' && Number.isFinite(raw.rev) && raw.rev >= 0) {
|
|
236
|
+
data.rev = Math.round(raw.rev)
|
|
237
|
+
}
|
|
238
|
+
// Hand-edited options the loader has to correct are disclosed, not silently
|
|
239
|
+
// adopted: the next save would otherwise rewrite the file with the fixed
|
|
240
|
+
// values and the user would never learn their edit had been ignored.
|
|
241
|
+
const optionFixes = []
|
|
242
|
+
if (typeof raw.enabled === 'boolean') {
|
|
243
|
+
data.enabled = raw.enabled
|
|
244
|
+
} else if ('enabled' in raw) {
|
|
245
|
+
optionFixes.push('enabled ' + describeValue(raw.enabled) + ' → true(需要 true/false)')
|
|
246
|
+
}
|
|
247
|
+
if ('budgetChars' in raw) {
|
|
248
|
+
const clamped = clampBudget(raw.budgetChars)
|
|
249
|
+
if (clamped !== raw.budgetChars) {
|
|
250
|
+
optionFixes.push(typeof raw.budgetChars === 'number' && Number.isFinite(raw.budgetChars)
|
|
251
|
+
? 'budgetChars ' + raw.budgetChars + ' → ' + clamped + '(有效范围 ' + MIN_BUDGET + '–' + MAX_BUDGET + ')'
|
|
252
|
+
: 'budgetChars ' + describeValue(raw.budgetChars) + ' → ' + clamped + '(需要 ' + MIN_BUDGET + '–' + MAX_BUDGET + ' 的数字)')
|
|
253
|
+
}
|
|
254
|
+
data.budgetChars = clamped
|
|
255
|
+
}
|
|
202
256
|
const list = Array.isArray(raw.entries) ? raw.entries : []
|
|
203
257
|
const seen = new Set()
|
|
204
258
|
let dropped = 0
|
|
@@ -216,7 +270,7 @@ function normalizeData(raw) {
|
|
|
216
270
|
seen.add(entry.id)
|
|
217
271
|
data.entries.push(entry)
|
|
218
272
|
}
|
|
219
|
-
return { data, dropped }
|
|
273
|
+
return { data, dropped, optionFixes }
|
|
220
274
|
}
|
|
221
275
|
|
|
222
276
|
/**
|
|
@@ -231,6 +285,7 @@ function normalizeData(raw) {
|
|
|
231
285
|
function cloneData(data) {
|
|
232
286
|
return {
|
|
233
287
|
version: 1,
|
|
288
|
+
rev: data.rev || 0,
|
|
234
289
|
enabled: data.enabled === true,
|
|
235
290
|
budgetChars: data.budgetChars,
|
|
236
291
|
entries: (Array.isArray(data.entries) ? data.entries : []).map((entry) => ({ ...entry })),
|
|
@@ -242,6 +297,22 @@ const droppedEntriesMessage = (count) =>
|
|
|
242
297
|
? '存储文件中有 ' + count + ' 条条目未能载入(内容为空或超过 ' + MAX_ENTRIES + ' 条上限);下次保存时它们将从文件中移除'
|
|
243
298
|
: null
|
|
244
299
|
|
|
300
|
+
/**
|
|
301
|
+
* The load-time disclosures for one normalizeData result: dropped entries and
|
|
302
|
+
* corrected hand-edited options, joined with a full-width semicolon.
|
|
303
|
+
* Returns null when neither applies, so callers can assign it directly to
|
|
304
|
+
* `state.storageWarning` (a null means "no warning", not an empty string).
|
|
305
|
+
*/
|
|
306
|
+
const loadWarnings = (loaded) => {
|
|
307
|
+
const parts = []
|
|
308
|
+
const dropped = droppedEntriesMessage(loaded.dropped)
|
|
309
|
+
if (dropped !== null) parts.push(dropped)
|
|
310
|
+
if (Array.isArray(loaded.optionFixes) && loaded.optionFixes.length > 0) {
|
|
311
|
+
parts.push('存储文件中的以下选项值无效,已按规则修正:' + loaded.optionFixes.join(';') + ';下次保存时修正值将写入文件')
|
|
312
|
+
}
|
|
313
|
+
return parts.length > 0 ? parts.join(';') : null
|
|
314
|
+
}
|
|
315
|
+
|
|
245
316
|
/**
|
|
246
317
|
* Hostnames a loopback-bound server may legitimately be reached by.
|
|
247
318
|
*
|
|
@@ -269,8 +340,9 @@ export function hostnameOf(value) {
|
|
|
269
340
|
}
|
|
270
341
|
|
|
271
342
|
/**
|
|
272
|
-
* Why the settings-page route checks `Host` and `
|
|
273
|
-
* refuses any content-type that is not
|
|
343
|
+
* Why the settings-page route checks `Host`, `Origin` and `Sec-Fetch-Site` at
|
|
344
|
+
* all, when it already refuses any content-type that is not
|
|
345
|
+
* `application/json`.
|
|
274
346
|
*
|
|
275
347
|
* The content-type rule stops a cross-site form post, and no CORS headers are
|
|
276
348
|
* sent so a cross-origin `fetch` with a JSON body dies in preflight. Both
|
|
@@ -283,10 +355,21 @@ export function hostnameOf(value) {
|
|
|
283
355
|
* change is the `Host` header the browser sends: it still carries `evil.com`.
|
|
284
356
|
*
|
|
285
357
|
* So when the server is bound to loopback (the default) the route requires a
|
|
286
|
-
* loopback `Host`. An operator who binds `0.0.0.0
|
|
287
|
-
*
|
|
288
|
-
* advance — the check is disabled
|
|
289
|
-
* client, and that trade-off is
|
|
358
|
+
* loopback `Host`. An operator who binds a non-loopback address (`0.0.0.0`,
|
|
359
|
+
* `::`, or a concrete one) has deliberately published the server beyond this
|
|
360
|
+
* machine, where no Host value can be known in advance — the check is disabled
|
|
361
|
+
* there rather than breaking every legitimate client, and that trade-off is
|
|
362
|
+
* documented in the README.
|
|
363
|
+
*
|
|
364
|
+
* `Sec-Fetch-Site` adds a second browser-only signal (absent from curl, Node,
|
|
365
|
+
* and pre-2020 browsers, which is exactly why it can only ADD rejections, never
|
|
366
|
+
* replace the checks above): a modern browser stamps every request with it, so
|
|
367
|
+
* a page at evil.com talking to this route — even through tricks that drop the
|
|
368
|
+
* `Origin` header — still says `cross-site`. Only `cross-site` is refused;
|
|
369
|
+
* `same-site` must pass because schemeful same-site ignores ports (the GUI and
|
|
370
|
+
* the route live on the same host but different ports), and local non-browser
|
|
371
|
+
* clients never send the header at all, so they are governed by the Host and
|
|
372
|
+
* content-type checks alone.
|
|
290
373
|
*/
|
|
291
374
|
export function requestOriginVerdict(headers, guarded) {
|
|
292
375
|
const read = (name) => {
|
|
@@ -301,6 +384,11 @@ export function requestOriginVerdict(headers, guarded) {
|
|
|
301
384
|
return { ok: false, reason: 'Host 头不是回环地址(可能是 DNS 重绑定攻击):' + (requestHost === '' ? '(缺失)' : requestHost) }
|
|
302
385
|
}
|
|
303
386
|
|
|
387
|
+
const fetchSite = read('sec-fetch-site').trim().toLowerCase()
|
|
388
|
+
if (fetchSite === 'cross-site') {
|
|
389
|
+
return { ok: false, reason: '请求被浏览器标记为跨站(Sec-Fetch-Site: cross-site)' }
|
|
390
|
+
}
|
|
391
|
+
|
|
304
392
|
const origin = read('origin').trim()
|
|
305
393
|
if (origin !== '') {
|
|
306
394
|
let originHostname = ''
|
|
@@ -435,6 +523,13 @@ export function apply(ctx) {
|
|
|
435
523
|
loadedFrom: null,
|
|
436
524
|
savedAt: null,
|
|
437
525
|
migratedFrom: null,
|
|
526
|
+
/**
|
|
527
|
+
* Set while the store on disk declares an unsupported format version.
|
|
528
|
+
* Mutating commands are refused in that state: a save would rewrite a
|
|
529
|
+
* future-format file into v1, destroying whatever the newer format meant.
|
|
530
|
+
*/
|
|
531
|
+
versionRejected: false,
|
|
532
|
+
rejectedVersion: null,
|
|
438
533
|
sectionRegistered: false,
|
|
439
534
|
sectionError: null,
|
|
440
535
|
/** Whether a store file is actually present on disk (diagnostics + the panel). */
|
|
@@ -590,14 +685,164 @@ export function apply(ctx) {
|
|
|
590
685
|
console.error('[instruction-memory] cleanup failed:', err)
|
|
591
686
|
}
|
|
592
687
|
}
|
|
688
|
+
stopWatching()
|
|
689
|
+
disposeStreams()
|
|
593
690
|
})
|
|
594
691
|
|
|
692
|
+
/* ---------------- external-change watching (SSE) ---------------- */
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Live subscribers to the settings page's event stream. Each entry is the
|
|
696
|
+
* `send(payload)` writer for one open response.
|
|
697
|
+
*/
|
|
698
|
+
const sseClients = new Set()
|
|
699
|
+
/** Per-connection teardown, so unmounting the plugin can end every stream. */
|
|
700
|
+
const sseCleanups = new Set()
|
|
701
|
+
let storeWatcher = null
|
|
702
|
+
let watchDebounce = null
|
|
703
|
+
|
|
704
|
+
const pushSnapshot = () => {
|
|
705
|
+
if (sseClients.size === 0) return
|
|
706
|
+
const payload = 'data: ' + JSON.stringify(snapshot()) + '\n\n'
|
|
707
|
+
for (const send of Array.from(sseClients)) {
|
|
708
|
+
try {
|
|
709
|
+
send(payload)
|
|
710
|
+
} catch {
|
|
711
|
+
sseClients.delete(send)
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* A store file changed on disk. Debounced (one atomic save fires rename +
|
|
718
|
+
* copy events), then serialised through the same diskQueue as every other
|
|
719
|
+
* mutation so a watcher re-read can never interleave with a save.
|
|
720
|
+
*
|
|
721
|
+
* The file is read WITHOUT adopting it first, because this plugin's own
|
|
722
|
+
* writes fire the watcher too: if the sanitised content is identical to the
|
|
723
|
+
* in-memory state, the event was ours and there is nothing to tell anyone.
|
|
724
|
+
* A failed read (the file is momentarily absent mid-replace, or corrupt) is
|
|
725
|
+
* skipped rather than recovered-from — recovery belongs to reload, which a
|
|
726
|
+
* human asked for.
|
|
727
|
+
*/
|
|
728
|
+
const onStoreFileEvent = () => {
|
|
729
|
+
if (watchDebounce !== null) clearTimeout(watchDebounce)
|
|
730
|
+
watchDebounce = setTimeout(() => {
|
|
731
|
+
watchDebounce = null
|
|
732
|
+
void enqueue(async () => {
|
|
733
|
+
try {
|
|
734
|
+
const parsed = await readStoreFile(storePath)
|
|
735
|
+
if (parsed === null) return
|
|
736
|
+
const loaded = normalizeData(parsed)
|
|
737
|
+
if (loaded.rejectedVersion === undefined
|
|
738
|
+
&& JSON.stringify(loaded.data) === JSON.stringify(state.data)) {
|
|
739
|
+
return
|
|
740
|
+
}
|
|
741
|
+
await readFromDisk()
|
|
742
|
+
syncSection()
|
|
743
|
+
pushSnapshot()
|
|
744
|
+
} catch {
|
|
745
|
+
// Transient: the next event, or the panel's 重新载入, catches up.
|
|
746
|
+
}
|
|
747
|
+
})
|
|
748
|
+
}, WATCH_DEBOUNCE_MS)
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const stopWatching = () => {
|
|
752
|
+
if (watchDebounce !== null) {
|
|
753
|
+
clearTimeout(watchDebounce)
|
|
754
|
+
watchDebounce = null
|
|
755
|
+
}
|
|
756
|
+
if (storeWatcher !== null) {
|
|
757
|
+
const watcher = storeWatcher
|
|
758
|
+
storeWatcher = null
|
|
759
|
+
try {
|
|
760
|
+
watcher.close()
|
|
761
|
+
} catch {
|
|
762
|
+
// Already closed by the OS side.
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* Watch the store DIRECTORY, not the file: this plugin's saves replace
|
|
769
|
+
* memory.json through an atomic rename, which on Windows can orphan a
|
|
770
|
+
* file-level watch handle (the watched inode is gone). Directory events
|
|
771
|
+
* survive replaces. A missing directory (first run, nothing materialised
|
|
772
|
+
* yet) throws and is retried by the first successful write.
|
|
773
|
+
*/
|
|
774
|
+
const startWatching = () => {
|
|
775
|
+
if (storeWatcher !== null) return
|
|
776
|
+
try {
|
|
777
|
+
storeWatcher = watch(dirname(storePath), { persistent: false }, (event, filename) => {
|
|
778
|
+
// Only events naming memory.json matter; the .tmp/.bak/.lock churn of
|
|
779
|
+
// our own writes is filtered out here (the content compare in
|
|
780
|
+
// onStoreFileEvent catches what slips through).
|
|
781
|
+
if (typeof filename === 'string' && filename !== basename(storePath)) return
|
|
782
|
+
onStoreFileEvent()
|
|
783
|
+
})
|
|
784
|
+
storeWatcher.on('error', () => stopWatching())
|
|
785
|
+
} catch {
|
|
786
|
+
storeWatcher = null
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const disposeStreams = () => {
|
|
791
|
+
for (const cleanup of Array.from(sseCleanups)) {
|
|
792
|
+
try {
|
|
793
|
+
cleanup()
|
|
794
|
+
} catch {
|
|
795
|
+
// The response is already gone.
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
sseCleanups.clear()
|
|
799
|
+
sseClients.clear()
|
|
800
|
+
}
|
|
801
|
+
|
|
595
802
|
/* ---------------- storage ---------------- */
|
|
596
803
|
|
|
597
804
|
const storePath = resolveStorePath()
|
|
598
805
|
|
|
599
806
|
const serialize = () => JSON.stringify(state.data, null, 2) + '\n'
|
|
600
807
|
|
|
808
|
+
/**
|
|
809
|
+
* Cross-process write lock: a `memory.json.lock` marker created exclusively
|
|
810
|
+
* (`wx`), held for the duration of one atomic replace, then removed.
|
|
811
|
+
*
|
|
812
|
+
* The in-process diskQueue serialises this plugin's own writes, but two DSH
|
|
813
|
+
* processes sharing a `$DSH_HOME` (e.g. `web` and `tui` profiles running at
|
|
814
|
+
* once) can interleave a copy-backup/atomic-rename sequence. The rename
|
|
815
|
+
* itself keeps the file uncorrupted; what the lock narrows is the
|
|
816
|
+
* last-write-wins window in which one process's edit silently erases the
|
|
817
|
+
* other's.
|
|
818
|
+
*
|
|
819
|
+
* Availability beats exclusion for a user-facing store: a lock that cannot
|
|
820
|
+
* be acquired within a short wait is NEVER allowed to fail the save — the
|
|
821
|
+
* write proceeds anyway (disclosed in the debug log). A lock older than
|
|
822
|
+
* LOCK_STALE_MS is treated as crashed-holder debris and stolen, so a killed
|
|
823
|
+
* process cannot wedge every later save behind a stale marker.
|
|
824
|
+
*/
|
|
825
|
+
const acquireStoreLock = async () => {
|
|
826
|
+
const lockPath = storePath + '.lock'
|
|
827
|
+
const deadline = Date.now() + LOCK_WAIT_MS
|
|
828
|
+
for (;;) {
|
|
829
|
+
try {
|
|
830
|
+
const handle = await open(lockPath, 'wx')
|
|
831
|
+
await handle.close()
|
|
832
|
+
return lockPath
|
|
833
|
+
} catch (err) {
|
|
834
|
+
if (err.code !== 'EEXIST') throw err
|
|
835
|
+
const info = await stat(lockPath).catch(() => null)
|
|
836
|
+
if (info !== null && Date.now() - info.mtimeMs > LOCK_STALE_MS) {
|
|
837
|
+
await unlink(lockPath).catch(() => {})
|
|
838
|
+
continue
|
|
839
|
+
}
|
|
840
|
+
if (Date.now() >= deadline) return null
|
|
841
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
601
846
|
const writeToDisk = async () => {
|
|
602
847
|
// Atomic replace (write a sibling temp file, then rename) with a rolling
|
|
603
848
|
// one-version backup: a crash mid-write must never leave a truncated
|
|
@@ -614,8 +859,18 @@ export function apply(ctx) {
|
|
|
614
859
|
// any step leaves the previous store readable at its own path.
|
|
615
860
|
const tmpPath = storePath + '.tmp'
|
|
616
861
|
const bakPath = storePath + '.bak'
|
|
862
|
+
let lockPath = null
|
|
863
|
+
let previousRev = null
|
|
617
864
|
try {
|
|
618
865
|
await mkdir(dirname(storePath), { recursive: true })
|
|
866
|
+
lockPath = await acquireStoreLock()
|
|
867
|
+
if (lockPath === null) {
|
|
868
|
+
logDebug('store lock still held by another process; writing anyway (last write wins)')
|
|
869
|
+
}
|
|
870
|
+
// The rev is bumped before serialising so the file on disk and the state
|
|
871
|
+
// in memory agree on it; a failed write restores the old value.
|
|
872
|
+
previousRev = state.data.rev || 0
|
|
873
|
+
state.data.rev = previousRev + 1
|
|
619
874
|
const text = serialize()
|
|
620
875
|
await writeFile(tmpPath, text, 'utf8')
|
|
621
876
|
try {
|
|
@@ -644,14 +899,20 @@ export function apply(ctx) {
|
|
|
644
899
|
// A successful write is the one thing that makes the file real, so it is
|
|
645
900
|
// also what clears the "not materialised yet" flag set by a first run.
|
|
646
901
|
needsMaterialize = false
|
|
902
|
+
// ...and what guarantees the store directory exists, so the watcher can
|
|
903
|
+
// be (re)armed after a first run that had nothing to watch.
|
|
904
|
+
startWatching()
|
|
647
905
|
state.savedAt = Date.now()
|
|
648
906
|
logDebug('store written: ' + storePath + ' bytes=' + text.length
|
|
649
|
-
+ ' entries=' + state.data.entries.length
|
|
907
|
+
+ ' entries=' + state.data.entries.length + ' rev=' + state.data.rev
|
|
650
908
|
+ (state.backupError === null ? ' backup=refreshed' : ' backup=FAILED'))
|
|
651
909
|
return true
|
|
652
910
|
} catch (err) {
|
|
911
|
+
if (previousRev !== null) state.data.rev = previousRev
|
|
653
912
|
state.storageError = '保存失败:' + describeError(err)
|
|
654
913
|
return false
|
|
914
|
+
} finally {
|
|
915
|
+
if (lockPath !== null) await unlink(lockPath).catch(() => {})
|
|
655
916
|
}
|
|
656
917
|
}
|
|
657
918
|
|
|
@@ -660,25 +921,57 @@ export function apply(ctx) {
|
|
|
660
921
|
return text.trim() === '' ? null : JSON.parse(text)
|
|
661
922
|
}
|
|
662
923
|
|
|
924
|
+
/**
|
|
925
|
+
* Load the store from disk. Most branches only read; two of them also write —
|
|
926
|
+
* the missing-primary recovery promotes the backup to memory.json, and the
|
|
927
|
+
* legacy-location migration writes the migrated store. The return value says
|
|
928
|
+
* which happened, because `reload` reports it as its `saved` field: the
|
|
929
|
+
* response envelope's `saved` means "this operation persisted the store",
|
|
930
|
+
* and a read-only reload claiming true would be indistinguishable from a
|
|
931
|
+
* real save.
|
|
932
|
+
*
|
|
933
|
+
* @returns whether this load performed a successful write.
|
|
934
|
+
*/
|
|
663
935
|
const readFromDisk = async () => {
|
|
664
936
|
state.storagePath = storePath
|
|
665
937
|
state.storageDisplay = storePath
|
|
666
938
|
state.storageWarning = null
|
|
667
939
|
state.loadedFrom = null
|
|
668
940
|
state.storeExists = false
|
|
941
|
+
state.versionRejected = false
|
|
942
|
+
state.rejectedVersion = null
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* A store declaring an unsupported format version: adopt nothing, write
|
|
946
|
+
* nothing, and say why. The one thing that must NEVER happen in this
|
|
947
|
+
* state is a save — it would rewrite the file as v1 and destroy whatever
|
|
948
|
+
* the newer format meant (the mutating commands refuse while
|
|
949
|
+
* versionRejected is set).
|
|
950
|
+
*/
|
|
951
|
+
const rejectVersion = (version, source) => {
|
|
952
|
+
state.versionRejected = true
|
|
953
|
+
state.rejectedVersion = version
|
|
954
|
+
state.storageError = '存储文件声明为格式版本 ' + version + ',本插件只支持版本 1;已拒绝载入以防数据损坏,原文件已保留,修改操作已暂停。请升级插件,或手动将文件改回版本 1 后点「重新载入」'
|
|
955
|
+
state.loadedFrom = source + ' (unsupported version ' + version + ')'
|
|
956
|
+
state.storeExists = true
|
|
957
|
+
}
|
|
669
958
|
|
|
670
959
|
// 1. The canonical location under the harness home.
|
|
671
960
|
try {
|
|
672
961
|
const parsed = await readStoreFile(storePath)
|
|
673
962
|
if (parsed !== null) {
|
|
674
963
|
const loaded = normalizeData(parsed)
|
|
964
|
+
if (loaded.rejectedVersion !== undefined) {
|
|
965
|
+
rejectVersion(loaded.rejectedVersion, 'memory.json')
|
|
966
|
+
return false
|
|
967
|
+
}
|
|
675
968
|
state.data = loaded.data
|
|
676
|
-
state.storageWarning =
|
|
969
|
+
state.storageWarning = loadWarnings(loaded)
|
|
677
970
|
state.loadedFrom = 'memory.json'
|
|
678
971
|
state.storeExists = true
|
|
679
972
|
}
|
|
680
973
|
state.storageError = null
|
|
681
|
-
return
|
|
974
|
+
return false
|
|
682
975
|
} catch (err) {
|
|
683
976
|
if (err === null || err === undefined || err.code !== 'ENOENT') {
|
|
684
977
|
// The main file is unreadable or corrupt. A previous good version
|
|
@@ -687,16 +980,27 @@ export function apply(ctx) {
|
|
|
687
980
|
// itself stays untouched for hand repair; it gets overwritten on the
|
|
688
981
|
// next save (the pre-existing and disclosed behaviour).
|
|
689
982
|
if (err instanceof SyntaxError) {
|
|
983
|
+
// The file was READ and only failed to PARSE, so a store file is
|
|
984
|
+
// physically present on disk. Saying onDisk=no here contradicted
|
|
985
|
+
// the branch's own story ("损坏文件已保留") and produced a mounted
|
|
986
|
+
// log line reading "from=memory.json.bak (main file corrupt)
|
|
987
|
+
// onDisk=no". (Other non-ENOENT errors stay no: ENOTDIR, say, means
|
|
988
|
+
// nothing exists at the store path at all.)
|
|
989
|
+
state.storeExists = true
|
|
690
990
|
try {
|
|
691
991
|
const backup = await readStoreFile(storePath + '.bak')
|
|
692
992
|
if (backup !== null) {
|
|
693
993
|
const loaded = normalizeData(backup)
|
|
994
|
+
if (loaded.rejectedVersion !== undefined) {
|
|
995
|
+
rejectVersion(loaded.rejectedVersion, 'memory.json.bak')
|
|
996
|
+
return false
|
|
997
|
+
}
|
|
694
998
|
state.data = loaded.data
|
|
695
|
-
|
|
696
|
-
|
|
999
|
+
const warnings = loadWarnings(loaded)
|
|
1000
|
+
state.storageWarning = (warnings !== null ? warnings + ';' : '')
|
|
697
1001
|
+ 'memory.json 解析失败(' + describeError(err) + '),已临时回退到最近备份 memory.json.bak;损坏文件已保留,下次保存时会覆盖它'
|
|
698
1002
|
state.loadedFrom = 'memory.json.bak (main file corrupt)'
|
|
699
|
-
return
|
|
1003
|
+
return false
|
|
700
1004
|
}
|
|
701
1005
|
} catch {
|
|
702
1006
|
// No usable backup either; fall through to the error below.
|
|
@@ -706,7 +1010,7 @@ export function apply(ctx) {
|
|
|
706
1010
|
state.storageError = err instanceof SyntaxError
|
|
707
1011
|
? '配置文件解析失败(' + describeError(err) + '),原文件已保留;你下次保存时会覆盖它'
|
|
708
1012
|
: '读取失败:' + describeError(err)
|
|
709
|
-
return
|
|
1013
|
+
return false
|
|
710
1014
|
}
|
|
711
1015
|
}
|
|
712
1016
|
|
|
@@ -722,14 +1026,18 @@ export function apply(ctx) {
|
|
|
722
1026
|
const backup = await readStoreFile(storePath + '.bak')
|
|
723
1027
|
if (backup !== null) {
|
|
724
1028
|
const loaded = normalizeData(backup)
|
|
1029
|
+
if (loaded.rejectedVersion !== undefined) {
|
|
1030
|
+
rejectVersion(loaded.rejectedVersion, 'memory.json.bak')
|
|
1031
|
+
return false
|
|
1032
|
+
}
|
|
725
1033
|
state.data = loaded.data
|
|
726
1034
|
const promoted = await writeToDisk()
|
|
727
|
-
|
|
728
|
-
|
|
1035
|
+
const warnings = loadWarnings(loaded)
|
|
1036
|
+
state.storageWarning = (warnings !== null ? warnings + ';' : '')
|
|
729
1037
|
+ 'memory.json 不存在,已从最近备份 memory.json.bak 恢复'
|
|
730
1038
|
+ (promoted ? ',并已写回 memory.json' : ';写回 memory.json 失败,下次保存时会重试')
|
|
731
1039
|
state.loadedFrom = 'memory.json.bak (main file missing)'
|
|
732
|
-
return
|
|
1040
|
+
return promoted
|
|
733
1041
|
}
|
|
734
1042
|
} catch {
|
|
735
1043
|
// An unusable backup must never block a fresh store; fall through.
|
|
@@ -742,15 +1050,21 @@ export function apply(ctx) {
|
|
|
742
1050
|
const parsed = await readStoreFile(legacyPath)
|
|
743
1051
|
if (parsed !== null) {
|
|
744
1052
|
const loaded = normalizeData(parsed)
|
|
1053
|
+
if (loaded.rejectedVersion !== undefined) {
|
|
1054
|
+
// A legacy file in a future format is not ours to migrate or judge.
|
|
1055
|
+
state.storageError = '旧位置文件为不支持的格式版本 ' + loaded.rejectedVersion + ',已忽略'
|
|
1056
|
+
return false
|
|
1057
|
+
}
|
|
745
1058
|
state.data = loaded.data
|
|
746
|
-
state.storageWarning =
|
|
747
|
-
|
|
1059
|
+
state.storageWarning = loadWarnings(loaded)
|
|
1060
|
+
const migrated = await writeToDisk()
|
|
1061
|
+
if (migrated) {
|
|
748
1062
|
state.migratedFrom = legacyPath
|
|
749
1063
|
// Keep the original as evidence instead of deleting user data.
|
|
750
1064
|
await rename(legacyPath, legacyPath + '.migrated').catch(() => {})
|
|
751
1065
|
}
|
|
752
1066
|
state.loadedFrom = 'legacy cwd file (migrated)'
|
|
753
|
-
return
|
|
1067
|
+
return migrated
|
|
754
1068
|
}
|
|
755
1069
|
} catch (err) {
|
|
756
1070
|
// A missing or unreadable legacy file must never block a fresh store.
|
|
@@ -770,6 +1084,7 @@ export function apply(ctx) {
|
|
|
770
1084
|
needsMaterialize = true
|
|
771
1085
|
state.loadedFrom = 'first run (no store on disk yet)'
|
|
772
1086
|
state.storeExists = false
|
|
1087
|
+
return false
|
|
773
1088
|
}
|
|
774
1089
|
|
|
775
1090
|
/**
|
|
@@ -809,6 +1124,9 @@ export function apply(ctx) {
|
|
|
809
1124
|
+ ' section=' + (chars > 0 ? chars + ' chars (pending assembly)' : 'nothing to inject')
|
|
810
1125
|
+ ' pulls=' + sectionPulls
|
|
811
1126
|
+ (state.storageError !== null ? ' error=' + state.storageError : ''))
|
|
1127
|
+
// Arm the external-change watcher once the store is known to exist. A
|
|
1128
|
+
// first run has nothing to watch yet; the first successful write rearms.
|
|
1129
|
+
if (state.storeExists) startWatching()
|
|
812
1130
|
})
|
|
813
1131
|
|
|
814
1132
|
/**
|
|
@@ -833,6 +1151,7 @@ export function apply(ctx) {
|
|
|
833
1151
|
return {
|
|
834
1152
|
data: {
|
|
835
1153
|
version: 1,
|
|
1154
|
+
rev: state.data.rev || 0,
|
|
836
1155
|
enabled: state.data.enabled === true,
|
|
837
1156
|
budgetChars: clampBudget(state.data.budgetChars),
|
|
838
1157
|
entries: (Array.isArray(state.data.entries) ? state.data.entries : []).map((entry) => ({ ...entry })),
|
|
@@ -859,7 +1178,13 @@ export function apply(ctx) {
|
|
|
859
1178
|
pulls: sectionPulls,
|
|
860
1179
|
},
|
|
861
1180
|
preview,
|
|
862
|
-
|
|
1181
|
+
/**
|
|
1182
|
+
* `maxBodyChars` is the route's request-body ceiling, published so the
|
|
1183
|
+
* Client can refuse an obviously oversized import file BEFORE reading it
|
|
1184
|
+
* into memory. It is a character budget against byte sizes, so the
|
|
1185
|
+
* Client applies a generous multiplier — see importSizeVerdict there.
|
|
1186
|
+
*/
|
|
1187
|
+
limits: { minBudget: MIN_BUDGET, maxBudget: MAX_BUDGET, maxEntries: MAX_ENTRIES, maxBodyChars: MAX_BODY_CHARS },
|
|
863
1188
|
}
|
|
864
1189
|
}
|
|
865
1190
|
|
|
@@ -872,6 +1197,41 @@ export function apply(ctx) {
|
|
|
872
1197
|
|
|
873
1198
|
/* ---------------- commands (shared by route and callers) ---------------- */
|
|
874
1199
|
|
|
1200
|
+
/**
|
|
1201
|
+
* Optimistic concurrency: a mutating command may carry `baseRev` — the
|
|
1202
|
+
* `data.rev` its caller last saw. A mismatch means another window or process
|
|
1203
|
+
* committed in between, and applying this command now would build on a stale
|
|
1204
|
+
* view (e.g. resurrect an entry another tab just deleted). The refusal hands
|
|
1205
|
+
* back the FRESH snapshot so the panel resyncs immediately; the user retries
|
|
1206
|
+
* and the retry carries the new rev.
|
|
1207
|
+
*
|
|
1208
|
+
* Absent `baseRev` (older clients) disables the check entirely — legacy
|
|
1209
|
+
* behaviour, not an error.
|
|
1210
|
+
*/
|
|
1211
|
+
const isStaleBaseRev = (baseRev) =>
|
|
1212
|
+
typeof baseRev === 'number' && Number.isFinite(baseRev) && baseRev !== (state.data.rev || 0)
|
|
1213
|
+
|
|
1214
|
+
const conflictResponse = () => ({
|
|
1215
|
+
ok: false,
|
|
1216
|
+
saved: false,
|
|
1217
|
+
conflict: true,
|
|
1218
|
+
message: '指令记忆刚被其他窗口或进程修改,已为你载入最新内容;请检查后重试本次操作',
|
|
1219
|
+
snapshot: snapshot(),
|
|
1220
|
+
})
|
|
1221
|
+
|
|
1222
|
+
/**
|
|
1223
|
+
* Refusal while the store on disk declares an unsupported format version:
|
|
1224
|
+
* a save here would rewrite the file as v1 and destroy whatever the newer
|
|
1225
|
+
* format meant. The file stays untouched until the user upgrades the plugin
|
|
1226
|
+
* or restores a v1 file and hits 重新载入.
|
|
1227
|
+
*/
|
|
1228
|
+
const versionRefusal = () => ({
|
|
1229
|
+
ok: false,
|
|
1230
|
+
saved: false,
|
|
1231
|
+
message: '存储文件为不支持的格式版本 ' + state.rejectedVersion + ',修改已暂停:现在保存会用版本 1 覆盖它,可能损坏数据。请先升级插件,或手动将文件改回版本 1 后点「重新载入」',
|
|
1232
|
+
snapshot: snapshot(),
|
|
1233
|
+
})
|
|
1234
|
+
|
|
875
1235
|
/**
|
|
876
1236
|
* Apply an in-memory mutation, persist it, and UNDO it when the write fails.
|
|
877
1237
|
*
|
|
@@ -896,6 +1256,14 @@ export function apply(ctx) {
|
|
|
896
1256
|
// dropped-entries warning from load time ("they will be removed on the
|
|
897
1257
|
// next save") has played out and must not keep showing.
|
|
898
1258
|
state.storageWarning = null
|
|
1259
|
+
// The write is real, so every OTHER open panel must hear about it. The
|
|
1260
|
+
// file watcher stays silent for this plugin's own writes (disk now
|
|
1261
|
+
// equals memory, see onStoreFileEvent), which is correct for recovery —
|
|
1262
|
+
// but a second window on the same host would otherwise sit on its stale
|
|
1263
|
+
// view until someone clicks 重新载入. The initiating window receives the
|
|
1264
|
+
// same frame and drops it client-side (identical content), so pushing to
|
|
1265
|
+
// everyone is safe and closing the loop needs no per-client bookkeeping.
|
|
1266
|
+
pushSnapshot()
|
|
899
1267
|
return true
|
|
900
1268
|
}
|
|
901
1269
|
logDebug('commit rolled back: the disk write failed, in-memory state restored')
|
|
@@ -905,7 +1273,9 @@ export function apply(ctx) {
|
|
|
905
1273
|
return false
|
|
906
1274
|
}
|
|
907
1275
|
|
|
908
|
-
const saveEntry = (raw) => enqueue(async () => {
|
|
1276
|
+
const saveEntry = (raw, baseRev) => enqueue(async () => {
|
|
1277
|
+
if (state.versionRejected) return versionRefusal()
|
|
1278
|
+
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
909
1279
|
const entry = sanitizeEntry(raw)
|
|
910
1280
|
if (entry === null) return { ok: false, saved: false, message: '标题与指令内容不能同时为空', snapshot: snapshot() }
|
|
911
1281
|
entry.updatedAt = Date.now()
|
|
@@ -928,14 +1298,18 @@ export function apply(ctx) {
|
|
|
928
1298
|
return settle(note, saved)
|
|
929
1299
|
})
|
|
930
1300
|
|
|
931
|
-
const deleteEntry = (id) => enqueue(async () => {
|
|
1301
|
+
const deleteEntry = (id, baseRev) => enqueue(async () => {
|
|
1302
|
+
if (state.versionRejected) return versionRefusal()
|
|
1303
|
+
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
932
1304
|
const saved = await commit(() => {
|
|
933
1305
|
state.data.entries = state.data.entries.filter((entry) => entry.id !== id)
|
|
934
1306
|
})
|
|
935
1307
|
return settle(null, saved)
|
|
936
1308
|
})
|
|
937
1309
|
|
|
938
|
-
const setOptions = (patch) => enqueue(async () => {
|
|
1310
|
+
const setOptions = (patch, baseRev) => enqueue(async () => {
|
|
1311
|
+
if (state.versionRejected) return versionRefusal()
|
|
1312
|
+
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
939
1313
|
const saved = await commit(() => {
|
|
940
1314
|
if (patch !== null && typeof patch === 'object') {
|
|
941
1315
|
if (typeof patch.enabled === 'boolean') state.data.enabled = patch.enabled
|
|
@@ -948,14 +1322,19 @@ export function apply(ctx) {
|
|
|
948
1322
|
})
|
|
949
1323
|
|
|
950
1324
|
const reload = () => enqueue(async () => {
|
|
951
|
-
|
|
1325
|
+
// `saved` is only true when the load itself wrote to disk (the
|
|
1326
|
+
// backup-promotion or legacy-migration branch); a plain re-read claiming
|
|
1327
|
+
// true would be indistinguishable from a real save on the wire.
|
|
1328
|
+
const wrote = await readFromDisk()
|
|
952
1329
|
syncSection()
|
|
953
|
-
return { ok: state.storageError === null, saved: true, message: state.storageError, snapshot: snapshot() }
|
|
1330
|
+
return { ok: state.storageError === null, saved: wrote === true, message: state.storageError, snapshot: snapshot() }
|
|
954
1331
|
})
|
|
955
1332
|
|
|
956
1333
|
const exportData = () => enqueue(async () => ({
|
|
957
1334
|
ok: true,
|
|
958
|
-
saved:
|
|
1335
|
+
// An export reads the store and never writes it; a truthful `saved:false`
|
|
1336
|
+
// keeps the field's meaning ("this operation persisted the store") intact.
|
|
1337
|
+
saved: false,
|
|
959
1338
|
message: null,
|
|
960
1339
|
// A self-describing envelope: re-importable by this plugin, and readable
|
|
961
1340
|
// as plain JSON by anything else.
|
|
@@ -963,7 +1342,9 @@ export function apply(ctx) {
|
|
|
963
1342
|
snapshot: snapshot(),
|
|
964
1343
|
}))
|
|
965
1344
|
|
|
966
|
-
const importData = (payload) => enqueue(async () => {
|
|
1345
|
+
const importData = (payload, baseRev) => enqueue(async () => {
|
|
1346
|
+
if (state.versionRejected) return versionRefusal()
|
|
1347
|
+
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
967
1348
|
if (payload === null || typeof payload !== 'object') {
|
|
968
1349
|
return { ok: false, saved: false, message: '导入内容不是有效的 JSON 对象', snapshot: snapshot() }
|
|
969
1350
|
}
|
|
@@ -1037,23 +1418,31 @@ export function apply(ctx) {
|
|
|
1037
1418
|
// A first run writes nothing at boot (readFromDisk step 3), so the
|
|
1038
1419
|
// materialisation happens here, on a deliberate user action, instead of
|
|
1039
1420
|
// as a side effect of the host merely starting up.
|
|
1040
|
-
await ensureStoreMaterialized()
|
|
1421
|
+
const materialized = await ensureStoreMaterialized()
|
|
1041
1422
|
// Same envelope as every other method. One response shape means the
|
|
1042
1423
|
// Client can never again be handed a payload it silently ignores.
|
|
1043
|
-
|
|
1424
|
+
// `saved` is true only on the first run, when materialisation actually
|
|
1425
|
+
// wrote the file; every later read wrote nothing and says so.
|
|
1426
|
+
return { ok: true, saved: materialized === true, message: null, snapshot: snapshot() }
|
|
1044
1427
|
}
|
|
1045
1428
|
case 'save-entry':
|
|
1046
|
-
return saveEntry(
|
|
1429
|
+
return saveEntry(
|
|
1430
|
+
args !== null && typeof args === 'object' ? args.entry : null,
|
|
1431
|
+
args !== null && typeof args === 'object' ? args.baseRev : undefined)
|
|
1047
1432
|
case 'delete-entry':
|
|
1048
|
-
return deleteEntry(
|
|
1433
|
+
return deleteEntry(
|
|
1434
|
+
args !== null && typeof args === 'object' && typeof args.id === 'string' ? args.id : '',
|
|
1435
|
+
args !== null && typeof args === 'object' ? args.baseRev : undefined)
|
|
1049
1436
|
case 'set-options':
|
|
1050
|
-
return setOptions(args)
|
|
1437
|
+
return setOptions(args, args !== null && typeof args === 'object' ? args.baseRev : undefined)
|
|
1051
1438
|
case 'reload':
|
|
1052
1439
|
return reload()
|
|
1053
1440
|
case 'export-data':
|
|
1054
1441
|
return exportData()
|
|
1055
1442
|
case 'import-data':
|
|
1056
|
-
return importData(
|
|
1443
|
+
return importData(
|
|
1444
|
+
args !== null && typeof args === 'object' ? args.payload : null,
|
|
1445
|
+
args !== null && typeof args === 'object' ? args.baseRev : undefined)
|
|
1057
1446
|
default:
|
|
1058
1447
|
return { ok: false, saved: false, message: '未知方法:' + String(method), snapshot: snapshot() }
|
|
1059
1448
|
}
|
|
@@ -1068,7 +1457,73 @@ export function apply(ctx) {
|
|
|
1068
1457
|
let guardHost = true
|
|
1069
1458
|
|
|
1070
1459
|
let routeDispose = null
|
|
1460
|
+
|
|
1461
|
+
/**
|
|
1462
|
+
* The live-update stream for the settings page (GET + `Accept:
|
|
1463
|
+
* text/event-stream`, i.e. exactly what an EventSource sends).
|
|
1464
|
+
*
|
|
1465
|
+
* Why the method/accept pair instead of a sub-path: the route is registered
|
|
1466
|
+
* as a plain prefix and how the harness presents `req.url` to it is not part
|
|
1467
|
+
* of any contract, while `req.method` and the Accept header are. A plain GET
|
|
1468
|
+
* (someone navigating to the URL) keeps the old 405.
|
|
1469
|
+
*
|
|
1470
|
+
* Frames carry the bare snapshot — the same shape `state` answers with —
|
|
1471
|
+
* which the Client's normalizer already accepts.
|
|
1472
|
+
*/
|
|
1473
|
+
const handleEventStream = (req, res) => {
|
|
1474
|
+
const verdict = requestOriginVerdict(req.headers, guardHost)
|
|
1475
|
+
if (!verdict.ok) {
|
|
1476
|
+
logInfo('route rejected a request: ' + verdict.reason)
|
|
1477
|
+
res.writeHead(403, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
1478
|
+
res.end(JSON.stringify({ ok: false, message: '请求来源被拒绝:' + verdict.reason }))
|
|
1479
|
+
return
|
|
1480
|
+
}
|
|
1481
|
+
res.writeHead(200, {
|
|
1482
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
1483
|
+
'cache-control': 'no-store',
|
|
1484
|
+
connection: 'keep-alive',
|
|
1485
|
+
})
|
|
1486
|
+
// Ask the browser to retry politely if the stream drops (e.g. the host was
|
|
1487
|
+
// restarted) instead of hammering the route.
|
|
1488
|
+
res.write('retry: 5000\n\n')
|
|
1489
|
+
const send = (payload) => { res.write(payload) }
|
|
1490
|
+
sseClients.add(send)
|
|
1491
|
+
// A snapshot on connect, so a late subscriber synchronises immediately
|
|
1492
|
+
// instead of waiting for the next change.
|
|
1493
|
+
send('data: ' + JSON.stringify(snapshot()) + '\n\n')
|
|
1494
|
+
const cleanup = () => {
|
|
1495
|
+
sseClients.delete(send)
|
|
1496
|
+
sseCleanups.delete(cleanup)
|
|
1497
|
+
try {
|
|
1498
|
+
res.end()
|
|
1499
|
+
} catch {
|
|
1500
|
+
// The response is already gone.
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
sseCleanups.add(cleanup)
|
|
1504
|
+
req.on('close', cleanup)
|
|
1505
|
+
req.on('error', cleanup)
|
|
1506
|
+
res.on('error', cleanup)
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1071
1509
|
const handler = (req, res) => {
|
|
1510
|
+
if (req.method === 'GET') {
|
|
1511
|
+
const accept = String(
|
|
1512
|
+
(req.headers !== null && typeof req.headers === 'object') ? (req.headers.accept || '') : '',
|
|
1513
|
+
)
|
|
1514
|
+
if (accept.indexOf('text/event-stream') !== -1) {
|
|
1515
|
+
// A call aborted mid-flight makes these streams emit 'error'; a server
|
|
1516
|
+
// stream with no error listener would take the host process down over
|
|
1517
|
+
// one dead HTTP request.
|
|
1518
|
+
req.on('error', () => {})
|
|
1519
|
+
res.on('error', () => {})
|
|
1520
|
+
handleEventStream(req, res)
|
|
1521
|
+
return
|
|
1522
|
+
}
|
|
1523
|
+
res.writeHead(405, { 'content-type': 'application/json; charset=utf-8' })
|
|
1524
|
+
res.end(JSON.stringify({ ok: false, message: 'only POST is supported' }))
|
|
1525
|
+
return
|
|
1526
|
+
}
|
|
1072
1527
|
if (req.method !== 'POST') {
|
|
1073
1528
|
res.writeHead(405, { 'content-type': 'application/json; charset=utf-8' })
|
|
1074
1529
|
res.end(JSON.stringify({ ok: false, message: 'only POST is supported' }))
|
|
@@ -1149,12 +1604,16 @@ export function apply(ctx) {
|
|
|
1149
1604
|
return
|
|
1150
1605
|
}
|
|
1151
1606
|
// The Host allowlist only makes sense when the operator bound the server to
|
|
1152
|
-
// loopback. `0.0.0.0
|
|
1153
|
-
//
|
|
1154
|
-
//
|
|
1155
|
-
//
|
|
1156
|
-
|
|
1157
|
-
|
|
1607
|
+
// loopback. Anything else — `0.0.0.0`/`::` (every interface) or a concrete
|
|
1608
|
+
// address such as `192.168.1.5` — is a deliberate publication beyond this
|
|
1609
|
+
// machine, where no Host value can be known in advance; enforcing a
|
|
1610
|
+
// loopback allowlist there would 403 every legitimate client of that
|
|
1611
|
+
// published deployment. (The previous check compared against `0.0.0.0`
|
|
1612
|
+
// only, which silently broke `::` and concrete-address binds.) The check is
|
|
1613
|
+
// disabled for all non-loopback binds and the trade-off is documented in
|
|
1614
|
+
// the README.
|
|
1615
|
+
const boundHost = typeof webServer.host === 'string' ? webServer.host.trim().toLowerCase() : ''
|
|
1616
|
+
guardHost = boundHost === '' || LOOPBACK_HOSTNAMES.has(boundHost)
|
|
1158
1617
|
try {
|
|
1159
1618
|
routeDispose = webServer.register({ kind: 'prefix', path: API_PREFIX, handler })
|
|
1160
1619
|
logDebug('settings route registered at ' + API_PREFIX + ' (boundHost=' + boundHost + ', hostGuard=' + guardHost + ')')
|