dsh-instruction-memory 1.0.14 → 1.0.16

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/lib/index.js CHANGED
@@ -10,9 +10,12 @@
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, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
14
+ import { watch } from 'node:fs'
15
+ import { randomUUID } from 'node:crypto'
16
+ import { isIP } from 'node:net'
14
17
  import { homedir } from 'node:os'
15
- import { dirname, join } from 'node:path'
18
+ import { basename, dirname, join } from 'node:path'
16
19
 
17
20
  export const name = 'instruction-memory'
18
21
 
@@ -105,6 +108,13 @@ const MAX_WHEN = 200
105
108
  */
106
109
  export const MAX_BODY_CHARS = MAX_ENTRIES * (MAX_CONTENT + MAX_TITLE + MAX_WHEN + 200) * 2 + 100_000
107
110
 
111
+ /** How long a crashed holder's `memory.json.lock` may block writes before it is stolen. */
112
+ const LOCK_STALE_MS = 10_000
113
+ /** How long a write waits for the cross-process lock before proceeding unlocked. */
114
+ const LOCK_WAIT_MS = 2_000
115
+ /** Coalescing window for store-directory watcher events before a re-read. */
116
+ const WATCH_DEBOUNCE_MS = 250
117
+
108
118
  /* ------------------------------------------------------------------ *
109
119
  * Pure helpers (no ctx, fully unit-testable)
110
120
  * ------------------------------------------------------------------ */
@@ -187,18 +197,64 @@ export function truncatedFields(raw) {
187
197
  return cut
188
198
  }
189
199
 
200
+ /** JSON.stringify keeps quotes/type visible; the String() tail is for values JSON cannot encode. */
201
+ const describeValue = (value) => {
202
+ const text = JSON.stringify(value)
203
+ return typeof text === 'string' ? text : String(value)
204
+ }
205
+
190
206
  /**
191
207
  * Load a parsed memory file. Returns the sanitized data plus how many array
192
208
  * items were discarded (blank entries, or valid ones past the cap). The count
193
209
  * is surfaced rather than swallowed because the next save rewrites the file
194
210
  * without those items — silently destroying user data is exactly what this
195
211
  * plugin must never do.
212
+ *
213
+ * `rev` is the store revision, bumped on every committed write and persisted
214
+ * in the file. Clients echo it back as `baseRev` so a write built on a stale
215
+ * snapshot (another window, another process) can be refused instead of
216
+ * silently overwriting it. Missing (pre-rev files) reads as 0.
217
+ *
218
+ * A numeric `version` other than 1 sets `rejectedVersion` and leaves the data
219
+ * untouched: a future format's fields cannot be best-effort parsed as v1, and
220
+ * adopting a misread would let the next save rewrite the file in the wrong
221
+ * shape. A MISSING version still loads as v1 — the field has been written as
222
+ * 1 since the first release, so absence is an old/hand-made file, not a
223
+ * foreign format.
224
+ *
225
+ * `optionFixes` lists options the file carried but the loader had to correct
226
+ * (a non-boolean `enabled`, an out-of-range or non-numeric `budgetChars`).
227
+ * Each is a human-readable "field 原值 → 修正值 (原因)" string, surfaced the
228
+ * same way as `dropped`: the next save persists the corrected values, so the
229
+ * user must be told their hand edit did not survive verbatim.
196
230
  */
197
231
  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.enabled === 'boolean') data.enabled = raw.enabled
201
- data.budgetChars = clampBudget(raw.budgetChars)
232
+ const data = { version: 1, rev: 0, enabled: true, budgetChars: DEFAULT_BUDGET, entries: [] }
233
+ if (raw === null || typeof raw !== 'object') return { data, dropped: 0, optionFixes: [] }
234
+ if (typeof raw.version === 'number' && raw.version !== 1) {
235
+ return { data, dropped: 0, optionFixes: [], rejectedVersion: raw.version }
236
+ }
237
+ if (typeof raw.rev === 'number' && Number.isFinite(raw.rev) && raw.rev >= 0) {
238
+ data.rev = Math.round(raw.rev)
239
+ }
240
+ // Hand-edited options the loader has to correct are disclosed, not silently
241
+ // adopted: the next save would otherwise rewrite the file with the fixed
242
+ // values and the user would never learn their edit had been ignored.
243
+ const optionFixes = []
244
+ if (typeof raw.enabled === 'boolean') {
245
+ data.enabled = raw.enabled
246
+ } else if ('enabled' in raw) {
247
+ optionFixes.push('enabled ' + describeValue(raw.enabled) + ' → true(需要 true/false)')
248
+ }
249
+ if ('budgetChars' in raw) {
250
+ const clamped = clampBudget(raw.budgetChars)
251
+ if (clamped !== raw.budgetChars) {
252
+ optionFixes.push(typeof raw.budgetChars === 'number' && Number.isFinite(raw.budgetChars)
253
+ ? 'budgetChars ' + raw.budgetChars + ' → ' + clamped + '(有效范围 ' + MIN_BUDGET + '–' + MAX_BUDGET + ')'
254
+ : 'budgetChars ' + describeValue(raw.budgetChars) + ' → ' + clamped + '(需要 ' + MIN_BUDGET + '–' + MAX_BUDGET + ' 的数字)')
255
+ }
256
+ data.budgetChars = clamped
257
+ }
202
258
  const list = Array.isArray(raw.entries) ? raw.entries : []
203
259
  const seen = new Set()
204
260
  let dropped = 0
@@ -216,7 +272,7 @@ function normalizeData(raw) {
216
272
  seen.add(entry.id)
217
273
  data.entries.push(entry)
218
274
  }
219
- return { data, dropped }
275
+ return { data, dropped, optionFixes }
220
276
  }
221
277
 
