dsh-vision-router 2.2.1 → 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/docs/releases/v2.2.2.md +21 -0
- package/lib/client.js +67 -27
- package/lib/dsh-settings-017-compat.js +416 -0
- package/lib/public-entry.js +21 -1
- package/lib/session-message-source-compat.js +92 -0
- package/lib/settings-client-017-compat.js +331 -0
- package/lib/settings-client-rc8-lifecycle.js +13 -0
- package/lib/web/dsh-settings-017-root-transport.js +299 -0
- package/lib/web/remote-settings-client.js +200 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -31,6 +31,14 @@ export const SETTINGS_RC8_CLIENT_PRELUDE = String.raw`(function(){
|
|
|
31
31
|
return undefined;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
function isLoopbackLocation(locationLike) {
|
|
35
|
+
var hostname = locationLike && typeof locationLike.hostname === 'string'
|
|
36
|
+
? locationLike.hostname.toLowerCase().replace(/^\[|\]$/g, '')
|
|
37
|
+
: '';
|
|
38
|
+
if (hostname === 'localhost' || hostname.endsWith('.localhost') || hostname === '::1') return true;
|
|
39
|
+
return /^127(?:\.\d{1,3}){3}$/.test(hostname);
|
|
40
|
+
}
|
|
41
|
+
|
|
34
42
|
async function writePermission(operation, value, revision) {
|
|
35
43
|
if (typeof fetch !== 'function') throw new Error('Vision Router local settings transport is unavailable');
|
|
36
44
|
var payload = { operation: operation };
|
|
@@ -269,6 +277,11 @@ export const SETTINGS_RC8_CLIENT_PRELUDE = String.raw`(function(){
|
|
|
269
277
|
if (connectionCache && connectionCache.has(connection)) return connectionCache.get(connection);
|
|
270
278
|
var wrapped = new Proxy(connection, {
|
|
271
279
|
get: function(target, property) {
|
|
280
|
+
if (property === 'isLoopback') {
|
|
281
|
+
var locationLike;
|
|
282
|
+
try { locationLike = window && window.location; } catch (_) { locationLike = undefined; }
|
|
283
|
+
if (isLoopbackLocation(locationLike)) return true;
|
|
284
|
+
}
|
|
272
285
|
if (property === 'rpc') return wrapRpc(Reflect.get(target, property, target));
|
|
273
286
|
var value = Reflect.get(target, property, target);
|
|
274
287
|
return typeof value === 'function' ? value.bind(target) : value;
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DSH_017_SETTINGS_COMPAT_MARK,
|
|
3
|
+
LOCAL_SETTINGS_PATH,
|
|
4
|
+
} from '../dsh-settings-017-compat.js'
|
|
5
|
+
|
|
6
|
+
const BODY_LIMIT_BYTES = 256 * 1024
|
|
7
|
+
const ROOT_TRANSPORT_REGISTRY_KEY = Symbol.for('dsh-vision-router.settings-017-root-local-transport')
|
|
8
|
+
const SLOW_MUTATION_MS = 5_000
|
|
9
|
+
const TRACE_ENV = 'DVR_SETTINGS_017_TRACE'
|
|
10
|
+
|
|
11
|
+
function objectLike(value) {
|
|
12
|
+
return value !== null && typeof value === 'object'
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function trace(stage, detail) {
|
|
16
|
+
if (process.env[TRACE_ENV] !== '1') return
|
|
17
|
+
try {
|
|
18
|
+
console.warn(`vision-router: DSH 0.1.7 settings trace ${stage}${detail === undefined ? '' : ` ${JSON.stringify(detail)}`}`)
|
|
19
|
+
} catch {}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function transportRegistry() {
|
|
23
|
+
let registry = globalThis[ROOT_TRANSPORT_REGISTRY_KEY]
|
|
24
|
+
if (!(registry instanceof WeakMap)) {
|
|
25
|
+
registry = new WeakMap()
|
|
26
|
+
Object.defineProperty(globalThis, ROOT_TRANSPORT_REGISTRY_KEY, {
|
|
27
|
+
value: registry,
|
|
28
|
+
configurable: true,
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
return registry
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function rootOf(ctx) {
|
|
35
|
+
try {
|
|
36
|
+
if (objectLike(ctx?.root)) return ctx.root
|
|
37
|
+
} catch {}
|
|
38
|
+
return ctx
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function stateFor(root) {
|
|
42
|
+
const registry = transportRegistry()
|
|
43
|
+
let state = registry.get(root)
|
|
44
|
+
if (!state) {
|
|
45
|
+
state = {
|
|
46
|
+
current: undefined,
|
|
47
|
+
routeOwner: undefined,
|
|
48
|
+
routeInstalling: false,
|
|
49
|
+
generationSequence: 0,
|
|
50
|
+
requestSequence: 0,
|
|
51
|
+
}
|
|
52
|
+
registry.set(root, state)
|
|
53
|
+
}
|
|
54
|
+
return state
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function serviceOf(ctx, name) {
|
|
58
|
+
try {
|
|
59
|
+
const value = typeof ctx?.get === 'function' ? ctx.get(name) : undefined
|
|
60
|
+
if (value !== undefined && value !== null) return value
|
|
61
|
+
} catch {}
|
|
62
|
+
try {
|
|
63
|
+
const value = ctx?.[name]
|
|
64
|
+
return value === undefined || value === null ? undefined : value
|
|
65
|
+
} catch {
|
|
66
|
+
return undefined
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function compatibleSettings(ctx) {
|
|
71
|
+
const settings = serviceOf(ctx, 'settings')
|
|
72
|
+
if (settings?.[DSH_017_SETTINGS_COMPAT_MARK] !== true) return undefined
|
|
73
|
+
if (typeof settings.describe !== 'function' || typeof settings.mutate !== 'function') return undefined
|
|
74
|
+
return settings
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function namespaceDescriptor(settings) {
|
|
78
|
+
const descriptors = settings.describe({ redactSecrets: true })
|
|
79
|
+
return Array.isArray(descriptors) ? descriptors.find((entry) => entry?.ns === 'vision-router') : undefined
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function sendJson(res, status, body) {
|
|
83
|
+
res.writeHead(status, {
|
|
84
|
+
'content-type': 'application/json; charset=utf-8',
|
|
85
|
+
'cache-control': 'no-store',
|
|
86
|
+
})
|
|
87
|
+
res.end(JSON.stringify(body))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function readJson(req) {
|
|
91
|
+
let size = 0
|
|
92
|
+
const chunks = []
|
|
93
|
+
for await (const chunk of req) {
|
|
94
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
95
|
+
size += bytes.length
|
|
96
|
+
if (size > BODY_LIMIT_BYTES) throw Object.assign(new Error('request body too large'), { statusCode: 413 })
|
|
97
|
+
chunks.push(bytes)
|
|
98
|
+
}
|
|
99
|
+
if (chunks.length === 0) return {}
|
|
100
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function currentSettings(state) {
|
|
104
|
+
const generation = state.current
|
|
105
|
+
if (!generation || generation.active === false) return undefined
|
|
106
|
+
try {
|
|
107
|
+
return generation.resolve()
|
|
108
|
+
} catch {
|
|
109
|
+
return undefined
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function flattenEffectLabels(effects) {
|
|
114
|
+
const labels = []
|
|
115
|
+
const visit = (rows) => {
|
|
116
|
+
if (!Array.isArray(rows)) return
|
|
117
|
+
for (const row of rows) {
|
|
118
|
+
if (!row || typeof row !== 'object') continue
|
|
119
|
+
if (typeof row.label === 'string') labels.push(row.label)
|
|
120
|
+
visit(row.children)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
visit(effects)
|
|
124
|
+
return labels
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function slowMutationSnapshot(root) {
|
|
128
|
+
const loader = serviceOf(root, 'loader')
|
|
129
|
+
let entries = []
|
|
130
|
+
try { entries = typeof loader?.entries === 'function' ? [...loader.entries()] : [] } catch {}
|
|
131
|
+
return entries.flatMap((entry) => {
|
|
132
|
+
const fiber = entry?.fiber
|
|
133
|
+
if (!fiber || (entry?.options?.id !== 'vision-router' && !fiber.inertia)) return []
|
|
134
|
+
let effects = []
|
|
135
|
+
try { effects = typeof fiber.getEffects === 'function' ? flattenEffectLabels(fiber.getEffects()) : [] } catch {}
|
|
136
|
+
return [{
|
|
137
|
+
id: entry?.options?.id,
|
|
138
|
+
state: fiber.state,
|
|
139
|
+
inertia: Boolean(fiber.inertia),
|
|
140
|
+
effects,
|
|
141
|
+
}]
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function beginSlowMutationWatch(root, requestId) {
|
|
146
|
+
return setTimeout(() => {
|
|
147
|
+
const snapshot = slowMutationSnapshot(root)
|
|
148
|
+
const message = `vision-router: DSH 0.1.7 settings mutation still reconciling after ${SLOW_MUTATION_MS}ms request=${requestId} ${JSON.stringify(snapshot)}`
|
|
149
|
+
try {
|
|
150
|
+
if (root?.logger && typeof root.logger.warn === 'function') root.logger.warn(message)
|
|
151
|
+
else console.warn(message)
|
|
152
|
+
} catch {
|
|
153
|
+
try { console.warn(message) } catch {}
|
|
154
|
+
}
|
|
155
|
+
}, SLOW_MUTATION_MS)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function routeFor(root, state) {
|
|
159
|
+
return {
|
|
160
|
+
kind: 'exact',
|
|
161
|
+
path: LOCAL_SETTINGS_PATH,
|
|
162
|
+
async handler(req, res) {
|
|
163
|
+
const requestId = ++state.requestSequence
|
|
164
|
+
trace('handler-enter', { requestId, method: req.method, generation: state.current?.id })
|
|
165
|
+
const settings = currentSettings(state)
|
|
166
|
+
if (!settings) {
|
|
167
|
+
trace('settings-unavailable', { requestId })
|
|
168
|
+
sendJson(res, 404, { ok: false, error: { code: 'settings-unavailable', message: 'Vision Router settings are unavailable' } })
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (req.method === 'GET') {
|
|
173
|
+
const descriptor = namespaceDescriptor(settings)
|
|
174
|
+
if (!descriptor) {
|
|
175
|
+
sendJson(res, 404, { ok: false, error: { code: 'settings-unavailable', message: 'Vision Router settings are unavailable' } })
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
sendJson(res, 200, { ok: true, value: { ...descriptor, writable: settings.writable === true } })
|
|
179
|
+
trace('response', { requestId, status: 200, revision: descriptor.revision })
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (req.method !== 'POST') {
|
|
184
|
+
res.setHeader('Allow', 'GET, POST')
|
|
185
|
+
sendJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'method not allowed' } })
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
let slowTimer
|
|
190
|
+
try {
|
|
191
|
+
trace('body-read-start', { requestId })
|
|
192
|
+
const payload = await readJson(req)
|
|
193
|
+
trace('body-read-done', { requestId, expectedRevision: payload?.expectedRevision, ops: payload?.ops?.length })
|
|
194
|
+
slowTimer = beginSlowMutationWatch(root, requestId)
|
|
195
|
+
trace('mutate-start', { requestId, generation: state.current?.id })
|
|
196
|
+
await settings.mutate('vision-router', payload?.ops, payload?.expectedRevision)
|
|
197
|
+
trace('mutate-done', { requestId, generation: state.current?.id })
|
|
198
|
+
|
|
199
|
+
// ConfigEditor.edit() reconciles the profile and can dispose the plugin
|
|
200
|
+
// generation that accepted this request. The route itself is owned by a
|
|
201
|
+
// root child fiber that depends only on WebServer, so finish the response
|
|
202
|
+
// from the newly-mounted DVR generation when present.
|
|
203
|
+
const afterSettings = currentSettings(state) ?? settings
|
|
204
|
+
const descriptor = namespaceDescriptor(afterSettings)
|
|
205
|
+
if (!descriptor) throw new Error('Vision Router settings disappeared after the write')
|
|
206
|
+
sendJson(res, 200, { ok: true, value: { ...descriptor, writable: afterSettings.writable === true } })
|
|
207
|
+
trace('response', { requestId, status: 200, revision: descriptor.revision })
|
|
208
|
+
} catch (error) {
|
|
209
|
+
const conflict = error?.code === 'SETTINGS_CONFLICT'
|
|
210
|
+
const status = error?.statusCode ?? (conflict ? 409 : 400)
|
|
211
|
+
trace('response-error', { requestId, status, code: error?.code, message: error?.message ?? String(error) })
|
|
212
|
+
sendJson(res, status, {
|
|
213
|
+
ok: false,
|
|
214
|
+
error: {
|
|
215
|
+
code: conflict ? 'settings-conflict' : 'settings-rejected',
|
|
216
|
+
message: error?.message ?? String(error),
|
|
217
|
+
...(conflict ? { details: { expected: error.expected, actual: error.actual } } : {}),
|
|
218
|
+
},
|
|
219
|
+
})
|
|
220
|
+
} finally {
|
|
221
|
+
if (slowTimer) clearTimeout(slowTimer)
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function ensureRootRoute(root, state) {
|
|
228
|
+
if (state.routeOwner || state.routeInstalling) return
|
|
229
|
+
if (typeof root?.inject !== 'function') {
|
|
230
|
+
throw new Error('Vision Router requires root dependency injection for the DSH 0.1.7 local settings route')
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
state.routeInstalling = true
|
|
234
|
+
try {
|
|
235
|
+
const owner = root.inject(['webServer'], (webCtx) => {
|
|
236
|
+
if (!webCtx?.webServer || typeof webCtx.webServer.register !== 'function' || typeof webCtx.effect !== 'function') {
|
|
237
|
+
throw new Error('Vision Router requires WebServer route registration on DSH 0.1.7')
|
|
238
|
+
}
|
|
239
|
+
trace('route-register', {})
|
|
240
|
+
webCtx.effect(
|
|
241
|
+
() => {
|
|
242
|
+
const dispose = webCtx.webServer.register(routeFor(root, state))
|
|
243
|
+
return () => {
|
|
244
|
+
trace('route-dispose', {})
|
|
245
|
+
dispose()
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
'vision-router: DSH 0.1.7 root local settings transport',
|
|
249
|
+
)
|
|
250
|
+
})
|
|
251
|
+
// `root.inject()` creates a root child fiber. It is independent from the
|
|
252
|
+
// DVR plugin fiber and therefore survives ConfigEditor reconciliation of
|
|
253
|
+
// the `vision-router` entry. Keep the owner reachable for the process life.
|
|
254
|
+
state.routeOwner = owner ?? true
|
|
255
|
+
} catch (error) {
|
|
256
|
+
state.routeOwner = undefined
|
|
257
|
+
throw error
|
|
258
|
+
} finally {
|
|
259
|
+
state.routeInstalling = false
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* ConfigEditor.edit() reconciles the plugin configuration and therefore disposes
|
|
265
|
+
* the DVR plugin fiber while a Settings POST is still in flight. A dedicated
|
|
266
|
+
* root child fiber owns the HTTP route and depends only on WebServer; individual
|
|
267
|
+
* DVR generations only publish a resolver for their current settings facade.
|
|
268
|
+
*/
|
|
269
|
+
export function installDsh017RootLocalSettingsTransport(ctx) {
|
|
270
|
+
if (!ctx || typeof ctx.inject !== 'function') return
|
|
271
|
+
const root = rootOf(ctx)
|
|
272
|
+
if (!objectLike(root)) return
|
|
273
|
+
const state = stateFor(root)
|
|
274
|
+
|
|
275
|
+
ctx.inject(['settings'], (settingsCtx) => {
|
|
276
|
+
const mounted = compatibleSettings(settingsCtx)
|
|
277
|
+
if (!mounted) return
|
|
278
|
+
|
|
279
|
+
const generation = {
|
|
280
|
+
id: ++state.generationSequence,
|
|
281
|
+
active: true,
|
|
282
|
+
resolve: () => compatibleSettings(ctx) ?? mounted,
|
|
283
|
+
}
|
|
284
|
+
state.current = generation
|
|
285
|
+
trace('generation-mount', { generation: generation.id })
|
|
286
|
+
ensureRootRoute(root, state)
|
|
287
|
+
|
|
288
|
+
if (typeof settingsCtx?.effect === 'function') {
|
|
289
|
+
settingsCtx.effect(
|
|
290
|
+
() => () => {
|
|
291
|
+
generation.active = false
|
|
292
|
+
if (state.current === generation) state.current = undefined
|
|
293
|
+
trace('generation-dispose', { generation: generation.id })
|
|
294
|
+
},
|
|
295
|
+
'vision-router: DSH 0.1.7 settings generation',
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
})
|
|
299
|
+
}
|