dsh-zen-remote 2.0.3 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +15 -1
- package/README.md +11 -5
- package/README.zh-CN.md +11 -5
- package/docs/adr/0001-plugin-bridge-forwards-plugin-routes.md +15 -0
- package/docs/adr/0002-relay-emulates-websocket.md +8 -0
- package/docs/interface.md +3 -1
- package/docs/plugin-bridge.md +140 -0
- package/docs/remote-access.en.md +57 -0
- package/docs/remote-access.md +57 -0
- package/lib/adapters/dsh-better-sidebar/index.js +144 -0
- package/lib/adapters/dsh-better-sidebar/index.js.map +1 -0
- package/lib/adapters/dsh-better-sidebar/methods.js +198 -0
- package/lib/adapters/dsh-better-sidebar/methods.js.map +1 -0
- package/lib/adapters/index.js +17 -0
- package/lib/adapters/index.js.map +1 -0
- package/lib/adapters/types.js +17 -0
- package/lib/adapters/types.js.map +1 -0
- package/lib/admin-routes.js +51 -0
- package/lib/admin-routes.js.map +1 -1
- package/lib/client-routes.js +18 -1
- package/lib/client-routes.js.map +1 -1
- package/lib/client.js +449 -45
- package/lib/config.js +27 -0
- package/lib/config.js.map +1 -1
- package/lib/http.js +10 -0
- package/lib/http.js.map +1 -1
- package/lib/index.js +246 -0
- package/lib/index.js.map +1 -1
- package/lib/plugin-adapter-core.js +385 -0
- package/lib/plugin-adapter-core.js.map +1 -0
- package/lib/plugin-bridge.js +318 -0
- package/lib/plugin-bridge.js.map +1 -0
- package/lib/plugin-loopback.js +207 -0
- package/lib/plugin-loopback.js.map +1 -0
- package/lib/plugin-route-intercept.js +1103 -0
- package/lib/plugin-route-intercept.js.map +1 -0
- package/lib/plugin-tunnel.js +416 -0
- package/lib/plugin-tunnel.js.map +1 -0
- package/lib/relay-client.js +319 -2
- package/lib/relay-client.js.map +1 -1
- package/lib/relay-server.js +633 -0
- package/lib/relay-server.js.map +1 -1
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts +27 -0
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -0
- package/lib/types/adapters/dsh-better-sidebar/methods.d.ts +51 -0
- package/lib/types/adapters/dsh-better-sidebar/methods.d.ts.map +1 -0
- package/lib/types/adapters/index.d.ts +17 -0
- package/lib/types/adapters/index.d.ts.map +1 -0
- package/lib/types/adapters/types.d.ts +42 -0
- package/lib/types/adapters/types.d.ts.map +1 -0
- package/lib/types/admin-routes.d.ts +16 -0
- package/lib/types/admin-routes.d.ts.map +1 -1
- package/lib/types/client/effects/remote-session.d.ts +0 -22
- package/lib/types/client/effects/remote-session.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +16 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
- package/lib/types/client-data/plugin-bridge.d.ts +79 -0
- package/lib/types/client-data/plugin-bridge.d.ts.map +1 -0
- package/lib/types/client-data/remote-status.d.ts +20 -0
- package/lib/types/client-data/remote-status.d.ts.map +1 -1
- package/lib/types/client-data/settings-form.d.ts +37 -1
- package/lib/types/client-data/settings-form.d.ts.map +1 -1
- package/lib/types/client-routes.d.ts +13 -0
- package/lib/types/client-routes.d.ts.map +1 -1
- package/lib/types/config.d.ts +8 -0
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/http.d.ts +7 -0
- package/lib/types/http.d.ts.map +1 -1
- package/lib/types/index.d.ts +4 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/plugin-adapter-core.d.ts +263 -0
- package/lib/types/plugin-adapter-core.d.ts.map +1 -0
- package/lib/types/plugin-bridge.d.ts +187 -0
- package/lib/types/plugin-bridge.d.ts.map +1 -0
- package/lib/types/plugin-loopback.d.ts +104 -0
- package/lib/types/plugin-loopback.d.ts.map +1 -0
- package/lib/types/plugin-route-intercept.d.ts +114 -0
- package/lib/types/plugin-route-intercept.d.ts.map +1 -0
- package/lib/types/plugin-tunnel.d.ts +96 -0
- package/lib/types/plugin-tunnel.d.ts.map +1 -0
- package/lib/types/relay-client.d.ts +76 -0
- package/lib/types/relay-client.d.ts.map +1 -1
- package/lib/types/relay-server.d.ts +32 -1
- package/lib/types/relay-server.d.ts.map +1 -1
- package/llms.txt +1 -0
- package/package.json +2 -2
- package/scripts/check-plugin-bridge-ui.mjs +168 -0
- package/scripts/check-settings-form.mjs +3 -1
- package/src/adapters/dsh-better-sidebar/index.ts +144 -0
- package/src/adapters/dsh-better-sidebar/methods.ts +215 -0
- package/src/adapters/index.ts +19 -0
- package/src/adapters/types.ts +44 -0
- package/src/admin-routes.ts +64 -0
- package/src/client/effects/remote-session.ts +82 -10
- package/src/client/locales.ts +36 -0
- package/src/client/settings/SettingsSection.tsx +107 -3
- package/src/client/styles/remote-session.css.ts +70 -31
- package/src/client-data/plugin-bridge.ts +150 -0
- package/src/client-data/remote-status.ts +30 -0
- package/src/client-data/settings-form.ts +78 -0
- package/src/client-routes.ts +29 -1
- package/src/config.ts +32 -0
- package/src/http.ts +12 -0
- package/src/index.ts +252 -0
- package/src/plugin-adapter-core.ts +530 -0
- package/src/plugin-bridge.ts +435 -0
- package/src/plugin-loopback.ts +299 -0
- package/src/plugin-route-intercept.ts +1193 -0
- package/src/plugin-tunnel.ts +470 -0
- package/src/relay-client.ts +394 -2
- package/src/relay-server.ts +656 -1
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plugin bridge's registration and comparison half (PB-1).
|
|
3
|
+
*
|
|
4
|
+
* Other DSH plugins can hand zen-remote a "bridge declaration": the list of
|
|
5
|
+
* THEIR OWN HTTP routes that may be forwarded to the main server inside a
|
|
6
|
+
* remote session (docs/adr/0001). A plugin counts as "bridged" only when
|
|
7
|
+
* BOTH ends agree on the plugin name, the exact version AND the declaration
|
|
8
|
+
* digest — everything here is the pure logic behind that agreement: the
|
|
9
|
+
* declaration shape and its validation ({@link normalizeDeclaration}), the
|
|
10
|
+
* per-process registry backing the `zenRemote` service
|
|
11
|
+
* ({@link createBridgeRegistry}), and the local-vs-server comparison that a
|
|
12
|
+
* later task feeds handshake data into ({@link compareBridges}).
|
|
13
|
+
*
|
|
14
|
+
* Pure logic by design: no DSH imports, no I/O, no clocks — the digest is
|
|
15
|
+
* the only place node:crypto appears, which is fine because this module only
|
|
16
|
+
* ever runs in the backend process. Nothing here talks to the relay or the
|
|
17
|
+
* network; the handshake side and the request rewrite side are later tasks
|
|
18
|
+
* that consume a {@link BridgeRegistry} through src/index.ts.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { createHash } from 'node:crypto'
|
|
22
|
+
|
|
23
|
+
/** Where a bridged route's caller supplies the session id. `'session'` — the
|
|
24
|
+
* data is scoped to one session, forwarded after the server checks the share.
|
|
25
|
+
* `'server'` — whole-machine data (e.g. every zcode task), which the server
|
|
26
|
+
* owner must consent to per plugin (the `pluginBridgeConsent` row knob). */
|
|
27
|
+
export type BridgeScope = 'session' | 'server'
|
|
28
|
+
|
|
29
|
+
/** The HTTP methods a bridged route may forward (PB-9). 2.1 accepted GET
|
|
30
|
+
* only; the write methods arrived with the `methods` field. */
|
|
31
|
+
export const BRIDGE_METHODS = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'] as const
|
|
32
|
+
|
|
33
|
+
/** The body cap a route may declare (`maxBody`), in bytes: 64 MiB. */
|
|
34
|
+
export const MAX_ROUTE_BODY_BYTES = 64 * 1024 * 1024
|
|
35
|
+
|
|
36
|
+
/** The body cap a route gets when it declares none: 1 MiB. */
|
|
37
|
+
export const DEFAULT_ROUTE_BODY_BYTES = 1024 * 1024
|
|
38
|
+
|
|
39
|
+
/** One forwardable route as a plugin declares it. `sessionParam` names the
|
|
40
|
+
* URL query parameter carrying the session id (default `'sessionId'`);
|
|
41
|
+
* `stream` marks routes answered as an event stream rather than one JSON
|
|
42
|
+
* body. `methods` (PB-9) lists the forwardable HTTP methods — uppercase
|
|
43
|
+
* members of {@link BRIDGE_METHODS}, deduplicated and sorted at
|
|
44
|
+
* normalization; omitted means `['GET']` (the 2.1 behavior). `maxBody`
|
|
45
|
+
* (PB-9) caps the forwarded request body in bytes; omitted means
|
|
46
|
+
* {@link DEFAULT_ROUTE_BODY_BYTES}. `upgrade` (PB-10) marks the route as a
|
|
47
|
+
* WebSocket endpoint carried over the byte tunnel — GET-only, mutually
|
|
48
|
+
* exclusive with `stream`. */
|
|
49
|
+
export interface BridgeRoute {
|
|
50
|
+
path: string
|
|
51
|
+
scope: BridgeScope
|
|
52
|
+
sessionParam?: string
|
|
53
|
+
stream?: boolean
|
|
54
|
+
methods?: readonly string[]
|
|
55
|
+
maxBody?: number
|
|
56
|
+
upgrade?: boolean
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** What a plugin hands `zenRemote.bridge(...)` — the raw, unvalidated shape. */
|
|
60
|
+
export interface BridgeDeclaration {
|
|
61
|
+
plugin: string
|
|
62
|
+
version: string
|
|
63
|
+
routes: BridgeRoute[]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A route after validation: every scalar field filled, `methods`,
|
|
67
|
+
* `maxBody` and `upgrade` present ONLY when the plugin declared them
|
|
68
|
+
* explicitly — the distinction is load-bearing for the digest (see the
|
|
69
|
+
* canonical form in {@link normalizeDeclaration}), while consumers read the
|
|
70
|
+
* EFFECTIVE values through {@link routeMethodsOf} / {@link routeMaxBodyOf}
|
|
71
|
+
* / {@link routeUpgradesOf}. */
|
|
72
|
+
export interface NormalizedBridgeRoute {
|
|
73
|
+
path: string
|
|
74
|
+
scope: BridgeScope
|
|
75
|
+
sessionParam: string
|
|
76
|
+
stream: boolean
|
|
77
|
+
methods: string[] | undefined
|
|
78
|
+
maxBody: number | undefined
|
|
79
|
+
upgrade: boolean | undefined
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The effective upgrade mark of one normalized route. */
|
|
83
|
+
export function routeUpgradesOf(route: NormalizedBridgeRoute): boolean {
|
|
84
|
+
return route.upgrade === true
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The effective forwardable methods of one normalized route. */
|
|
88
|
+
export function routeMethodsOf(route: NormalizedBridgeRoute): string[] {
|
|
89
|
+
return route.methods ?? ['GET']
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The effective body cap of one normalized route, in bytes. */
|
|
93
|
+
export function routeMaxBodyOf(route: NormalizedBridgeRoute): number {
|
|
94
|
+
return route.maxBody ?? DEFAULT_ROUTE_BODY_BYTES
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A declaration after validation: every route field filled, routes sorted
|
|
98
|
+
* by path, and the 16-hex-char digest both ends will compare. */
|
|
99
|
+
export interface NormalizedDeclaration {
|
|
100
|
+
plugin: string
|
|
101
|
+
version: string
|
|
102
|
+
routes: NormalizedBridgeRoute[]
|
|
103
|
+
digest: string
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Longest legal `plugin` — the npm package name cap. */
|
|
107
|
+
const MAX_PLUGIN_LENGTH = 214
|
|
108
|
+
|
|
109
|
+
/** Most routes one declaration may carry. */
|
|
110
|
+
const MAX_ROUTES = 64
|
|
111
|
+
|
|
112
|
+
/** npm package name shape: lowercase, URL-safe run of `[a-z0-9._-]` that
|
|
113
|
+
* starts with a letter or digit, optionally `@scope/name` with the same body
|
|
114
|
+
* rules on both halves. Not the full official validator (reserved names,
|
|
115
|
+
* edge punctuation) — enough that a typo'd string cannot masquerade as a
|
|
116
|
+
* package name and that the two ends normalize identically. */
|
|
117
|
+
const PLUGIN_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/u
|
|
118
|
+
|
|
119
|
+
/** Query-parameter name carrying the session id. */
|
|
120
|
+
const SESSION_PARAM_PATTERN = /^[A-Za-z][A-Za-z0-9_]{0,63}$/u
|
|
121
|
+
|
|
122
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
123
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Every rejection says WHICH field failed and why. */
|
|
127
|
+
function fail(field: string, detail: string): never {
|
|
128
|
+
throw new Error(`invalid bridge declaration, ${field}: ${detail}`)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Validate one declaration and fold it into its comparable form.
|
|
133
|
+
*
|
|
134
|
+
* Route paths must be plain absolute paths (start with `/`, no `?`, `#`,
|
|
135
|
+
* `..` or whitespace) and must stay off the two prefixes zen-remote does not
|
|
136
|
+
* own: `/_dsh/` (the plugin's own surface) and `/api` (the host's shared
|
|
137
|
+
* fetch channel) — bridging either would let a third plugin put words in the
|
|
138
|
+
* host's mouth. The `/api` check is the literal string prefix, so a plugin
|
|
139
|
+
* route like `/apifoo` is refused too; that overreach is deliberate, the
|
|
140
|
+
* rule must stay trivially predictable on both ends.
|
|
141
|
+
*
|
|
142
|
+
* The digest is sha256 over a canonical JSON of `{plugin, version, routes}`
|
|
143
|
+
* with a fixed key order, hex, first 16 characters — route ORDER is not part
|
|
144
|
+
* of it (two ends listing the same routes in different orders still match),
|
|
145
|
+
* any field VALUE is.
|
|
146
|
+
* @throws with a field-naming message on any illegal shape.
|
|
147
|
+
*/
|
|
148
|
+
export function normalizeDeclaration(input: unknown): NormalizedDeclaration {
|
|
149
|
+
if (!isPlainObject(input)) fail('declaration', 'expected an object')
|
|
150
|
+
for (const key of Object.keys(input)) {
|
|
151
|
+
if (key !== 'plugin' && key !== 'version' && key !== 'routes') fail(key, 'unknown field (typo guard)')
|
|
152
|
+
}
|
|
153
|
+
const { plugin, version, routes } = input
|
|
154
|
+
if (typeof plugin !== 'string' || plugin === '') fail('plugin', 'must be a non-empty string')
|
|
155
|
+
if (plugin.length > MAX_PLUGIN_LENGTH) fail('plugin', `longer than ${MAX_PLUGIN_LENGTH} characters`)
|
|
156
|
+
if (!PLUGIN_PATTERN.test(plugin)) fail('plugin', `"${plugin}" is not an npm package name`)
|
|
157
|
+
if (typeof version !== 'string' || version.trim() === '') fail('version', 'must be a non-empty string')
|
|
158
|
+
if (!Array.isArray(routes) || routes.length === 0) fail('routes', 'must be a non-empty array')
|
|
159
|
+
if (routes.length > MAX_ROUTES) fail('routes', `${routes.length} routes exceeds the ${MAX_ROUTES} cap`)
|
|
160
|
+
|
|
161
|
+
const seen = new Set<string>()
|
|
162
|
+
const normalizedRoutes = routes.map((route, index): NormalizedBridgeRoute => {
|
|
163
|
+
if (!isPlainObject(route)) fail(`routes[${index}]`, 'expected an object')
|
|
164
|
+
for (const key of Object.keys(route)) {
|
|
165
|
+
if (key !== 'path' && key !== 'scope' && key !== 'sessionParam' && key !== 'stream' && key !== 'methods' && key !== 'maxBody' && key !== 'upgrade') {
|
|
166
|
+
fail(`routes[${index}].${key}`, 'unknown field (typo guard)')
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const { path, scope, sessionParam, stream, methods, maxBody, upgrade } = route
|
|
170
|
+
if (typeof path !== 'string' || path === '') fail(`routes[${index}].path`, 'must be a non-empty string')
|
|
171
|
+
if (!path.startsWith('/')) fail(`routes[${index}].path`, `"${path}" must start with "/"`)
|
|
172
|
+
if (path.includes('?')) fail(`routes[${index}].path`, `"${path}" must not contain "?"`)
|
|
173
|
+
if (path.includes('#')) fail(`routes[${index}].path`, `"${path}" must not contain "#"`)
|
|
174
|
+
if (path.includes('..')) fail(`routes[${index}].path`, `"${path}" must not contain ".."`)
|
|
175
|
+
if (/\s/u.test(path)) fail(`routes[${index}].path`, `"${path}" must not contain whitespace`)
|
|
176
|
+
if (path.startsWith('/_dsh/') || path.startsWith('/api')) {
|
|
177
|
+
fail(`routes[${index}].path`, `"${path}" starts with a host-owned prefix (/_dsh/ or /api) and cannot be bridged`)
|
|
178
|
+
}
|
|
179
|
+
if (scope !== 'session' && scope !== 'server') {
|
|
180
|
+
fail(`routes[${index}].scope`, 'must be "session" or "server"')
|
|
181
|
+
}
|
|
182
|
+
const param = sessionParam === undefined ? 'sessionId' : sessionParam
|
|
183
|
+
if (typeof param !== 'string' || !SESSION_PARAM_PATTERN.test(param)) {
|
|
184
|
+
fail(`routes[${index}].sessionParam`, `"${String(param)}" must match ${SESSION_PARAM_PATTERN.source}`)
|
|
185
|
+
}
|
|
186
|
+
const streamFlag = stream === undefined ? false : stream
|
|
187
|
+
if (typeof streamFlag !== 'boolean') fail(`routes[${index}].stream`, 'must be a boolean')
|
|
188
|
+
if (seen.has(path)) fail(`routes[${index}].path`, `duplicate path "${path}"`)
|
|
189
|
+
seen.add(path)
|
|
190
|
+
// PB-9: the forwardable methods — uppercase members of the fixed
|
|
191
|
+
// vocabulary only (method names are case-sensitive on the wire; a
|
|
192
|
+
// lowercase or mixed-case entry is exactly the typo the strictness
|
|
193
|
+
// exists to catch), deduplicated and sorted so order never moves the
|
|
194
|
+
// digest. Omitted stays `undefined` — the digest-preserving choice: a
|
|
195
|
+
// 2.1-era declaration must hash EXACTLY as it did before this field
|
|
196
|
+
// existed, so defaults never enter the canonical form.
|
|
197
|
+
let normalizedMethods: string[] | undefined
|
|
198
|
+
if (methods !== undefined) {
|
|
199
|
+
if (!Array.isArray(methods) || methods.length === 0) {
|
|
200
|
+
fail(`routes[${index}].methods`, 'must be a non-empty array of HTTP methods')
|
|
201
|
+
}
|
|
202
|
+
const upper: string[] = []
|
|
203
|
+
for (const entry of methods) {
|
|
204
|
+
if (typeof entry !== 'string' || !(BRIDGE_METHODS as readonly string[]).includes(entry)) {
|
|
205
|
+
fail(`routes[${index}].methods`, `${JSON.stringify(entry ?? null)} is not one of ${BRIDGE_METHODS.join(', ')}`)
|
|
206
|
+
}
|
|
207
|
+
if (!upper.includes(entry)) upper.push(entry)
|
|
208
|
+
}
|
|
209
|
+
upper.sort()
|
|
210
|
+
normalizedMethods = upper
|
|
211
|
+
}
|
|
212
|
+
// PB-9: the body cap — a positive integer, at most
|
|
213
|
+
// {@link MAX_ROUTE_BODY_BYTES}. Same digest rule as `methods`: only an
|
|
214
|
+
// EXPLICIT maxBody enters the canonical form, even when it equals the
|
|
215
|
+
// default.
|
|
216
|
+
let normalizedMaxBody: number | undefined
|
|
217
|
+
if (maxBody !== undefined) {
|
|
218
|
+
if (typeof maxBody !== 'number' || !Number.isInteger(maxBody) || maxBody <= 0 || maxBody > MAX_ROUTE_BODY_BYTES) {
|
|
219
|
+
fail(`routes[${index}].maxBody`, `must be a positive integer up to ${MAX_ROUTE_BODY_BYTES}`)
|
|
220
|
+
}
|
|
221
|
+
normalizedMaxBody = maxBody
|
|
222
|
+
}
|
|
223
|
+
// PB-10: the WebSocket tunnel mark. Only `true` is legal (this is a
|
|
224
|
+
// declaration, not a toggle), it cannot coexist with `stream` (a route
|
|
225
|
+
// is one thing), and it is GET-only — an explicit `methods` naming
|
|
226
|
+
// anything else cannot be an upgrade route.
|
|
227
|
+
let normalizedUpgrade: boolean | undefined
|
|
228
|
+
if (upgrade !== undefined) {
|
|
229
|
+
if (upgrade !== true) fail(`routes[${index}].upgrade`, 'must be true or omitted')
|
|
230
|
+
if (streamFlag) fail(`routes[${index}].upgrade`, 'cannot be combined with stream')
|
|
231
|
+
if (normalizedMethods !== undefined && normalizedMethods.some((entry) => entry !== 'GET')) {
|
|
232
|
+
fail(`routes[${index}].upgrade`, 'upgrade routes are GET-only')
|
|
233
|
+
}
|
|
234
|
+
normalizedUpgrade = true
|
|
235
|
+
}
|
|
236
|
+
return { path, scope, sessionParam: param, stream: streamFlag, methods: normalizedMethods, maxBody: normalizedMaxBody, upgrade: normalizedUpgrade }
|
|
237
|
+
})
|
|
238
|
+
normalizedRoutes.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
|
|
239
|
+
|
|
240
|
+
// Fixed key order = the canonical form both ends hash; insertion order of
|
|
241
|
+
// the object literal IS the contract, JSON.stringify never sorts. The
|
|
242
|
+
// route entries carry `methods` / `maxBody` ONLY when the plugin declared
|
|
243
|
+
// them — `JSON.stringify` drops `undefined` values, but the explicit
|
|
244
|
+
// conditional documents the intent and keeps the PB-1 compatibility
|
|
245
|
+
// visible instead of incidental.
|
|
246
|
+
const canonical = JSON.stringify({
|
|
247
|
+
plugin,
|
|
248
|
+
version,
|
|
249
|
+
routes: normalizedRoutes.map((route) => {
|
|
250
|
+
const entry: Record<string, unknown> = {
|
|
251
|
+
path: route.path,
|
|
252
|
+
scope: route.scope,
|
|
253
|
+
sessionParam: route.sessionParam,
|
|
254
|
+
stream: route.stream,
|
|
255
|
+
}
|
|
256
|
+
if (route.methods !== undefined) entry.methods = route.methods
|
|
257
|
+
if (route.maxBody !== undefined) entry.maxBody = route.maxBody
|
|
258
|
+
if (route.upgrade !== undefined) entry.upgrade = route.upgrade
|
|
259
|
+
return entry
|
|
260
|
+
}),
|
|
261
|
+
})
|
|
262
|
+
const digest = createHash('sha256').update(canonical, 'utf8').digest('hex').slice(0, 16)
|
|
263
|
+
return { plugin, version, routes: normalizedRoutes, digest }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** The service provided on the row context as `zenRemote`, both roles. Other
|
|
267
|
+
* plugins reach it with `ctx.inject(['zenRemote'], (c) => …)`; when
|
|
268
|
+
* zen-remote is not installed the inject never fires, so dependents need no
|
|
269
|
+
* conditional code. Exported as a TYPE only — no Context augmentation, so
|
|
270
|
+
* nothing here leaks into compositions without this plugin. */
|
|
271
|
+
export interface ZenRemoteService {
|
|
272
|
+
/** Register this process's bridge declaration. Throws on an illegal
|
|
273
|
+
* declaration (nothing is registered); on success returns the dispose that
|
|
274
|
+
* removes exactly this registration. */
|
|
275
|
+
bridge(declaration: BridgeDeclaration): () => void
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** How `register` reports a same-plugin overwrite. Injectable so tests (and
|
|
279
|
+
* src/index.ts, via the context logger) can observe it; absent = silent. */
|
|
280
|
+
export interface CreateBridgeRegistryOptions {
|
|
281
|
+
warn?: (message: string) => void
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** The per-process declaration table behind the `zenRemote` service. */
|
|
285
|
+
export interface BridgeRegistry {
|
|
286
|
+
/** Validate and record one declaration (throws pass through, nothing is
|
|
287
|
+
* registered). A same-plugin registration REPLACES the earlier one and
|
|
288
|
+
* reports through `warn`. The returned dispose removes only the
|
|
289
|
+
* registration it came from — after an overwrite the STALE dispose is a
|
|
290
|
+
* no-op and must not remove the replacement. */
|
|
291
|
+
register(input: unknown): () => void
|
|
292
|
+
/** Record an ALREADY-normalized declaration verbatim — no validation, no
|
|
293
|
+
* re-digest (PB-11). This is the adapter registration door: an adapter
|
|
294
|
+
* arrives with its own digest (over its route table, locator included)
|
|
295
|
+
* and its own normalized routes, shapes the cooperative validator cannot
|
|
296
|
+
* produce. The same-plugin replace/report/dispose semantics of
|
|
297
|
+
* {@link register} apply unchanged — which is exactly the yielding rule:
|
|
298
|
+
* a plugin's own later `zenRemote.bridge(...)` call replaces the adapter's
|
|
299
|
+
* entry and a warn notes the takeover. */
|
|
300
|
+
registerNormalized(normalized: NormalizedDeclaration): () => void
|
|
301
|
+
/** All live declarations, sorted by plugin name. */
|
|
302
|
+
list(): NormalizedDeclaration[]
|
|
303
|
+
get(plugin: string): NormalizedDeclaration | undefined
|
|
304
|
+
/** Fires once per successful register and per effective removal (an
|
|
305
|
+
* overwritten stale dispose fires nothing). Returns the unsubscribe. */
|
|
306
|
+
onChange(listener: () => void): () => void
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Create the registry one DSH process (one plugin row) owns.
|
|
311
|
+
*
|
|
312
|
+
* Overwrite bookkeeping is identity-based, not flag-based: each dispose
|
|
313
|
+
* closes over the exact declaration object it registered and only deletes
|
|
314
|
+
* the table entry while it is STILL that object — a stale dispose meeting a
|
|
315
|
+
* newer registration finds someone else's identity and does nothing.
|
|
316
|
+
*/
|
|
317
|
+
export function createBridgeRegistry(options: CreateBridgeRegistryOptions = {}): BridgeRegistry {
|
|
318
|
+
const byPlugin = new Map<string, NormalizedDeclaration>()
|
|
319
|
+
const listeners = new Set<() => void>()
|
|
320
|
+
const warn = options.warn
|
|
321
|
+
// A throwing listener must not break the register/remove that invoked it —
|
|
322
|
+
// registration is bookkeeping other plugins depend on, a bad observer is
|
|
323
|
+
// that observer's problem. Failures surface through warn when there is one.
|
|
324
|
+
const emit = (): void => {
|
|
325
|
+
for (const listener of [...listeners]) {
|
|
326
|
+
try {
|
|
327
|
+
listener()
|
|
328
|
+
} catch (error) {
|
|
329
|
+
warn?.(`a zenRemote onChange listener failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return {
|
|
334
|
+
register(input) {
|
|
335
|
+
const normalized = normalizeDeclaration(input)
|
|
336
|
+
const replaced = byPlugin.has(normalized.plugin)
|
|
337
|
+
byPlugin.set(normalized.plugin, normalized)
|
|
338
|
+
if (replaced) warn?.(`plugin "${normalized.plugin}" re-registered its bridge declaration; the earlier one was replaced`)
|
|
339
|
+
emit()
|
|
340
|
+
return () => {
|
|
341
|
+
if (byPlugin.get(normalized.plugin) !== normalized) return
|
|
342
|
+
byPlugin.delete(normalized.plugin)
|
|
343
|
+
emit()
|
|
344
|
+
}
|
|
345
|
+
},
|
|
346
|
+
registerNormalized(normalized) {
|
|
347
|
+
const replaced = byPlugin.has(normalized.plugin)
|
|
348
|
+
byPlugin.set(normalized.plugin, normalized)
|
|
349
|
+
if (replaced) warn?.(`plugin "${normalized.plugin}" re-registered its bridge entry; the earlier one was replaced`)
|
|
350
|
+
emit()
|
|
351
|
+
return () => {
|
|
352
|
+
if (byPlugin.get(normalized.plugin) !== normalized) return
|
|
353
|
+
byPlugin.delete(normalized.plugin)
|
|
354
|
+
emit()
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
list() {
|
|
358
|
+
return [...byPlugin.values()].sort((a, b) => (a.plugin < b.plugin ? -1 : a.plugin > b.plugin ? 1 : 0))
|
|
359
|
+
},
|
|
360
|
+
get(plugin) {
|
|
361
|
+
return byPlugin.get(plugin)
|
|
362
|
+
},
|
|
363
|
+
onChange(listener) {
|
|
364
|
+
listeners.add(listener)
|
|
365
|
+
return () => {
|
|
366
|
+
listeners.delete(listener)
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Why a plugin is NOT bridged. The order of the cases below is the order
|
|
373
|
+
* {@link compareBridges} judges them in. */
|
|
374
|
+
export type BridgeReason =
|
|
375
|
+
| 'not-on-server'
|
|
376
|
+
| 'version-mismatch'
|
|
377
|
+
| 'declaration-mismatch'
|
|
378
|
+
| 'consent-off'
|
|
379
|
+
| 'offline'
|
|
380
|
+
| 'server-too-old'
|
|
381
|
+
|
|
382
|
+
/** One plugin's bridge verdict on this device. */
|
|
383
|
+
export interface BridgeStatus {
|
|
384
|
+
plugin: string
|
|
385
|
+
bridged: boolean
|
|
386
|
+
reason?: BridgeReason
|
|
387
|
+
localVersion: string
|
|
388
|
+
serverVersion?: string
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** What the server reports about one plugin at handshake time (a later task
|
|
392
|
+
* carries it in the relay handshake). `serverScopeConsent` records whether
|
|
393
|
+
* the server owner consented to this plugin's whole-machine-data routes. */
|
|
394
|
+
export interface ServerPluginEntry {
|
|
395
|
+
version: string
|
|
396
|
+
digest: string
|
|
397
|
+
serverScopeConsent: boolean
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Compare the local registry against the server's handshake table — one
|
|
402
|
+
* {@link BridgeStatus} per LOCAL declaration, in the input order.
|
|
403
|
+
*
|
|
404
|
+
* Judged in this order, first hit wins: offline → the server predates the
|
|
405
|
+
* plugins handshake field (`undefined`, so nothing can match) → the plugin
|
|
406
|
+
* is absent server-side → version differs → version equal but digest
|
|
407
|
+
* differs (same release, different declaration — a skew worth naming) → a
|
|
408
|
+
* `scope:'server'` route without the server owner's consent → bridged.
|
|
409
|
+
*
|
|
410
|
+
* Consent is judged `!== true` on purpose, not `=== false`: an entry without
|
|
411
|
+
* the field (an older server that learned the field later) means UNKNOWN
|
|
412
|
+
* consent, and whole-machine data must stay home until the server says yes.
|
|
413
|
+
*/
|
|
414
|
+
export function compareBridges(
|
|
415
|
+
local: NormalizedDeclaration[],
|
|
416
|
+
server: Record<string, ServerPluginEntry> | undefined,
|
|
417
|
+
online: boolean,
|
|
418
|
+
): BridgeStatus[] {
|
|
419
|
+
return local.map((declaration): BridgeStatus => {
|
|
420
|
+
const localStatus: BridgeStatus = { plugin: declaration.plugin, bridged: false, localVersion: declaration.version }
|
|
421
|
+
if (!online) return { ...localStatus, reason: 'offline' }
|
|
422
|
+
if (server === undefined) return { ...localStatus, reason: 'server-too-old' }
|
|
423
|
+
const entry: unknown = server[declaration.plugin]
|
|
424
|
+
if (typeof entry !== 'object' || entry === null) return { ...localStatus, reason: 'not-on-server' }
|
|
425
|
+
const record = entry as Partial<ServerPluginEntry>
|
|
426
|
+
const serverVersion = typeof record.version === 'string' ? record.version : undefined
|
|
427
|
+
if (serverVersion !== declaration.version) return { ...localStatus, reason: 'version-mismatch', serverVersion }
|
|
428
|
+
if (record.digest !== declaration.digest) return { ...localStatus, reason: 'declaration-mismatch', serverVersion }
|
|
429
|
+
const hasServerScopeRoute = declaration.routes.some((route) => route.scope === 'server')
|
|
430
|
+
if (hasServerScopeRoute && record.serverScopeConsent !== true) {
|
|
431
|
+
return { ...localStatus, reason: 'consent-off', serverVersion }
|
|
432
|
+
}
|
|
433
|
+
return { plugin: declaration.plugin, bridged: true, localVersion: declaration.version, serverVersion }
|
|
434
|
+
})
|
|
435
|
+
}
|