222
278
  /**
@@ -231,6 +287,7 @@ function normalizeData(raw) {
231
287
  function cloneData(data) {
232
288
  return {
233
289
  version: 1,
290
+ rev: data.rev || 0,
234
291
  enabled: data.enabled === true,
235
292
  budgetChars: data.budgetChars,
236
293
  entries: (Array.isArray(data.entries) ? data.entries : []).map((entry) => ({ ...entry })),
@@ -242,6 +299,22 @@ const droppedEntriesMessage = (count) =>
242
299
  ? '存储文件中有 ' + count + ' 条条目未能载入(内容为空或超过 ' + MAX_ENTRIES + ' 条上限);下次保存时它们将从文件中移除'
243
300
  : null
244
301
 
302
+ /**
303
+ * The load-time disclosures for one normalizeData result: dropped entries and
304
+ * corrected hand-edited options, joined with a full-width semicolon.
305
+ * Returns null when neither applies, so callers can assign it directly to
306
+ * `state.storageWarning` (a null means "no warning", not an empty string).
307
+ */
308
+ const loadWarnings = (loaded) => {
309
+ const parts = []
310
+ const dropped = droppedEntriesMessage(loaded.dropped)
311
+ if (dropped !== null) parts.push(dropped)
312
+ if (Array.isArray(loaded.optionFixes) && loaded.optionFixes.length > 0) {
313
+ parts.push('存储文件中的以下选项值无效,已按规则修正:' + loaded.optionFixes.join(';') + ';下次保存时修正值将写入文件')
314
+ }
315
+ return parts.length > 0 ? parts.join(';') : null
316
+ }
317
+
245
318
  /**
246
319
  * Hostnames a loopback-bound server may legitimately be reached by.
247
320
  *
@@ -255,6 +328,13 @@ const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', 'localhost', '::1', '::ffff:127
255
328
  *
256
329
  * `127.0.0.1:8080` -> `127.0.0.1`; `[::1]:8080` -> `::1`; `localhost` -> `localhost`.
257
330
  * Exported so the parsing itself can be pinned by tests.
331
+ *
332
+ * HTTP requires IPv6 literals in Host to wear brackets, so the bracketed
333
+ * form is the one real traffic uses; the bare form still has to parse instead
334
+ * of being chopped at the last colon (the old code turned a bare `::1` into
335
+ * `:`, which never matched the loopback set). A bare value carrying MORE than
336
+ * one colon is an IPv6 literal with no port — with at most one colon it is
337
+ * host:port.
258
338
  */
