dsh-vision-router 2.2.0 → 2.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/README.zh.md +3 -3
- package/docs/releases/v2.2.1.md +22 -0
- package/docs/releases/v2.2.2.md +21 -0
- package/lib/client.js +67 -27
- package/lib/core-primitives.js +11 -0
- package/lib/dsh-contract-compat.js +28 -5
- package/lib/dsh-settings-017-compat.js +416 -0
- package/lib/fetch-wrapper-lifecycle.js +56 -0
- package/lib/legacy-global-proxy-boundary.js +4 -3
- package/lib/pi-ai-bridge-wire-compat.js +4 -3
- package/lib/pixel-diff-stream.js +12 -6
- package/lib/public-entry.js +21 -1
- package/lib/session-message-source-compat.js +92 -0
- package/lib/session-vision-index.js +238 -10
- package/lib/settings-client-017-compat.js +331 -0
- package/lib/settings-client-rc8-lifecycle.js +13 -0
- package/lib/vision-breaker-shadow-health.js +11 -1
- package/lib/web/dsh-settings-017-root-transport.js +299 -0
- package/lib/web/remote-settings-client.js +200 -0
- package/package.json +3 -3
|
@@ -2,6 +2,8 @@ import { currentSessionSurfacePolicy } from './session-surface-policy.js'
|
|
|
2
2
|
import { sessionSurfaceReplacementIntent } from './session-surface-compat.js'
|
|
3
3
|
import { createSessionVisionStateStore } from './session-vision-state.js'
|
|
4
4
|
|
|
5
|
+
const MAX_PENDING_REPAIR_EVENTS = 256
|
|
6
|
+
|
|
5
7
|
function isObject(value) {
|
|
6
8
|
return value !== null && typeof value === 'object'
|
|
7
9
|
}
|
|
@@ -39,13 +41,23 @@ function boundedMessage(error) {
|
|
|
39
41
|
return String(text ?? '').slice(0, 400)
|
|
40
42
|
}
|
|
41
43
|
|
|
44
|
+
function isReplacementEvent(event) {
|
|
45
|
+
const op = event?.surfaceOp
|
|
46
|
+
return !!op && typeof op === 'object' && !Array.isArray(op) && op.op === 'replace'
|
|
47
|
+
}
|
|
48
|
+
|
|
42
49
|
/**
|
|
43
50
|
* Session visual data-plane index.
|
|
44
51
|
*
|
|
45
52
|
* SessionVisionStateStore remains the bounded storage owner. This object owns
|
|
46
|
-
* target-only durable attachment recovery
|
|
47
|
-
*
|
|
48
|
-
*
|
|
53
|
+
* target-only durable attachment recovery, event-feed-backed tool-result image
|
|
54
|
+
* surface repair and expired structured guard-stop surface repair.
|
|
55
|
+
*
|
|
56
|
+
* Supported DSH Hosts expose the post-commit `session/event` feed. Production
|
|
57
|
+
* surface repair consumes only the exact frozen events observed on that feed;
|
|
58
|
+
* it never asks SessionQuery to materialize or clone the live Session history.
|
|
59
|
+
* The older reader path remains only for isolated/partial callers that do not
|
|
60
|
+
* install the event feed boundary.
|
|
49
61
|
*
|
|
50
62
|
* The index never replaces stateStore.lookupAttachment(). Cache lookup stays
|
|
51
63
|
* synchronous through lookupAttachment(); cold durable recovery is requested
|
|
@@ -73,16 +85,20 @@ export function createSessionVisionIndex({
|
|
|
73
85
|
: undefined
|
|
74
86
|
const toolSurfaceScans = new WeakMap()
|
|
75
87
|
const guardSurfaceScans = new WeakMap()
|
|
88
|
+
const pendingToolRepairEvents = new WeakMap()
|
|
89
|
+
const pendingGuardRepairEvents = new WeakMap()
|
|
76
90
|
const unsupportedSurfaceContracts = new WeakSet()
|
|
77
91
|
const repairReadWarnings = new WeakMap()
|
|
92
|
+
const repairFeedOverflowWarnings = new WeakSet()
|
|
93
|
+
const repairFeedObserverWarnings = new WeakMap()
|
|
78
94
|
const attachmentRecoveryWarnings = new WeakMap()
|
|
95
|
+
let surfaceEventFeedActive = false
|
|
79
96
|
|
|
80
97
|
const recordAttachments = (session, refs) => {
|
|
81
98
|
if (!session || !Array.isArray(refs) || refs.length === 0) return
|
|
82
99
|
store.recordAttachments(session, refs)
|
|
83
100
|
}
|
|
84
101
|
|
|
85
|
-
|
|
86
102
|
// Synchronous lookup is cache-only. Projected short handles first warm this
|
|
87
103
|
// cache from current derived messages and must not trigger historical I/O.
|
|
88
104
|
const lookupAttachment = (session, id) => {
|
|
@@ -217,13 +233,47 @@ export function createSessionVisionIndex({
|
|
|
217
233
|
}
|
|
218
234
|
|
|
219
235
|
// Isolated/partial Hosts that do not expose SessionQuery retain the released
|
|
220
|
-
// Session-local compatibility path. Supported production Hosts
|
|
221
|
-
//
|
|
236
|
+
// Session-local compatibility path. Supported production Hosts install the
|
|
237
|
+
// event feed and never enter this compatibility reader.
|
|
222
238
|
const events = legacySessionEvents(session)
|
|
223
239
|
if (!events) return { readable: false }
|
|
224
240
|
return { readable: true, event: events[seq] }
|
|
225
241
|
}
|
|
226
242
|
|
|
243
|
+
const surfaceRepairSnapshot = async (session, seq) => {
|
|
244
|
+
if (typeof readSessionLog !== 'function') return undefined
|
|
245
|
+
let result
|
|
246
|
+
try {
|
|
247
|
+
result = await readSessionLog(session)
|
|
248
|
+
} catch (error) {
|
|
249
|
+
warnRepairReadFailure(session, seq, error)
|
|
250
|
+
return { readable: false }
|
|
251
|
+
}
|
|
252
|
+
if (result?.supported === true) {
|
|
253
|
+
if (!Array.isArray(result.events)) {
|
|
254
|
+
warnRepairReadFailure(session, seq, new Error('Session log reader returned no event log'))
|
|
255
|
+
return { readable: false }
|
|
256
|
+
}
|
|
257
|
+
repairReadWarnings.delete(session)
|
|
258
|
+
return { readable: true, events: result.events }
|
|
259
|
+
}
|
|
260
|
+
if (result?.supported !== false) {
|
|
261
|
+
warnRepairReadFailure(session, seq, new Error('Session log reader returned an invalid capability result'))
|
|
262
|
+
return { readable: false }
|
|
263
|
+
}
|
|
264
|
+
return undefined
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const eventFromRepairSnapshot = (session, events, seq) => {
|
|
268
|
+
const event = events?.[seq]
|
|
269
|
+
if (!event || typeof event !== 'object' || (event.seq !== undefined && event.seq !== seq)) {
|
|
270
|
+
warnRepairReadFailure(session, seq, new Error(`Session repair snapshot has no event at seq ${seq}`))
|
|
271
|
+
return { readable: false }
|
|
272
|
+
}
|
|
273
|
+
repairReadWarnings.delete(session)
|
|
274
|
+
return { readable: true, event }
|
|
275
|
+
}
|
|
276
|
+
|
|
227
277
|
const keepToolResultImages = () => {
|
|
228
278
|
const policy = currentSessionSurfacePolicy(
|
|
229
279
|
typeof config === 'function' ? config() : config,
|
|
@@ -244,14 +294,165 @@ export function createSessionVisionIndex({
|
|
|
244
294
|
return undefined
|
|
245
295
|
}
|
|
246
296
|
|
|
297
|
+
const cachePendingRepairEvent = (pendingBySession, session, event) => {
|
|
298
|
+
if (!session || !Number.isSafeInteger(event?.seq) || event.seq < 0 || Object.is(event.seq, -0)) return
|
|
299
|
+
let pending = pendingBySession.get(session)
|
|
300
|
+
if (!pending) {
|
|
301
|
+
pending = new Map()
|
|
302
|
+
pendingBySession.set(session, pending)
|
|
303
|
+
}
|
|
304
|
+
if (!pending.has(event.seq) && pending.size >= MAX_PENDING_REPAIR_EVENTS) {
|
|
305
|
+
const oldest = pending.keys().next().value
|
|
306
|
+
if (oldest !== undefined) pending.delete(oldest)
|
|
307
|
+
if (!repairFeedOverflowWarnings.has(session)) {
|
|
308
|
+
repairFeedOverflowWarnings.add(session)
|
|
309
|
+
logger?.warn?.(
|
|
310
|
+
'vision-router: session surface repair event feed exceeded %s pending events; dropping oldest entry',
|
|
311
|
+
MAX_PENDING_REPAIR_EVENTS,
|
|
312
|
+
)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
pending.set(event.seq, event)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const planAgainstOneEvent = (event, planner) => {
|
|
319
|
+
if (!Number.isSafeInteger(event?.seq) || event.seq < 0 || Object.is(event.seq, -0)) return []
|
|
320
|
+
const events = { [event.seq]: event }
|
|
321
|
+
return planner(events, [event.seq])
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const recordSessionEvent = (session, event) => {
|
|
325
|
+
if (!session || !event || typeof event !== 'object') return
|
|
326
|
+
try {
|
|
327
|
+
if (typeof core.collectEventAttachmentRefs === 'function') {
|
|
328
|
+
const refs = core.collectEventAttachmentRefs([event])
|
|
329
|
+
if (Array.isArray(refs) && refs.length > 0) recordAttachments(session, refs)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Replacement events are consequences of this repair path and must never
|
|
333
|
+
// be re-enqueued, otherwise a guard-stop replacement would shadow itself
|
|
334
|
+
// forever because its stable plugin-owned id is intentionally preserved.
|
|
335
|
+
if (isReplacementEvent(event)) return
|
|
336
|
+
|
|
337
|
+
if (event.type === 'tool/result' && typeof core.planToolResultImageShadows === 'function') {
|
|
338
|
+
const plans = planAgainstOneEvent(
|
|
339
|
+
event,
|
|
340
|
+
(events, seqs) => core.planToolResultImageShadows(events, seqs, () => true),
|
|
341
|
+
)
|
|
342
|
+
if (plans.length > 0) cachePendingRepairEvent(pendingToolRepairEvents, session, event)
|
|
343
|
+
} else if (event.type === 'user/message' && typeof core.planGuardStopShadows === 'function') {
|
|
344
|
+
const plans = planAgainstOneEvent(event, (events, seqs) => core.planGuardStopShadows(events, seqs))
|
|
345
|
+
if (plans.length > 0) cachePendingRepairEvent(pendingGuardRepairEvents, session, event)
|
|
346
|
+
}
|
|
347
|
+
repairFeedObserverWarnings.delete(session)
|
|
348
|
+
} catch (error) {
|
|
349
|
+
const message = boundedMessage(error)
|
|
350
|
+
if (repairFeedObserverWarnings.get(session) === message) return
|
|
351
|
+
repairFeedObserverWarnings.set(session, message)
|
|
352
|
+
logger?.warn?.('vision-router: session event feed observation failed error=%s', message)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const activateSurfaceEventFeed = () => {
|
|
357
|
+
surfaceEventFeedActive = true
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const consumePendingRepairEvents = (pendingBySession, session) => {
|
|
361
|
+
const pending = pendingBySession.get(session)
|
|
362
|
+
if (!pending || pending.size === 0) return []
|
|
363
|
+
const nodes = surfaceNodes(session)
|
|
364
|
+
if (!nodes || nodes.length === 0) {
|
|
365
|
+
pending.clear()
|
|
366
|
+
return []
|
|
367
|
+
}
|
|
368
|
+
const current = new Set(nodes)
|
|
369
|
+
for (const seq of pending.keys()) {
|
|
370
|
+
if (!current.has(seq)) pending.delete(seq)
|
|
371
|
+
}
|
|
372
|
+
const entries = []
|
|
373
|
+
for (const seq of nodes) {
|
|
374
|
+
const event = pending.get(seq)
|
|
375
|
+
if (event === undefined) continue
|
|
376
|
+
pending.delete(seq)
|
|
377
|
+
entries.push({ seq, event })
|
|
378
|
+
}
|
|
379
|
+
return entries
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const repairToolResultSurfaceFromFeed = async (session) => {
|
|
383
|
+
if (typeof session?.append !== 'function' || typeof core.planToolResultImageShadows !== 'function') return 0
|
|
384
|
+
const pending = consumePendingRepairEvents(pendingToolRepairEvents, session)
|
|
385
|
+
if (pending.length === 0) return 0
|
|
386
|
+
const preserve = keepToolResultImages()
|
|
387
|
+
let repaired = 0
|
|
388
|
+
for (const { seq, event } of pending) {
|
|
389
|
+
const events = { [seq]: event }
|
|
390
|
+
const plans = core.planToolResultImageShadows(events, [seq], () => !preserve)
|
|
391
|
+
for (const plan of plans) {
|
|
392
|
+
try {
|
|
393
|
+
const intent = replacementIntent(session, plan.seq)
|
|
394
|
+
if (intent === undefined) continue
|
|
395
|
+
await session.append(
|
|
396
|
+
'tool/result',
|
|
397
|
+
{ ...plan.event.data, message: plan.message },
|
|
398
|
+
intent,
|
|
399
|
+
)
|
|
400
|
+
repaired += 1
|
|
401
|
+
} catch (error) {
|
|
402
|
+
logger?.warn?.(
|
|
403
|
+
'vision-router: session tool-result surface repair failed seq=%s error=%s',
|
|
404
|
+
plan.seq,
|
|
405
|
+
boundedMessage(error),
|
|
406
|
+
)
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return repaired
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const repairGuardStopSurfaceFromFeed = async (session) => {
|
|
414
|
+
if (typeof session?.append !== 'function' || typeof core.planGuardStopShadows !== 'function') return 0
|
|
415
|
+
const pending = consumePendingRepairEvents(pendingGuardRepairEvents, session)
|
|
416
|
+
if (pending.length === 0) return 0
|
|
417
|
+
let repaired = 0
|
|
418
|
+
for (const { seq, event } of pending) {
|
|
419
|
+
const events = { [seq]: event }
|
|
420
|
+
const plans = core.planGuardStopShadows(events, [seq])
|
|
421
|
+
for (const plan of plans) {
|
|
422
|
+
try {
|
|
423
|
+
const intent = replacementIntent(session, plan.seq)
|
|
424
|
+
if (intent === undefined) continue
|
|
425
|
+
await session.append(
|
|
426
|
+
'user/message',
|
|
427
|
+
plan.data,
|
|
428
|
+
intent,
|
|
429
|
+
)
|
|
430
|
+
repaired += 1
|
|
431
|
+
} catch (error) {
|
|
432
|
+
logger?.warn?.(
|
|
433
|
+
'vision-router: session guard-stop surface repair failed seq=%s error=%s',
|
|
434
|
+
plan.seq,
|
|
435
|
+
boundedMessage(error),
|
|
436
|
+
)
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return repaired
|
|
441
|
+
}
|
|
442
|
+
|
|
247
443
|
const repairToolResultSurface = async (session) => {
|
|
444
|
+
if (surfaceEventFeedActive) return repairToolResultSurfaceFromFeed(session)
|
|
248
445
|
if (typeof session?.append !== 'function') return 0
|
|
249
446
|
const scan = pendingSurfaceScan(toolSurfaceScans, session)
|
|
250
447
|
if (!scan || typeof core.planToolResultImageShadows !== 'function') return 0
|
|
251
448
|
const preserve = keepToolResultImages()
|
|
449
|
+
const snapshot = await surfaceRepairSnapshot(session, scan.seqs[0])
|
|
450
|
+
if (snapshot?.readable === false) return 0
|
|
252
451
|
let repaired = 0
|
|
253
452
|
for (const seq of scan.seqs) {
|
|
254
|
-
const read =
|
|
453
|
+
const read = snapshot?.readable === true
|
|
454
|
+
? eventFromRepairSnapshot(session, snapshot.events, seq)
|
|
455
|
+
: await eventForSurfaceRepair(session, seq)
|
|
255
456
|
if (!read.readable) break
|
|
256
457
|
// A readable node is settled for this repair pass even when it needs no
|
|
257
458
|
// replacement or append later fails; only an unread node remains pending.
|
|
@@ -282,12 +483,17 @@ export function createSessionVisionIndex({
|
|
|
282
483
|
}
|
|
283
484
|
|
|
284
485
|
const repairGuardStopSurface = async (session) => {
|
|
486
|
+
if (surfaceEventFeedActive) return repairGuardStopSurfaceFromFeed(session)
|
|
285
487
|
if (typeof session?.append !== 'function') return 0
|
|
286
488
|
const scan = pendingSurfaceScan(guardSurfaceScans, session)
|
|
287
489
|
if (!scan || typeof core.planGuardStopShadows !== 'function') return 0
|
|
490
|
+
const snapshot = await surfaceRepairSnapshot(session, scan.seqs[0])
|
|
491
|
+
if (snapshot?.readable === false) return 0
|
|
288
492
|
let repaired = 0
|
|
289
493
|
for (const seq of scan.seqs) {
|
|
290
|
-
const read =
|
|
494
|
+
const read = snapshot?.readable === true
|
|
495
|
+
? eventFromRepairSnapshot(session, snapshot.events, seq)
|
|
496
|
+
: await eventForSurfaceRepair(session, seq)
|
|
291
497
|
if (!read.readable) break
|
|
292
498
|
scan.advance()
|
|
293
499
|
const events = []
|
|
@@ -333,6 +539,8 @@ export function createSessionVisionIndex({
|
|
|
333
539
|
|
|
334
540
|
return Object.freeze({
|
|
335
541
|
recordAttachments,
|
|
542
|
+
recordSessionEvent,
|
|
543
|
+
activateSurfaceEventFeed,
|
|
336
544
|
lookupAttachment,
|
|
337
545
|
resolveAttachment,
|
|
338
546
|
resolveAttachments,
|
|
@@ -344,8 +552,9 @@ export function createSessionVisionIndex({
|
|
|
344
552
|
|
|
345
553
|
/**
|
|
346
554
|
* Intercept core's pre-step registration and decorate only its downstream
|
|
347
|
-
* `next()` result. Production
|
|
348
|
-
*
|
|
555
|
+
* `next()` result. Production also subscribes the index to DSH's post-commit
|
|
556
|
+
* `session/event` feed before activating feed-backed surface repair. Direct/test
|
|
557
|
+
* callers without a usable event feed retain the compatibility reader path.
|
|
349
558
|
*/
|
|
350
559
|
export function installSessionVisionIndexBoundary(ctx, config, core, options = {}) {
|
|
351
560
|
if (!isObject(ctx)) return ctx
|
|
@@ -363,6 +572,25 @@ export function installSessionVisionIndexBoundary(ctx, config, core, options = {
|
|
|
363
572
|
logger: options.logger,
|
|
364
573
|
})
|
|
365
574
|
|
|
575
|
+
try {
|
|
576
|
+
const on = Reflect.get(ctx, 'on', ctx)
|
|
577
|
+
if (
|
|
578
|
+
typeof on === 'function'
|
|
579
|
+
&& typeof index.recordSessionEvent === 'function'
|
|
580
|
+
&& typeof index.activateSurfaceEventFeed === 'function'
|
|
581
|
+
) {
|
|
582
|
+
on.call(ctx, 'session/event', (session, event) => {
|
|
583
|
+
index.recordSessionEvent(session, event)
|
|
584
|
+
})
|
|
585
|
+
index.activateSurfaceEventFeed()
|
|
586
|
+
}
|
|
587
|
+
} catch (error) {
|
|
588
|
+
options.logger?.warn?.(
|
|
589
|
+
'vision-router: session event feed unavailable; retaining compatibility surface reader error=%s',
|
|
590
|
+
boundedMessage(error),
|
|
591
|
+
)
|
|
592
|
+
}
|
|
593
|
+
|
|
366
594
|
return new Proxy(ctx, {
|
|
367
595
|
get(target, property) {
|
|
368
596
|
if (property === 'on') {
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { htmlHasScriptMarker } from './html-script-marker.js'
|
|
2
|
+
import { LOCAL_SETTINGS_PATH } from './dsh-settings-017-compat.js'
|
|
3
|
+
|
|
4
|
+
const SETTINGS_017_MARK = 'data-vision-router-settings-017-compat'
|
|
5
|
+
|
|
6
|
+
export const SETTINGS_017_CLIENT_PRELUDE = String.raw`(function(){
|
|
7
|
+
'use strict';
|
|
8
|
+
var TARGET = 'dsh-vision-router';
|
|
9
|
+
var ENDPOINT = '${LOCAL_SETTINGS_PATH}';
|
|
10
|
+
var contextCache = typeof WeakMap === 'function' ? new WeakMap() : undefined;
|
|
11
|
+
var scopeCache = typeof WeakMap === 'function' ? new WeakMap() : undefined;
|
|
12
|
+
var binderCache = typeof WeakMap === 'function' ? new WeakMap() : undefined;
|
|
13
|
+
var connectionCache = typeof WeakMap === 'function' ? new WeakMap() : undefined;
|
|
14
|
+
|
|
15
|
+
function safeGet(ctx, name) {
|
|
16
|
+
if (!ctx) return undefined;
|
|
17
|
+
try {
|
|
18
|
+
if (typeof ctx.get === 'function') {
|
|
19
|
+
var value = ctx.get(name);
|
|
20
|
+
if (value !== undefined && value !== null) return value;
|
|
21
|
+
}
|
|
22
|
+
} catch (_) {}
|
|
23
|
+
try { return ctx[name]; } catch (_) { return undefined; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isLoopbackLocation(locationLike) {
|
|
27
|
+
var hostname = locationLike && typeof locationLike.hostname === 'string'
|
|
28
|
+
? locationLike.hostname.toLowerCase().replace(/^\[|\]$/g, '')
|
|
29
|
+
: '';
|
|
30
|
+
if (hostname === 'localhost' || hostname.endsWith('.localhost') || hostname === '::1') return true;
|
|
31
|
+
return /^127(?:\.\d{1,3}){3}$/.test(hostname);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeConnection(connection) {
|
|
35
|
+
if (!connection || typeof connection !== 'object') return connection;
|
|
36
|
+
var locationLike;
|
|
37
|
+
try { locationLike = window && window.location; } catch (_) { locationLike = undefined; }
|
|
38
|
+
if (!isLoopbackLocation(locationLike) || connection.isLoopback !== false) return connection;
|
|
39
|
+
if (connectionCache && connectionCache.has(connection)) return connectionCache.get(connection);
|
|
40
|
+
var wrapped = new Proxy(connection, {
|
|
41
|
+
get: function(target, property) {
|
|
42
|
+
if (property === 'isLoopback') return true;
|
|
43
|
+
var value = Reflect.get(target, property, target);
|
|
44
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
if (connectionCache) connectionCache.set(connection, wrapped);
|
|
48
|
+
return wrapped;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function errorOf(value, fallback) {
|
|
52
|
+
var detail = value && value.error;
|
|
53
|
+
var error = new Error(detail && detail.message ? detail.message : fallback);
|
|
54
|
+
if (detail && detail.code) error.code = detail.code;
|
|
55
|
+
if (detail && detail.details !== undefined) error.details = detail.details;
|
|
56
|
+
return error;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function createScope() {
|
|
60
|
+
var listeners = new Set();
|
|
61
|
+
var writeTail = Promise.resolve();
|
|
62
|
+
var inFlight;
|
|
63
|
+
var snapshot = Object.freeze({
|
|
64
|
+
status: 'loading', value: undefined, base: undefined, user: undefined,
|
|
65
|
+
revision: undefined, writable: false, mode: 'host'
|
|
66
|
+
});
|
|
67
|
+
function publish(next) {
|
|
68
|
+
snapshot = Object.freeze(next);
|
|
69
|
+
listeners.forEach(function(listener){ try { listener(); } catch (_) {} });
|
|
70
|
+
}
|
|
71
|
+
function accept(view) {
|
|
72
|
+
if (!view || !view.value || typeof view.value !== 'object' || Array.isArray(view.value)
|
|
73
|
+
|| !Number.isInteger(view.revision) || view.revision < 0) {
|
|
74
|
+
throw new Error('Vision Router local settings returned an invalid view');
|
|
75
|
+
}
|
|
76
|
+
publish({
|
|
77
|
+
status: 'ready', value: view.value, base: view.base, user: view.user,
|
|
78
|
+
revision: view.revision, writable: view.writable === true, mode: 'host'
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
async function request(method, payload) {
|
|
82
|
+
if (typeof fetch !== 'function') throw new Error('Vision Router local settings transport is unavailable');
|
|
83
|
+
var response = await fetch(ENDPOINT, {
|
|
84
|
+
method: method,
|
|
85
|
+
headers: { accept: 'application/json', ...(method === 'POST' ? { 'content-type': 'application/json' } : {}) },
|
|
86
|
+
cache: 'no-store',
|
|
87
|
+
credentials: 'same-origin',
|
|
88
|
+
...(method === 'POST' ? { body: JSON.stringify(payload) } : {})
|
|
89
|
+
});
|
|
90
|
+
var body;
|
|
91
|
+
try { body = await response.json(); } catch (_) { body = undefined; }
|
|
92
|
+
if (!response.ok || !body || body.ok !== true) throw errorOf(body, 'Vision Router local settings request failed');
|
|
93
|
+
return body.value;
|
|
94
|
+
}
|
|
95
|
+
function load(restart) {
|
|
96
|
+
if (!restart && inFlight) return inFlight;
|
|
97
|
+
var task = request('GET').then(accept, function(error){
|
|
98
|
+
publish({
|
|
99
|
+
status: 'unavailable', value: undefined, base: undefined, user: undefined,
|
|
100
|
+
revision: undefined, writable: false, mode: 'host', error: error && error.message ? error.message : String(error)
|
|
101
|
+
});
|
|
102
|
+
throw error;
|
|
103
|
+
});
|
|
104
|
+
var held = task.finally(function(){ if (inFlight === held) inFlight = undefined; });
|
|
105
|
+
inFlight = held;
|
|
106
|
+
return held;
|
|
107
|
+
}
|
|
108
|
+
function writeOps(ops) {
|
|
109
|
+
if (!Array.isArray(ops) || ops.length === 0) return Promise.reject(new TypeError('settings operations must be a non-empty array'));
|
|
110
|
+
var task = writeTail.then(async function(){
|
|
111
|
+
if (snapshot.status !== 'ready' || !Number.isInteger(snapshot.revision)) await load(true);
|
|
112
|
+
if (snapshot.status !== 'ready' || !Number.isInteger(snapshot.revision)) throw new Error('Vision Router local settings are not ready');
|
|
113
|
+
if (!snapshot.writable) throw new Error('Vision Router settings provider is read-only');
|
|
114
|
+
try {
|
|
115
|
+
// One ConfigEditor edit may reload DVR. Keep all fields from one UI
|
|
116
|
+
// Save inside that single Host transaction so a second request cannot
|
|
117
|
+
// get stranded between plugin generations.
|
|
118
|
+
var view = await request('POST', { ops: ops, expectedRevision: snapshot.revision });
|
|
119
|
+
accept(view);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
try { await load(true); } catch (_) {}
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
writeTail = task.catch(function(){});
|
|
126
|
+
return task;
|
|
127
|
+
}
|
|
128
|
+
function planOps(items) {
|
|
129
|
+
if (!Array.isArray(items) || items.length === 0) throw new TypeError('settings plan must be a non-empty array');
|
|
130
|
+
return items.map(function(item){
|
|
131
|
+
if (!item || typeof item.key !== 'string' || item.key.length === 0 || !item.run) {
|
|
132
|
+
throw new TypeError('settings plan item is invalid');
|
|
133
|
+
}
|
|
134
|
+
return item.run.clear
|
|
135
|
+
? { op: 'unset', path: [item.key] }
|
|
136
|
+
: { op: 'set', path: [item.key], value: item.run.value };
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
var scope = {
|
|
140
|
+
getSnapshot: function(){ return snapshot; },
|
|
141
|
+
subscribe: function(listener){
|
|
142
|
+
if (typeof listener !== 'function') return function(){};
|
|
143
|
+
listeners.add(listener);
|
|
144
|
+
return function(){ listeners.delete(listener); };
|
|
145
|
+
},
|
|
146
|
+
load: function(){ return load(false); },
|
|
147
|
+
reload: function(){ return load(true); },
|
|
148
|
+
set: function(field, value){ return writeOps([{ op: 'set', path: [field], value: value }]); },
|
|
149
|
+
unset: function(field){ return writeOps([{ op: 'unset', path: [field] }]); },
|
|
150
|
+
__visionRouterWritePlan: function(items){ return writeOps(planOps(items)); },
|
|
151
|
+
dispose: async function(){ listeners.clear(); await Promise.allSettled([inFlight, writeTail].filter(Boolean)); }
|
|
152
|
+
};
|
|
153
|
+
void load(false).catch(function(){});
|
|
154
|
+
return scope;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function scopeFor(ctx) {
|
|
158
|
+
if (scopeCache && scopeCache.has(ctx)) return scopeCache.get(ctx);
|
|
159
|
+
var scope = createScope();
|
|
160
|
+
if (scopeCache) scopeCache.set(ctx, scope);
|
|
161
|
+
return scope;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function syntheticBinder(ctx, original) {
|
|
165
|
+
return {
|
|
166
|
+
bind: function(spec) {
|
|
167
|
+
if (spec && spec.namespace === 'vision-router') return scopeFor(ctx);
|
|
168
|
+
if (original && typeof original.bind === 'function') return original.bind(spec);
|
|
169
|
+
throw new Error('Settings scope ' + String(spec && spec.namespace) + ' is unavailable');
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function configFormsBinder(ctx) {
|
|
175
|
+
var forms = safeGet(ctx, 'configForms');
|
|
176
|
+
if (!forms || typeof forms.get !== 'function') return undefined;
|
|
177
|
+
if (binderCache && binderCache.has(forms)) return binderCache.get(forms);
|
|
178
|
+
var binder = {
|
|
179
|
+
bind: function(spec) {
|
|
180
|
+
var namespace = spec && spec.namespace;
|
|
181
|
+
if (typeof namespace !== 'string' || namespace.length === 0) {
|
|
182
|
+
throw new TypeError('settings namespace must be a non-empty string');
|
|
183
|
+
}
|
|
184
|
+
return forms.get(namespace);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
if (binderCache) binderCache.set(forms, binder);
|
|
188
|
+
return binder;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function wrapContext(ctx) {
|
|
192
|
+
if (!ctx || typeof ctx !== 'object') return ctx;
|
|
193
|
+
if (contextCache && contextCache.has(ctx)) return contextCache.get(ctx);
|
|
194
|
+
var wrapped = new Proxy(ctx, {
|
|
195
|
+
get: function(target, property) {
|
|
196
|
+
if (property === 'settingsScope') {
|
|
197
|
+
var original = safeGet(target, 'settingsScope');
|
|
198
|
+
if (original && typeof original.bind === 'function') return original;
|
|
199
|
+
// DSH 0.1.7 configForms only mirrors Config fields declared volatile.
|
|
200
|
+
// DVR must keep its public Config ordinary for the older supported Host
|
|
201
|
+
// window, so its namespace intentionally uses the local-only ConfigEditor
|
|
202
|
+
// bridge while other namespaces may still delegate to native configForms.
|
|
203
|
+
var official = configFormsBinder(target);
|
|
204
|
+
return syntheticBinder(ctx, official);
|
|
205
|
+
}
|
|
206
|
+
if (property === 'get') {
|
|
207
|
+
var getter = Reflect.get(target, property, target);
|
|
208
|
+
if (typeof getter !== 'function') return getter;
|
|
209
|
+
return function(name) {
|
|
210
|
+
var value = getter.call(target, name);
|
|
211
|
+
return name === 'connection' ? normalizeConnection(value) : value;
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
var value = Reflect.get(target, property, target);
|
|
215
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
if (contextCache) contextCache.set(ctx, wrapped);
|
|
219
|
+
return wrapped;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function requireConfigForms(plugin) {
|
|
223
|
+
if (!plugin || !Array.isArray(plugin.inject)) return plugin;
|
|
224
|
+
var inject = [];
|
|
225
|
+
var inserted = false;
|
|
226
|
+
for (var index = 0; index < plugin.inject.length; index += 1) {
|
|
227
|
+
var service = plugin.inject[index];
|
|
228
|
+
if (service === 'settingsScope') {
|
|
229
|
+
if (!inserted) {
|
|
230
|
+
inject.push('configForms');
|
|
231
|
+
inserted = true;
|
|
232
|
+
}
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (service === 'configForms') inserted = true;
|
|
236
|
+
if (inject.indexOf(service) === -1) inject.push(service);
|
|
237
|
+
}
|
|
238
|
+
// Another loader compatibility layer may already have removed the legacy
|
|
239
|
+
// dependency before this factory runs. DSH 0.1.7 still requires the native
|
|
240
|
+
// configForms service to be activation-ready before Vision Router applies.
|
|
241
|
+
if (!inserted) inject.unshift('configForms');
|
|
242
|
+
try {
|
|
243
|
+
plugin.inject = inject;
|
|
244
|
+
return plugin;
|
|
245
|
+
} catch (_) {
|
|
246
|
+
return Object.assign({}, plugin, { inject: inject });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function patchLoader(loader) {
|
|
251
|
+
if (!loader || typeof loader.load !== 'function' || loader.load.__visionRouterSettings017Compat) return;
|
|
252
|
+
var original = loader.load;
|
|
253
|
+
function load(spec) {
|
|
254
|
+
if (spec && spec.id === TARGET && typeof spec.factory === 'function') {
|
|
255
|
+
var factory = spec.factory;
|
|
256
|
+
spec = Object.assign({}, spec, {
|
|
257
|
+
factory: function(require) {
|
|
258
|
+
var plugin = requireConfigForms(factory(require));
|
|
259
|
+
if (plugin && typeof plugin.apply === 'function' && !plugin.apply.__visionRouterSettings017Compat) {
|
|
260
|
+
var apply = plugin.apply;
|
|
261
|
+
var wrappedApply = function(ctx) {
|
|
262
|
+
var rest = Array.prototype.slice.call(arguments, 1);
|
|
263
|
+
return apply.apply(plugin, [wrapContext(ctx)].concat(rest));
|
|
264
|
+
};
|
|
265
|
+
Object.defineProperty(wrappedApply, '__visionRouterSettings017Compat', { value: true });
|
|
266
|
+
plugin.apply = wrappedApply;
|
|
267
|
+
}
|
|
268
|
+
return plugin;
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
return original.call(this, spec);
|
|
273
|
+
}
|
|
274
|
+
Object.defineProperty(load, '__visionRouterSettings017Compat', { value: true });
|
|
275
|
+
loader.load = load;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function patchCreate(loader) {
|
|
279
|
+
if (!loader || typeof loader.create !== 'function' || loader.create.__visionRouterSettings017Compat) return;
|
|
280
|
+
var original = loader.create;
|
|
281
|
+
function create() {
|
|
282
|
+
var result = original.apply(this, arguments);
|
|
283
|
+
patchLoader(loader);
|
|
284
|
+
if (result && result !== loader) patchLoader(result);
|
|
285
|
+
return result;
|
|
286
|
+
}
|
|
287
|
+
Object.defineProperty(create, '__visionRouterSettings017Compat', { value: true });
|
|
288
|
+
loader.create = create;
|
|
289
|
+
if (loader.mode === 'live') patchLoader(loader);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function install() {
|
|
293
|
+
var descriptor = Object.getOwnPropertyDescriptor(window, '__ModuleLoader__');
|
|
294
|
+
var stored = window.__ModuleLoader__;
|
|
295
|
+
if (stored) patchCreate(stored);
|
|
296
|
+
if (descriptor && descriptor.configurable === false) return;
|
|
297
|
+
var previousGet = descriptor && descriptor.get;
|
|
298
|
+
var previousSet = descriptor && descriptor.set;
|
|
299
|
+
Object.defineProperty(window, '__ModuleLoader__', {
|
|
300
|
+
configurable: true,
|
|
301
|
+
enumerable: !descriptor || descriptor.enumerable !== false,
|
|
302
|
+
get: function(){ return previousGet ? previousGet.call(window) : stored; },
|
|
303
|
+
set: function(value) {
|
|
304
|
+
if (previousSet) previousSet.call(window, value); else stored = value;
|
|
305
|
+
try { patchCreate(previousGet ? previousGet.call(window) : value); } catch (_) {}
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
try { install(); } catch (_) {}
|
|
311
|
+
})();`
|
|
312
|
+
|
|
313
|
+
export function injectSettings017ClientPrelude(html) {
|
|
314
|
+
if (typeof html !== 'string' || htmlHasScriptMarker(html, SETTINGS_017_MARK)) return html
|
|
315
|
+
const safe = SETTINGS_017_CLIENT_PRELUDE.replace(/<\/script/gi, '<\\/script')
|
|
316
|
+
const script = `<script ${SETTINGS_017_MARK}>${safe}</script>`
|
|
317
|
+
const closeHead = html.indexOf('</head>')
|
|
318
|
+
return closeHead === -1 ? `${html}${script}` : `${html.slice(0, closeHead)}${script}${html.slice(closeHead)}`
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function installSettings017ClientCompatibility(ctx) {
|
|
322
|
+
if (!ctx || typeof ctx.inject !== 'function') return
|
|
323
|
+
// ConfigEditor is the generation fence: old supported Hosts keep their native
|
|
324
|
+
// settingsScope activation contract and never see this client shim.
|
|
325
|
+
ctx.inject(['configEditor', 'webServer'], (webCtx) => {
|
|
326
|
+
webCtx.effect(
|
|
327
|
+
() => webCtx.webServer.tapIndex(injectSettings017ClientPrelude),
|
|
328
|
+
'vision-router: DSH 0.1.7 settings client compatibility',
|
|
329
|
+
)
|
|
330
|
+
})
|
|
331
|
+
}
|