259
339
  export function hostnameOf(value) {
260
340
  if (typeof value !== 'string') return ''
@@ -262,15 +342,104 @@ export function hostnameOf(value) {
262
342
  if (trimmed === '') return ''
263
343
  if (trimmed.startsWith('[')) {
264
344
  const end = trimmed.indexOf(']')
265
- return end === -1 ? trimmed : trimmed.slice(1, end)
345
+ return end === -1 ? trimmed.slice(1) : trimmed.slice(1, end)
266
346
  }
347
+ // Unbracketed IPv6 literal: the colons are part of the address, not a port.
348
+ if (trimmed.indexOf(':') !== trimmed.lastIndexOf(':')) return trimmed
267
349
  const colon = trimmed.lastIndexOf(':')
268
350
  return colon === -1 ? trimmed : trimmed.slice(0, colon)
269
351
  }
270
352
 
271
353
  /**
272
- * Why the settings-page route checks `Host` and `Origin` at all, when it already
273
- * refuses any content-type that is not `application/json`.
354
+ * Normalise a hostname the same way on BOTH sides of the Origin comparison.
355
+ *
356
+ * `new URL('http://[::1]:8080').hostname` keeps the brackets (`[::1]`), while
357
+ * `hostnameOf('[::1]:8080')` strips them (`::1`) — comparing the two raw forms
358
+ * rejected every same-origin request that reached the GUI over IPv6 loopback.
359
+ * Strip one surrounding pair of brackets here so both sides spell an IPv6
360
+ * literal identically.
361
+ */
362
+ const normalizeHostname = (value) => (
363
+ typeof value === 'string' && value.startsWith('[') && value.endsWith(']') && value.length >= 2
364
+ ? value.slice(1, -1)
365
+ : value
366
+ )
367
+
368
+ /**
369
+ * Expand an IPv6 literal to eight lower-cased zero-padded groups, translating a
370
+ * trailing embedded IPv4 suffix to two hex groups. Returns null for anything
371
+ * that is not a valid literal.
372
+ *
373
+ * Needed because the two sides of the Origin check spell the SAME address
374
+ * differently: a browser sends Host `[::ffff:127.0.0.1]` while the URL parser
375
+ * canonicalises Origin to `[::ffff:7f00:1]` — text comparison says these are
376
+ * different; address comparison must say they are the same.
377
+ */
378
+ export function canonicalIp(value) {
379
+ if (typeof value !== 'string') return null
380
+ let addr = normalizeHostname(value.trim().toLowerCase())
381
+ const percent = addr.indexOf('%')
382
+ if (percent !== -1) addr = addr.slice(0, percent)
383
+ if (isIP(addr) === 4) {
384
+ return 'v4:' + addr.split('.').map((part) => Number(part)).join('.')
385
+ }
386
+ if (isIP(addr) !== 6) return null
387
+
388
+ // Translate a trailing dotted-quad (e.g. ::ffff:127.0.0.1) into two groups.
389
+ const v4 = /(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(addr)
390
+ if (v4 !== null) {
391
+ const octets = v4[1].split('.').map((part) => Number(part))
392
+ if (octets.some((octet) => octet > 255)) return null
393
+ const prefix = addr.slice(0, v4.index)
394
+ const hi = (octets[0] << 8 | octets[1]).toString(16)
395
+ const lo = (octets[2] << 8 | octets[3]).toString(16)
396
+ // Keep the trailing ':' of the prefix as the separator before the two
397
+ // translated groups; it may also be the second ':' of a '::' compression,
398
+ // which must survive (so do not strip a leading colon afterwards).
399
+ addr = (prefix.endsWith(':') ? prefix : prefix + ':') + hi + ':' + lo
400
+ }
401
+
402
+ const halves = addr.split('::')
403
+ if (halves.length > 2) return null
404
+ if (halves.length === 1) {
405
+ const groups = halves[0].split(':')
406
+ if (groups.length !== 8 || groups.some((group) => !/^[0-9a-f]{1,4}$/i.test(group))) return null
407
+ return groups.map((group) => parseInt(group, 16).toString(16).padStart(4, '0')).join(':')
408
+ }
409
+ const [leftRaw, rightRaw] = halves
410
+ const left = leftRaw === '' ? [] : leftRaw.split(':')
411
+ const right = rightRaw === '' ? [] : rightRaw.split(':')
412
+ const missing = 8 - left.length - right.length
413
+ if (missing < 0) return null
414
+ const groups = left.concat(Array(missing).fill('0'), right)
415
+ if (groups.length !== 8 || groups.some((group) => !/^[0-9a-f]{1,4}$/i.test(group))) return null
416
+ return groups.map((group) => parseInt(group, 16).toString(16).padStart(4, '0')).join(':')
417
+ }
418
+
419
+ /** Whether two hostnames are the same endpoint: exact text or same IP literal. */
420
+ const sameHostname = (a, b) => {
421
+ if (a === b) return true
422
+ const ca = canonicalIp(a)
423
+ const cb = canonicalIp(b)
424
+ return ca !== null && cb !== null && ca === cb
425
+ }
426
+
427
+ /** Loopback by exact name or by equivalent IP spelling (mapped/hex forms). */
428
+ const isLoopbackName = (name) => {
429
+ if (LOOPBACK_HOSTNAMES.has(name)) return true
430
+ const canonical = canonicalIp(name)
431
+ if (canonical === null) return false
432
+ for (const known of LOOPBACK_HOSTNAMES) {
433
+ const knownCanonical = canonicalIp(known)
434
+ if (knownCanonical !== null && knownCanonical === canonical) return true
435
+ }
436
+ return false
437
+ }
438
+
439
+ /**
440
+ * Why the settings-page route checks `Host`, `Origin` and `Sec-Fetch-Site` at
441
+ * all, when it already refuses any content-type that is not
442
+ * `application/json`.
274
443
  *
275
444
  * The content-type rule stops a cross-site form post, and no CORS headers are
276
445
  * sent so a cross-origin `fetch` with a JSON body dies in preflight. Both
@@ -283,10 +452,21 @@ export function hostnameOf(value) {
283
452
  * change is the `Host` header the browser sends: it still carries `evil.com`.
284
453
  *
285
454
  * 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` has deliberately published
287
- * the whole server on every interface, where no Host value can be known in
288
- * advance — the check is disabled there rather than breaking every legitimate
289
- * client, and that trade-off is documented in the README.
455
+ * loopback `Host`. An operator who binds a non-loopback address (`0.0.0.0`,
456
+ * `::`, or a concrete one) has deliberately published the server beyond this
457
+ * machine, where no Host value can be known in advance — the check is disabled
458
+ * there rather than breaking every legitimate client, and that trade-off is
459
+ * documented in the README.
460
+ *
461
+ * `Sec-Fetch-Site` adds a second browser-only signal (absent from curl, Node,
462
+ * and pre-2020 browsers, which is exactly why it can only ADD rejections, never
463
+ * replace the checks above): a modern browser stamps every request with it, so
464
+ * a page at evil.com talking to this route — even through tricks that drop the
465
+ * `Origin` header — still says `cross-site`. Only `cross-site` is refused;
466
+ * `same-site` must pass because schemeful same-site ignores ports (the GUI and
467
+ * the route live on the same host but different ports), and local non-browser
468
+ * clients never send the header at all, so they are governed by the Host and
469
+ * content-type checks alone.
290
470
  */
291
471
  export function requestOriginVerdict(headers, guarded) {
292
472
  const read = (name) => {
@@ -297,22 +477,32 @@ export function requestOriginVerdict(headers, guarded) {
297
477
  const requestHost = read('host').trim().toLowerCase()
298
478
  const requestHostname = hostnameOf(requestHost)
299
479
 
300
- if (guarded && !LOOPBACK_HOSTNAMES.has(requestHostname)) {
480
+ if (guarded && !isLoopbackName(requestHostname)) {
301
481
  return { ok: false, reason: 'Host 头不是回环地址(可能是 DNS 重绑定攻击):' + (requestHost === '' ? '(缺失)' : requestHost) }
302
482
  }
303
483
 
484
+ const fetchSite = read('sec-fetch-site').trim().toLowerCase()
485
+ if (fetchSite === 'cross-site') {
486
+ return { ok: false, reason: '请求被浏览器标记为跨站(Sec-Fetch-Site: cross-site)' }
487
+ }
488
+
304
489
  const origin = read('origin').trim()
305
490
  if (origin !== '') {
306
491
  let originHostname = ''
307
492
  try {
308
- originHostname = new URL(origin).hostname.toLowerCase()
493
+ // URL.hostname keeps the brackets on an IPv6 literal; normalizeHostname
494
+ // takes them off so it can meet the bracket-free Host parsing on equal
495
+ // terms.
496
+ originHostname = normalizeHostname(new URL(origin).hostname.toLowerCase())
309
497
  } catch {
310
498
  originHostname = ''
311
499
  }
312
500
  // Compare hostnames, not host:port: a proxy may rewrite the port, and the
313
501
  // hostname is the part rebinding actually attacks. `Origin: null`
314
502
  // (sandboxed iframe, file://) parses to no hostname and is refused.
315
- if (originHostname === '' || originHostname !== requestHostname) {
503
+ // Canonical IP comparison covers the two spellings of one address:
504
+ // ::1 vs [::1], and ::ffff:127.0.0.1 vs ::ffff:7f00:1.
505
+ if (originHostname === '' || !sameHostname(originHostname, requestHostname)) {
316
506
  return { ok: false, reason: 'Origin 与 Host 不同源:' + origin }
317
507
  }
318
508
  }
@@ -435,6 +625,13 @@ export function apply(ctx) {
435
625
  loadedFrom: null,
436
626
  savedAt: null,
437
627
  migratedFrom: null,
628
+ /**
629
+ * Set while the store on disk declares an unsupported format version.
630
+ * Mutating commands are refused in that state: a save would rewrite a
631
+ * future-format file into v1, destroying whatever the newer format meant.
632
+ */
633
+ versionRejected: false,
634
+ rejectedVersion: null,
438
635
  sectionRegistered: false,
439
636
  sectionError: null,
440
637
  /** Whether a store file is actually present on disk (diagnostics + the panel). */
@@ -590,14 +787,194 @@ export function apply(ctx) {
590
787
  console.error('[instruction-memory] cleanup failed:', err)
591
788
  }
592
789
  }
790
+ stopWatching()
791
+ disposeStreams()
593
792
  })
594
793
 
794
+ /* ---------------- external-change watching (SSE) ---------------- */
795
+
796
+ /**
797
+ * Live subscribers to the settings page's event stream. Each entry maps a
798
+ * stream's `send(payload)` writer to its own teardown, so a write that fails
799
+ * (the socket went away) can dispose THAT stream instead of just dropping
800
+ * it from the broadcast set while its response stays open.
801
+ */
802
+ const sseStreams = new Map()
803
+ let storeWatcher = null
804
+ let watchDebounce = null
805
+
806
+ const pushSnapshot = () => {
807
+ if (sseStreams.size === 0) return
808
+ const payload = 'data: ' + JSON.stringify(snapshot()) + '\n\n'
809
+ for (const [send, cleanup] of Array.from(sseStreams)) {
810
+ try {
811
+ send(payload)
812
+ } catch {
813
+ // The response is dead: run its full teardown (end + unsubscribe),
814
+ // not just a set delete that leaves the stream half-registered.
815
+ cleanup()
816
+ }
817
+ }
818
+ }
819
+
820
+ /**
821
+ * A store file changed on disk. Debounced (one atomic save fires rename +
822
+ * copy events), then serialised through the same diskQueue as every other
823
+ * mutation so a watcher re-read can never interleave with a save.
824
+ *
825
+ * The file is read WITHOUT adopting it first, because this plugin's own
826
+ * writes fire the watcher too: if the sanitised content is identical to the
827
+ * in-memory state, the event was ours and there is nothing to tell anyone.
828
+ * A failed read (the file is momentarily absent mid-replace, or corrupt) is
829
+ * skipped rather than recovered-from — recovery belongs to reload, which a
830
+ * human asked for.
831
+ */
832
+ const onStoreFileEvent = () => {
833
+ if (watchDebounce !== null) clearTimeout(watchDebounce)
834
+ watchDebounce = setTimeout(() => {
835
+ watchDebounce = null
836
+ void enqueue(async () => {
837
+ try {
838
+ const parsed = await readStoreFile(storePath)
839
+ if (parsed === null) return
840
+ const loaded = normalizeData(parsed)
841
+ if (loaded.rejectedVersion === undefined
842
+ && JSON.stringify(loaded.data) === JSON.stringify(state.data)) {
843
+ return
844
+ }
845
+ await readFromDisk()
846
+ syncSection()
847
+ pushSnapshot()
848
+ } catch {
849
+ // Transient: the next event, or the panel's 重新载入, catches up.
850
+ }
851
+ })
852
+ }, WATCH_DEBOUNCE_MS)
853
+ }
854
+
855
+ const stopWatching = () => {
856
+ if (watchDebounce !== null) {
857
+ clearTimeout(watchDebounce)
858
+ watchDebounce = null
859
+ }
860
+ if (storeWatcher !== null) {
861
+ const watcher = storeWatcher
862
+ storeWatcher = null
863
+ try {
864
+ watcher.close()
865
+ } catch {
866
+ // Already closed by the OS side.
867
+ }
868
+ }
869
+ }
870
+
871
+ /**
872
+ * Watch the store DIRECTORY, not the file: this plugin's saves replace
873
+ * memory.json through an atomic rename, which on Windows can orphan a
874
+ * file-level watch handle (the watched inode is gone). Directory events
875
+ * survive replaces. A missing directory (first run, nothing materialised
876
+ * yet) throws and is retried by the first successful write.
877
+ */
878
+ const startWatching = () => {
879
+ if (storeWatcher !== null) return
880
+ try {
881
+ storeWatcher = watch(dirname(storePath), { persistent: false }, (event, filename) => {
882
+ // Only events naming memory.json matter; the .tmp/.bak/.lock churn of
883
+ // our own writes is filtered out here (the content compare in
884
+ // onStoreFileEvent catches what slips through).
885
+ if (typeof filename === 'string' && filename !== basename(storePath)) return
886
+ onStoreFileEvent()
887
+ })
888
+ storeWatcher.on('error', () => stopWatching())
889
+ } catch {
890
+ storeWatcher = null
891
+ }
892
+ }
893
+
894
+ const disposeStreams = () => {
895
+ for (const cleanup of Array.from(sseStreams.values())) {
896
+ try {
897
+ cleanup()
898
+ } catch {
899
+ // The response is already gone.
900
+ }
901
+ }
902
+ sseStreams.clear()
903
+ }
904
+
595
905
  /* ---------------- storage ---------------- */
596
906
 
597
907
  const storePath = resolveStorePath()
598
908
 
599
909
  const serialize = () => JSON.stringify(state.data, null, 2) + '\n'
600
910
 
911
+ /**
912
+ * Cross-process write lock: a `memory.json.lock` marker created exclusively
913
+ * (`wx`) holding a random owner token, kept for the duration of one atomic
914
+ * replace, and removed afterwards ONLY BY ITS OWNER.
915
+ *
916
+ * The in-process diskQueue serialises this plugin's own writes, but two DSH
917
+ * processes sharing a `$DSH_HOME` (e.g. `web` and `tui` profiles running at
918
+ * once) can interleave a copy-backup/atomic-rename sequence. The rename
919
+ * itself keeps the file uncorrupted; what the lock narrows is the
920
+ * last-write-wins window in which one process's edit silently erases the
921
+ * other's.
922
+ *
923
+ * Availability beats exclusion for a user-facing store: a lock that cannot
924
+ * be acquired within a short wait is NEVER allowed to fail the save — the
925
+ * write proceeds anyway (disclosed in the debug log). A lock older than
926
+ * LOCK_STALE_MS is treated as crashed-holder debris and stolen, so a killed
927
+ * process cannot wedge every later save behind a stale marker.
928
+ *
929
+ * The token matters: stealing used to be a plain unlink, and the slow
930
+ * holder's `finally` then unlinked the lock the NEW owner had just created —
931
+ * a classic TOCTOU that silently unlocked the third writer. The marker
932
+ * carries this attempt's UUID, and release deletes it only when the file
933
+ * still carries that UUID, so a finished holder can never remove a lock it
934
+ * no longer owns.
935
+ *
936
+ * @returns `{ path, token }` while held, or `null` when the wait elapsed
937
+ * (the save proceeds unlocked by design).
938
+ */
939
+ const acquireStoreLock = async () => {
940
+ const lockPath = storePath + '.lock'
941
+ const token = randomUUID()
942
+ const deadline = Date.now() + LOCK_WAIT_MS
943
+ for (;;) {
944
+ try {
945
+ await writeFile(lockPath, token, { flag: 'wx' })
946
+ return { path: lockPath, token }
947
+ } catch (err) {
948
+ if (err.code !== 'EEXIST') throw err
949
+ const info = await stat(lockPath).catch(() => null)
950
+ if (info !== null && Date.now() - info.mtimeMs > LOCK_STALE_MS) {
951
+ // Crashed-holder debris. It may be recreated by a live process
952
+ // between this unlink and the next wx attempt — that case simply
953
+ // loops and re-evaluates freshness instead of forcing its way in.
954
+ await unlink(lockPath).catch(() => {})
955
+ continue
956
+ }
957
+ if (Date.now() >= deadline) return null
958
+ await new Promise((resolve) => setTimeout(resolve, 50))
959
+ }
960
+ }
961
+ }
962
+
963
+ /**
964
+ * Release a lock only when THIS attempt still owns it. Reading the marker
965
+ * back and comparing the token closes the window in which a finished slow
966
+ * holder deleted a lock the next owner had already taken over.
967
+ */
968
+ const releaseStoreLock = async (lock) => {
969
+ if (lock === null) return
970
+ try {
971
+ const owner = await readFile(lock.path, 'utf8').catch(() => null)
972
+ if (owner === lock.token) await unlink(lock.path).catch(() => {})
973
+ } catch {
974
+ // Cleanup must never throw into the save's finally.
975
+ }
976
+ }
977
+
601
978
  const writeToDisk = async () => {
602
979
  // Atomic replace (write a sibling temp file, then rename) with a rolling
603
980
  // one-version backup: a crash mid-write must never leave a truncated
@@ -614,8 +991,18 @@ export function apply(ctx) {
614
991
  // any step leaves the previous store readable at its own path.
615
992
  const tmpPath = storePath + '.tmp'
616
993
  const bakPath = storePath + '.bak'
994
+ let lock = null
995
+ let previousRev = null
617
996
  try {
618
997
  await mkdir(dirname(storePath), { recursive: true })
998
+ lock = await acquireStoreLock()
999
+ if (lock === null) {
1000
+ logDebug('store lock still held by another process; writing anyway (last write wins)')
1001
+ }
1002
+ // The rev is bumped before serialising so the file on disk and the state
1003
+ // in memory agree on it; a failed write restores the old value.
1004
+ previousRev = state.data.rev || 0
1005
+ state.data.rev = previousRev + 1
619
1006
  const text = serialize()
620
1007
  await writeFile(tmpPath, text, 'utf8')
621
1008
  try {
@@ -644,14 +1031,20 @@ export function apply(ctx) {
644
1031
  // A successful write is the one thing that makes the file real, so it is
645
1032
  // also what clears the "not materialised yet" flag set by a first run.
646
1033
  needsMaterialize = false
1034
+ // ...and what guarantees the store directory exists, so the watcher can
1035
+ // be (re)armed after a first run that had nothing to watch.
1036
+ startWatching()
647
1037
  state.savedAt = Date.now()
648
1038
  logDebug('store written: ' + storePath + ' bytes=' + text.length
649
- + ' entries=' + state.data.entries.length
1039
+ + ' entries=' + state.data.entries.length + ' rev=' + state.data.rev
650
1040
  + (state.backupError === null ? ' backup=refreshed' : ' backup=FAILED'))
651
1041
  return true
652
1042
  } catch (err) {
1043
+ if (previousRev !== null) state.data.rev = previousRev
653
1044
  state.storageError = '保存失败:' + describeError(err)
654
1045
  return false
1046
+ } finally {
1047
+ await releaseStoreLock(lock)
655
1048
  }
656
1049
  }
657
1050
 
@@ -660,25 +1053,57 @@ export function apply(ctx) {
660
1053
  return text.trim() === '' ? null : JSON.parse(text)
661
1054
  }
662
1055
 
1056
+ /**
1057
+ * Load the store from disk. Most branches only read; two of them also write —
1058
+ * the missing-primary recovery promotes the backup to memory.json, and the
1059
+ * legacy-location migration writes the migrated store. The return value says
1060
+ * which happened, because `reload` reports it as its `saved` field: the
1061
+ * response envelope's `saved` means "this operation persisted the store",
1062
+ * and a read-only reload claiming true would be indistinguishable from a
1063
+ * real save.
1064
+ *
1065
+ * @returns whether this load performed a successful write.
1066
+ */
663
1067
  const readFromDisk = async () => {
664
1068
  state.storagePath = storePath
665
1069
  state.storageDisplay = storePath
666
1070
  state.storageWarning = null
667
1071
  state.loadedFrom = null
668
1072
  state.storeExists = false
1073
+ state.versionRejected = false
1074
+ state.rejectedVersion = null
1075
+
1076
+ /**
1077
+ * A store declaring an unsupported format version: adopt nothing, write
1078
+ * nothing, and say why. The one thing that must NEVER happen in this
1079
+ * state is a save — it would rewrite the file as v1 and destroy whatever
1080
+ * the newer format meant (the mutating commands refuse while
1081
+ * versionRejected is set).
1082
+ */
1083
+ const rejectVersion = (version, source) => {
1084
+ state.versionRejected = true
1085
+ state.rejectedVersion = version
1086
+ state.storageError = '存储文件声明为格式版本 ' + version + ',本插件只支持版本 1;已拒绝载入以防数据损坏,原文件已保留,修改操作已暂停。请升级插件,或手动将文件改回版本 1 后点「重新载入」'
1087
+ state.loadedFrom = source + ' (unsupported version ' + version + ')'
1088
+ state.storeExists = true
1089
+ }
669
1090
 
670
1091
  // 1. The canonical location under the harness home.
671
1092
  try {
672
1093
  const parsed = await readStoreFile(storePath)
673
1094
  if (parsed !== null) {
674
1095
  const loaded = normalizeData(parsed)
1096
+ if (loaded.rejectedVersion !== undefined) {
1097
+ rejectVersion(loaded.rejectedVersion, 'memory.json')
1098
+ return false
1099
+ }
675
1100
  state.data = loaded.data
676
- state.storageWarning = droppedEntriesMessage(loaded.dropped)
1101
+ state.storageWarning = loadWarnings(loaded)
677
1102
  state.loadedFrom = 'memory.json'
678
1103
  state.storeExists = true
679
1104
  }
680
1105
  state.storageError = null
681
- return
1106
+ return false
682
1107
  } catch (err) {
683
1108
  if (err === null || err === undefined || err.code !== 'ENOENT') {
684
1109
  // The main file is unreadable or corrupt. A previous good version
@@ -687,16 +1112,27 @@ export function apply(ctx) {
687
1112
  // itself stays untouched for hand repair; it gets overwritten on the
688
1113
  // next save (the pre-existing and disclosed behaviour).
689
1114
  if (err instanceof SyntaxError) {
1115
+ // The file was READ and only failed to PARSE, so a store file is
1116
+ // physically present on disk. Saying onDisk=no here contradicted
1117
+ // the branch's own story ("损坏文件已保留") and produced a mounted
1118
+ // log line reading "from=memory.json.bak (main file corrupt)
1119
+ // onDisk=no". (Other non-ENOENT errors stay no: ENOTDIR, say, means
1120
+ // nothing exists at the store path at all.)
1121
+ state.storeExists = true
690
1122
  try {
691
1123
  const backup = await readStoreFile(storePath + '.bak')
692
1124
  if (backup !== null) {
693
1125
  const loaded = normalizeData(backup)
1126
+ if (loaded.rejectedVersion !== undefined) {
1127
+ rejectVersion(loaded.rejectedVersion, 'memory.json.bak')
1128
+ return false
1129
+ }
694
1130
  state.data = loaded.data
695
- state.storageWarning = (droppedEntriesMessage(loaded.dropped) || '')
696
- + (loaded.dropped > 0 ? ';' : '')
1131
+ const warnings = loadWarnings(loaded)
1132
+ state.storageWarning = (warnings !== null ? warnings + ';' : '')
697
1133
  + 'memory.json 解析失败(' + describeError(err) + '),已临时回退到最近备份 memory.json.bak;损坏文件已保留,下次保存时会覆盖它'
698
1134
  state.loadedFrom = 'memory.json.bak (main file corrupt)'
699
- return
1135
+ return false
700
1136
  }
701
1137
  } catch {
702
1138
  // No usable backup either; fall through to the error below.
@@ -706,7 +1142,7 @@ export function apply(ctx) {
706
1142
  state.storageError = err instanceof SyntaxError
707
1143
  ? '配置文件解析失败(' + describeError(err) + '),原文件已保留;你下次保存时会覆盖它'
708
1144
  : '读取失败:' + describeError(err)
709
- return
1145
+ return false
710
1146
  }
711
1147
  }
712
1148
 
@@ -722,14 +1158,18 @@ export function apply(ctx) {
722
1158
  const backup = await readStoreFile(storePath + '.bak')
723
1159
  if (backup !== null) {
724
1160
  const loaded = normalizeData(backup)
1161
+ if (loaded.rejectedVersion !== undefined) {
1162
+ rejectVersion(loaded.rejectedVersion, 'memory.json.bak')
1163
+ return false
1164
+ }
725
1165
  state.data = loaded.data
726
1166
  const promoted = await writeToDisk()
727
- state.storageWarning = (droppedEntriesMessage(loaded.dropped) || '')
728
- + (loaded.dropped > 0 ? ';' : '')
1167
+ const warnings = loadWarnings(loaded)
1168
+ state.storageWarning = (warnings !== null ? warnings + ';' : '')
729
1169
  + 'memory.json 不存在,已从最近备份 memory.json.bak 恢复'
730
1170
  + (promoted ? ',并已写回 memory.json' : ';写回 memory.json 失败,下次保存时会重试')
731
1171
  state.loadedFrom = 'memory.json.bak (main file missing)'
732
- return
1172
+ return promoted
733
1173
  }
734
1174
  } catch {
735
1175
  // An unusable backup must never block a fresh store; fall through.
@@ -742,15 +1182,21 @@ export function apply(ctx) {
742
1182
  const parsed = await readStoreFile(legacyPath)
743
1183
  if (parsed !== null) {
744
1184
  const loaded = normalizeData(parsed)
1185
+ if (loaded.rejectedVersion !== undefined) {
1186
+ // A legacy file in a future format is not ours to migrate or judge.
1187
+ state.storageError = '旧位置文件为不支持的格式版本 ' + loaded.rejectedVersion + ',已忽略'
1188
+ return false
1189
+ }
745
1190
  state.data = loaded.data
746
- state.storageWarning = droppedEntriesMessage(loaded.dropped)
747
- if (await writeToDisk()) {
1191
+ state.storageWarning = loadWarnings(loaded)
1192
+ const migrated = await writeToDisk()
1193
+ if (migrated) {
748
1194
  state.migratedFrom = legacyPath
749
1195
  // Keep the original as evidence instead of deleting user data.
750
1196
  await rename(legacyPath, legacyPath + '.migrated').catch(() => {})
751
1197
  }
752
1198
  state.loadedFrom = 'legacy cwd file (migrated)'
753
- return
1199
+ return migrated
754
1200
  }
755
1201
  } catch (err) {
756
1202
  // A missing or unreadable legacy file must never block a fresh store.
@@ -770,6 +1216,7 @@ export function apply(ctx) {
770
1216
  needsMaterialize = true
771
1217
  state.loadedFrom = 'first run (no store on disk yet)'
772
1218
  state.storeExists = false
1219
+ return false
773
1220
  }
774
1221
 
775
1222
  /**
@@ -789,11 +1236,19 @@ export function apply(ctx) {
789
1236
  // Load the store, then register the section if anything must be injected.
790
1237
  // Every entry point awaits this, so a request that arrives before the disk
791
1238
  // read settles can never overwrite the file with an empty in-memory state.
1239
+ //
1240
+ // `bootSettled` flips only after the read lands. The SSE connect frame is
1241
+ // gated on it: a stream opened in the gap between apply() and the read
1242
+ // finishing must wait, or its first frame would describe the EMPTY boot
1243
+ // state and the panel would flash "no instructions" before the real
1244
+ // snapshot arrived (POST /state already awaited boot; the stream did not).
1245
+ let bootSettled = false
792
1246
  const boot = readFromDisk()
793
1247
  .catch((err) => {
794
1248
  state.storageError = '读取失败:' + describeError(err)
795
1249
  })
796
1250
  .then(() => {
1251
+ bootSettled = true
797
1252
  syncSection()
798
1253
  // The one always-on line: it answers "is memory mounted, where does it
799
1254
  // live, and how much is being injected?" without any further digging.
@@ -809,6 +1264,9 @@ export function apply(ctx) {
809
1264
  + ' section=' + (chars > 0 ? chars + ' chars (pending assembly)' : 'nothing to inject')
810
1265
  + ' pulls=' + sectionPulls
811
1266
  + (state.storageError !== null ? ' error=' + state.storageError : ''))
1267
+ // Arm the external-change watcher once the store is known to exist. A
1268
+ // first run has nothing to watch yet; the first successful write rearms.
1269
+ if (state.storeExists) startWatching()
812
1270
  })
813
1271
 
814
1272
  /**
@@ -833,6 +1291,7 @@ export function apply(ctx) {
833
1291
  return {
834
1292
  data: {
835
1293
  version: 1,
1294
+ rev: state.data.rev || 0,
836
1295
  enabled: state.data.enabled === true,
837
1296
  budgetChars: clampBudget(state.data.budgetChars),
838
1297
  entries: (Array.isArray(state.data.entries) ? state.data.entries : []).map((entry) => ({ ...entry })),
@@ -859,7 +1318,13 @@ export function apply(ctx) {
859
1318
  pulls: sectionPulls,
860
1319
  },
861
1320
  preview,
862
- limits: { minBudget: MIN_BUDGET, maxBudget: MAX_BUDGET, maxEntries: MAX_ENTRIES },
1321
+ /**
1322
+ * `maxBodyChars` is the route's request-body ceiling, published so the
1323
+ * Client can refuse an obviously oversized import file BEFORE reading it
1324
+ * into memory. It is a character budget against byte sizes, so the
1325
+ * Client applies a generous multiplier — see importSizeVerdict there.
1326
+ */
1327
+ limits: { minBudget: MIN_BUDGET, maxBudget: MAX_BUDGET, maxEntries: MAX_ENTRIES, maxBodyChars: MAX_BODY_CHARS },
863
1328
  }
864
1329
  }
865
1330
 
@@ -872,6 +1337,41 @@ export function apply(ctx) {
872
1337
 
873
1338
  /* ---------------- commands (shared by route and callers) ---------------- */
874
1339
 
1340
+ /**
1341
+ * Optimistic concurrency: a mutating command may carry `baseRev` — the
1342
+ * `data.rev` its caller last saw. A mismatch means another window or process
1343
+ * committed in between, and applying this command now would build on a stale
1344
+ * view (e.g. resurrect an entry another tab just deleted). The refusal hands
1345
+ * back the FRESH snapshot so the panel resyncs immediately; the user retries
1346
+ * and the retry carries the new rev.
1347
+ *
1348
+ * Absent `baseRev` (older clients) disables the check entirely — legacy
1349
+ * behaviour, not an error.
1350
+ */
1351
+ const isStaleBaseRev = (baseRev) =>
1352
+ typeof baseRev === 'number' && Number.isFinite(baseRev) && baseRev !== (state.data.rev || 0)
1353
+
1354
+ const conflictResponse = () => ({
1355
+ ok: false,
1356
+ saved: false,
1357
+ conflict: true,
1358
+ message: '指令记忆刚被其他窗口或进程修改,已为你载入最新内容;请检查后重试本次操作',
1359
+ snapshot: snapshot(),
1360
+ })
1361
+
1362
+ /**
1363
+ * Refusal while the store on disk declares an unsupported format version:
1364
+ * a save here would rewrite the file as v1 and destroy whatever the newer
1365
+ * format meant. The file stays untouched until the user upgrades the plugin
1366
+ * or restores a v1 file and hits 重新载入.
1367
+ */
1368
+ const versionRefusal = () => ({
1369
+ ok: false,
1370
+ saved: false,
1371
+ message: '存储文件为不支持的格式版本 ' + state.rejectedVersion + ',修改已暂停:现在保存会用版本 1 覆盖它,可能损坏数据。请先升级插件,或手动将文件改回版本 1 后点「重新载入」',
1372
+ snapshot: snapshot(),
1373
+ })
1374
+
875
1375
  /**
876
1376
  * Apply an in-memory mutation, persist it, and UNDO it when the write fails.
877
1377
  *
@@ -896,6 +1396,14 @@ export function apply(ctx) {
896
1396
  // dropped-entries warning from load time ("they will be removed on the
897
1397
  // next save") has played out and must not keep showing.
898
1398
  state.storageWarning = null
1399
+ // The write is real, so every OTHER open panel must hear about it. The
1400
+ // file watcher stays silent for this plugin's own writes (disk now
1401
+ // equals memory, see onStoreFileEvent), which is correct for recovery —
1402
+ // but a second window on the same host would otherwise sit on its stale
1403
+ // view until someone clicks 重新载入. The initiating window receives the
1404
+ // same frame and drops it client-side (identical content), so pushing to
1405
+ // everyone is safe and closing the loop needs no per-client bookkeeping.
1406
+ pushSnapshot()
899
1407
  return true
900
1408
  }
901
1409
  logDebug('commit rolled back: the disk write failed, in-memory state restored')
@@ -905,7 +1413,9 @@ export function apply(ctx) {
905
1413
  return false
906
1414
  }
907
1415
 
908
- const saveEntry = (raw) => enqueue(async () => {
1416
+ const saveEntry = (raw, baseRev) => enqueue(async () => {
1417
+ if (state.versionRejected) return versionRefusal()
1418
+ if (isStaleBaseRev(baseRev)) return conflictResponse()
909
1419
  const entry = sanitizeEntry(raw)
910
1420
  if (entry === null) return { ok: false, saved: false, message: '标题与指令内容不能同时为空', snapshot: snapshot() }
911
1421
  entry.updatedAt = Date.now()
@@ -928,14 +1438,25 @@ export function apply(ctx) {
928
1438
  return settle(note, saved)
929
1439
  })
930
1440
 
931
- const deleteEntry = (id) => enqueue(async () => {
1441
+ const deleteEntry = (id, baseRev) => enqueue(async () => {
1442
+ if (state.versionRejected) return versionRefusal()
1443
+ if (isStaleBaseRev(baseRev)) return conflictResponse()
1444
+ // Deleting an id that is not there changes nothing, so it must not touch
1445
+ // disk, bump rev, or push an echo frame — otherwise two panels deleting
1446
+ // the same entry one after another would manufacture a pointless write
1447
+ // (and an external process watching the file would wake for nothing).
1448
+ if (!state.data.entries.some((entry) => entry.id === id)) {
1449
+ return { ok: true, saved: false, message: null, snapshot: snapshot() }
1450
+ }
932
1451
  const saved = await commit(() => {
933
1452
  state.data.entries = state.data.entries.filter((entry) => entry.id !== id)
934
1453
  })
935
1454
  return settle(null, saved)
936
1455
  })
937
1456
 
938
- const setOptions = (patch) => enqueue(async () => {
1457
+ const setOptions = (patch, baseRev) => enqueue(async () => {
1458
+ if (state.versionRejected) return versionRefusal()
1459
+ if (isStaleBaseRev(baseRev)) return conflictResponse()
939
1460
  const saved = await commit(() => {
940
1461
  if (patch !== null && typeof patch === 'object') {
941
1462
  if (typeof patch.enabled === 'boolean') state.data.enabled = patch.enabled
@@ -948,14 +1469,19 @@ export function apply(ctx) {
948
1469
  })
949
1470
 
950
1471
  const reload = () => enqueue(async () => {
951
- await readFromDisk()
1472
+ // `saved` is only true when the load itself wrote to disk (the
1473
+ // backup-promotion or legacy-migration branch); a plain re-read claiming
1474
+ // true would be indistinguishable from a real save on the wire.
1475
+ const wrote = await readFromDisk()
952
1476
  syncSection()
953
- return { ok: state.storageError === null, saved: true, message: state.storageError, snapshot: snapshot() }
1477
+ return { ok: state.storageError === null, saved: wrote === true, message: state.storageError, snapshot: snapshot() }
954
1478
  })
955
1479
 
956
1480
  const exportData = () => enqueue(async () => ({
957
1481
  ok: true,
958
- saved: true,
1482
+ // An export reads the store and never writes it; a truthful `saved:false`
1483
+ // keeps the field's meaning ("this operation persisted the store") intact.
1484
+ saved: false,
959
1485
  message: null,
960
1486
  // A self-describing envelope: re-importable by this plugin, and readable
961
1487
  // as plain JSON by anything else.
@@ -963,7 +1489,9 @@ export function apply(ctx) {
963
1489
  snapshot: snapshot(),
964
1490
  }))
965
1491
 
966
- const importData = (payload) => enqueue(async () => {
1492
+ const importData = (payload, baseRev) => enqueue(async () => {
1493
+ if (state.versionRejected) return versionRefusal()
1494
+ if (isStaleBaseRev(baseRev)) return conflictResponse()
967
1495
  if (payload === null || typeof payload !== 'object') {
968
1496
  return { ok: false, saved: false, message: '导入内容不是有效的 JSON 对象', snapshot: snapshot() }
969
1497
  }
@@ -1037,23 +1565,31 @@ export function apply(ctx) {
1037
1565
  // A first run writes nothing at boot (readFromDisk step 3), so the
1038
1566
  // materialisation happens here, on a deliberate user action, instead of
1039
1567
  // as a side effect of the host merely starting up.
1040
- await ensureStoreMaterialized()
1568
+ const materialized = await ensureStoreMaterialized()
1041
1569
  // Same envelope as every other method. One response shape means the
1042
1570
  // Client can never again be handed a payload it silently ignores.
1043
- return { ok: true, saved: true, message: null, snapshot: snapshot() }
1571
+ // `saved` is true only on the first run, when materialisation actually
1572
+ // wrote the file; every later read wrote nothing and says so.
1573
+ return { ok: true, saved: materialized === true, message: null, snapshot: snapshot() }
1044
1574
  }
1045
1575
  case 'save-entry':
1046
- return saveEntry(args !== null && typeof args === 'object' ? args.entry : null)
1576
+ return saveEntry(
1577
+ args !== null && typeof args === 'object' ? args.entry : null,
1578
+ args !== null && typeof args === 'object' ? args.baseRev : undefined)
1047
1579
  case 'delete-entry':
1048
- return deleteEntry(args !== null && typeof args === 'object' && typeof args.id === 'string' ? args.id : '')
1580
+ return deleteEntry(
1581
+ args !== null && typeof args === 'object' && typeof args.id === 'string' ? args.id : '',
1582
+ args !== null && typeof args === 'object' ? args.baseRev : undefined)
1049
1583
  case 'set-options':
1050
- return setOptions(args)
1584
+ return setOptions(args, args !== null && typeof args === 'object' ? args.baseRev : undefined)
1051
1585
  case 'reload':
1052
1586
  return reload()
1053
1587
  case 'export-data':
1054
1588
  return exportData()
1055
1589
  case 'import-data':
1056
- return importData(args !== null && typeof args === 'object' ? args.payload : null)
1590
+ return importData(
1591
+ args !== null && typeof args === 'object' ? args.payload : null,
1592
+ args !== null && typeof args === 'object' ? args.baseRev : undefined)
1057
1593
  default:
1058
1594
  return { ok: false, saved: false, message: '未知方法:' + String(method), snapshot: snapshot() }
1059
1595
  }
@@ -1068,7 +1604,88 @@ export function apply(ctx) {
1068
1604
  let guardHost = true
1069
1605
 
1070
1606
  let routeDispose = null
1607
+
1608
+ /**
1609
+ * The live-update stream for the settings page (GET + `Accept:
1610
+ * text/event-stream`, i.e. exactly what an EventSource sends).
1611
+ *
1612
+ * Why the method/accept pair instead of a sub-path: the route is registered
1613
+ * as a plain prefix and how the harness presents `req.url` to it is not part
1614
+ * of any contract, while `req.method` and the Accept header are. A plain GET
1615
+ * (someone navigating to the URL) keeps the old 405.
1616
+ *
1617
+ * Frames carry the bare snapshot — the same shape `state` answers with —
1618
+ * which the Client's normalizer already accepts.
1619
+ */
1620
+ const handleEventStream = (req, res) => {
1621
+ const verdict = requestOriginVerdict(req.headers, guardHost)
1622
+ if (!verdict.ok) {
1623
+ logInfo('route rejected a request: ' + verdict.reason)
1624
+ res.writeHead(403, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
1625
+ res.end(JSON.stringify({ ok: false, message: '请求来源被拒绝:' + verdict.reason }))
1626
+ return
1627
+ }
1628
+ res.writeHead(200, {
1629
+ 'content-type': 'text/event-stream; charset=utf-8',
1630
+ 'cache-control': 'no-store',
1631
+ connection: 'keep-alive',
1632
+ })
1633
+ // Ask the browser to retry politely if the stream drops (e.g. the host was
1634
+ // restarted) instead of hammering the route.
1635
+ res.write('retry: 5000\n\n')
1636
+ const send = (payload) => { res.write(payload) }
1637
+ let closed = false
1638
+ const cleanup = () => {
1639
+ closed = true
1640
+ sseStreams.delete(send)
1641
+ try {
1642
+ res.end()
1643
+ } catch {
1644
+ // The response is already gone.
1645
+ }
1646
+ }
1647
+ sseStreams.set(send, cleanup)
1648
+ req.on('close', cleanup)
1649
+ req.on('error', cleanup)
1650
+ res.on('error', cleanup)
1651
+ // A snapshot on connect, so a late subscriber synchronises immediately
1652
+ // instead of waiting for the next change. It must wait for boot: a stream
1653
+ // opened while the initial disk read is still in flight would otherwise be
1654
+ // handed the empty boot state as its first frame, flashing "no
1655
+ // instructions" before the loaded store arrives.
1656
+ const sendConnectFrame = () => {
1657
+ if (closed) return
1658
+ try {
1659
+ send('data: ' + JSON.stringify(snapshot()) + '\n\n')
1660
+ } catch {
1661
+ cleanup()
1662
+ }
1663
+ }
1664
+ if (bootSettled) {
1665
+ sendConnectFrame()
1666
+ } else {
1667
+ boot.then(sendConnectFrame, sendConnectFrame)
1668
+ }
1669
+ }
1670
+
1071
1671
  const handler = (req, res) => {
1672
+ if (req.method === 'GET') {
1673
+ const accept = String(
1674
+ (req.headers !== null && typeof req.headers === 'object') ? (req.headers.accept || '') : '',
1675
+ )
1676
+ if (accept.indexOf('text/event-stream') !== -1) {
1677
+ // A call aborted mid-flight makes these streams emit 'error'; a server
1678
+ // stream with no error listener would take the host process down over
1679
+ // one dead HTTP request.
1680
+ req.on('error', () => {})
1681
+ res.on('error', () => {})
1682
+ handleEventStream(req, res)
1683
+ return
1684
+ }
1685
+ res.writeHead(405, { 'content-type': 'application/json; charset=utf-8' })
1686
+ res.end(JSON.stringify({ ok: false, message: 'only POST is supported' }))
1687
+ return
1688
+ }
1072
1689
  if (req.method !== 'POST') {
1073
1690
  res.writeHead(405, { 'content-type': 'application/json; charset=utf-8' })
1074
1691
  res.end(JSON.stringify({ ok: false, message: 'only POST is supported' }))
@@ -1149,12 +1766,16 @@ export function apply(ctx) {
1149
1766
  return
1150
1767
  }
1151
1768
  // The Host allowlist only makes sense when the operator bound the server to
1152
- // loopback. `0.0.0.0` means they deliberately published it on every
1153
- // interface, where no Host value can be known in advance — enforcing a
1154
- // loopback allowlist there would break every legitimate client, so the
1155
- // check is disabled and the trade-off is documented in the README.
1156
- const boundHost = typeof webServer.host === 'string' ? webServer.host.trim().toLowerCase() : '127.0.0.1'
1157
- guardHost = boundHost !== '0.0.0.0'
1769
+ // loopback. Anything else — `0.0.0.0`/`::` (every interface) or a concrete
1770
+ // address such as `192.168.1.5` — is a deliberate publication beyond this
1771
+ // machine, where no Host value can be known in advance; enforcing a
1772
+ // loopback allowlist there would 403 every legitimate client of that
1773
+ // published deployment. (The previous check compared against `0.0.0.0`
1774
+ // only, which silently broke `::` and concrete-address binds.) The check is
1775
+ // disabled for all non-loopback binds and the trade-off is documented in
1776
+ // the README.
1777
+ const boundHost = typeof webServer.host === 'string' ? webServer.host.trim().toLowerCase() : ''
1778
+ guardHost = boundHost === '' || isLoopbackName(boundHost)
1158
1779
  try {
1159
1780
  routeDispose = webServer.register({ kind: 'prefix', path: API_PREFIX, handler })
1160
1781
  logDebug('settings route registered at ' + API_PREFIX + ' (boundHost=' + boundHost + ', hostGuard=' + guardHost + ')')