dsh-zen-remote 2.0.2 → 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 +63 -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/intercept.js +5 -2
- package/lib/intercept.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 +340 -4
- package/lib/relay-client.js.map +1 -1
- package/lib/relay-server.js +665 -7
- 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/intercept.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 +81 -1
- 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/intercept.ts +5 -2
- 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 +413 -4
- package/src/relay-server.ts +687 -9
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plugin bridge's adapter core (PB-11) — the framework half of the
|
|
3
|
+
* pluggable adapters. A cooperative plugin registers its OWN bridge
|
|
4
|
+
* declaration (PB-1); an adapter is zen-remote-internal code, written
|
|
5
|
+
* against one third-party plugin's EXACT version, that locates the session
|
|
6
|
+
* ids in that plugin's requests and rewrites them both ways (GLOSSARY:
|
|
7
|
+
* 适配器). This module owns everything the framework needs to run one:
|
|
8
|
+
* the route/locator shapes, their validation, the locate → classify →
|
|
9
|
+
* rewrite pipeline, and the digest the adapter registers under.
|
|
10
|
+
*
|
|
11
|
+
* Deliberately OUTSIDE `src/adapters/`: the pluggability contract
|
|
12
|
+
* (test/adapters-pluggable.test.cjs) is that deleting that directory breaks
|
|
13
|
+
* ONLY the wiring import in src/index.ts. The adapter-author face — the
|
|
14
|
+
* `BridgeAdapter` interface — lives in `src/adapters/types.ts` ON TOP of
|
|
15
|
+
* the shapes defined here (it re-exports them), so the framework never
|
|
16
|
+
* imports a single thing from `src/adapters/`.
|
|
17
|
+
*
|
|
18
|
+
* Pure logic: no DSH, no I/O. The id semantics ride src/virtual-id.ts —
|
|
19
|
+
* a request counts as REMOTE exactly when every id the route's locator
|
|
20
|
+
* finds is a `zr~` virtual id, and the rewrite swaps each for its real
|
|
21
|
+
* half before the request leaves for the relay (docs/adr/0001: the
|
|
22
|
+
* sub-client never lets a remote session's request reach a local plugin).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { createHash } from 'node:crypto'
|
|
26
|
+
import { isVirtual, fromVirtual } from './virtual-id.js'
|
|
27
|
+
import type { NormalizedBridgeRoute, NormalizedDeclaration } from './plugin-bridge.js'
|
|
28
|
+
import { BRIDGE_METHODS, DEFAULT_ROUTE_BODY_BYTES } from './plugin-bridge.js'
|
|
29
|
+
|
|
30
|
+
export { fromVirtual }
|
|
31
|
+
|
|
32
|
+
/** Where a route's session id lives in a request (`AdapterRoute.locator`).
|
|
33
|
+
* `query` names the search parameter; `path-segment` counts segments AFTER
|
|
34
|
+
* the declared path (a `prefix` route's variable part, already decoded once
|
|
35
|
+
* off the wire); `json-body` names TOP-LEVEL fields of the parsed JSON
|
|
36
|
+
* request body — every one of those fields that is present counts. */
|
|
37
|
+
export type AdapterLocator =
|
|
38
|
+
| { in: 'query'; name: string }
|
|
39
|
+
| { in: 'path-segment'; index: number }
|
|
40
|
+
| { in: 'json-body'; fields: readonly string[] }
|
|
41
|
+
|
|
42
|
+
/** One adapter route as the adapter declares it. `match: 'prefix'` hits
|
|
43
|
+
* `pathname === path || pathname.startsWith(path + '/')` on the RAW
|
|
44
|
+
* undecoded pathname; `exact` is the equality alone. `local: true` marks a
|
|
45
|
+
* path that stays on the sub-client's own machine forever (static assets
|
|
46
|
+
* and the like) — it never forwards, in either direction.
|
|
47
|
+
*
|
|
48
|
+
* `deny` (optional) answers a reason string for requests that must refuse
|
|
49
|
+
* BEFORE any id locating happens — the better-sidebar adapter uses it to
|
|
50
|
+
* refuse `open.external` when the browser's CURRENT session is a remote
|
|
51
|
+
* one (the request itself carries no session field, so the classify step
|
|
52
|
+
* would otherwise read it as local) and the side-chat methods wholesale.
|
|
53
|
+
* A `deny` hit answers the bridge's uniform 503 with that reason; returning
|
|
54
|
+
* `undefined` falls through to the normal classify. */
|
|
55
|
+
export interface AdapterRoute {
|
|
56
|
+
path: string
|
|
57
|
+
match: 'exact' | 'prefix'
|
|
58
|
+
methods?: readonly string[]
|
|
59
|
+
maxBody?: number
|
|
60
|
+
stream?: boolean
|
|
61
|
+
upgrade?: boolean
|
|
62
|
+
locator: AdapterLocator
|
|
63
|
+
local?: boolean
|
|
64
|
+
deny?: (info: { pathname: string; method: string; currentSessionId: string | undefined }) => string | undefined
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** One adapter route after validation: every scalar filled with its
|
|
68
|
+
* EFFECTIVE value (the adapters have no legacy-digest constraint, so
|
|
69
|
+
* unlike the cooperative {@link NormalizedBridgeRoute} the canonical form
|
|
70
|
+
* always carries the defaults). `deny` rides along by reference — a
|
|
71
|
+
* function cannot enter the digest, and it is client-half logic the
|
|
72
|
+
* server never needs. */
|
|
73
|
+
export interface NormalizedAdapterRoute {
|
|
74
|
+
path: string
|
|
75
|
+
match: 'exact' | 'prefix'
|
|
76
|
+
methods: string[]
|
|
77
|
+
maxBody: number
|
|
78
|
+
stream: boolean
|
|
79
|
+
upgrade: boolean
|
|
80
|
+
locator: AdapterLocator
|
|
81
|
+
local: boolean
|
|
82
|
+
deny?: AdapterRoute['deny']
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The two hook faces an adapter may implement (both optional — an adapter
|
|
86
|
+
* that only relocates ids needs neither). `client.request` runs on the
|
|
87
|
+
* sub-client AFTER the ids are rewritten to their real halves and can
|
|
88
|
+
* refuse the forward outright or swap the parsed JSON body;
|
|
89
|
+
* `client.response` runs on the sub-client on a JSON answer and returns
|
|
90
|
+
* the rewritten body (real ids back to virtual form). `server.request`
|
|
91
|
+
* runs on the main server before the loopback and can strip fields (e.g.
|
|
92
|
+
* a `cwd`) the local machine must not receive. */
|
|
93
|
+
export interface AdapterRequestContext {
|
|
94
|
+
method: string
|
|
95
|
+
/** The full request pathname (RAW, undecoded) — a prefix route's
|
|
96
|
+
* tail segment is where better-sidebar carries its API method name. */
|
|
97
|
+
pathname: string
|
|
98
|
+
/** The matched route, as declared. */
|
|
99
|
+
route: AdapterRoute
|
|
100
|
+
/** The request's query, ids already in their REAL form. */
|
|
101
|
+
query: URLSearchParams
|
|
102
|
+
/** The parsed JSON request body when the route locates in the body and a
|
|
103
|
+
* body arrived; `undefined` otherwise. */
|
|
104
|
+
body: unknown
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The verdict of a request hook: allow (optionally replacing the JSON
|
|
108
|
+
* body and/or the query — the better-sidebar adapter strips the `cwd`
|
|
109
|
+
* query field of the file/upload routes so the backend resolves it from
|
|
110
|
+
* the session), or refuse with a reason — the refusal travels the bridge's
|
|
111
|
+
* uniform 503 shape on the client half, a 403 on the server half. */
|
|
112
|
+
export type AdapterVerdict =
|
|
113
|
+
| { allow: true; body?: unknown; query?: URLSearchParams }
|
|
114
|
+
| { allow: false; reason: string }
|
|
115
|
+
|
|
116
|
+
/** The context of `client.response`: the parsed JSON answer plus the
|
|
117
|
+
* virtualizer for this device — an adapter maps server-real session ids
|
|
118
|
+
* back to THIS sub-client's virtual form with it. `pathname` names the
|
|
119
|
+
* request the answer belongs to (better-sidebar's response rewriting is
|
|
120
|
+
* per API method). */
|
|
121
|
+
export interface AdapterResponseContext {
|
|
122
|
+
status: number
|
|
123
|
+
pathname: string
|
|
124
|
+
body: unknown
|
|
125
|
+
/** Wrap one server-real session id into this device's virtual form. */
|
|
126
|
+
virtualize(id: string): string
|
|
127
|
+
/** Whether a value already IS a virtual id (rewriters skip those). */
|
|
128
|
+
isVirtual(value: unknown): boolean
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The hook members the framework calls, structurally. `src/adapters/
|
|
132
|
+
* types.ts`'s `BridgeAdapter` conforms to this shape by construction. */
|
|
133
|
+
export interface AdapterHooks {
|
|
134
|
+
client?: {
|
|
135
|
+
request?(ctx: AdapterRequestContext): AdapterVerdict
|
|
136
|
+
response?(ctx: AdapterResponseContext): unknown
|
|
137
|
+
}
|
|
138
|
+
server?: {
|
|
139
|
+
request?(ctx: AdapterRequestContext): AdapterVerdict
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** One adapter after validation: the route table, the digest the registry
|
|
144
|
+
* entry carries, the registry-shaped declaration (server-scope routes —
|
|
145
|
+
* an adapter is whole-machine data by definition), and the hooks. */
|
|
146
|
+
export interface NormalizedAdapter {
|
|
147
|
+
plugin: string
|
|
148
|
+
/** The exact versions this adapter was written against; the INSTALLED
|
|
149
|
+
* version must be among them or the adapter does not register. */
|
|
150
|
+
versions: readonly string[]
|
|
151
|
+
routes: NormalizedAdapterRoute[]
|
|
152
|
+
/** The digest of the ROUTE TABLE (locator and match included) — the
|
|
153
|
+
* value the registry entry and the handshake both carry. */
|
|
154
|
+
digest: string
|
|
155
|
+
/** What went into the registry: version = the INSTALLED version, digest
|
|
156
|
+
* = {@link digest}, routes = the forwardable (non-local) ones. */
|
|
157
|
+
declaration: NormalizedDeclaration
|
|
158
|
+
hooks: AdapterHooks
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The npm package name of an adapter's target, same shape the cooperative
|
|
162
|
+
* declarations validate. */
|
|
163
|
+
const PLUGIN_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/u
|
|
164
|
+
const MAX_ROUTES = 64
|
|
165
|
+
|
|
166
|
+
/** The body cap an ADAPTER route may declare: 100 MiB — the relay's own
|
|
167
|
+
* upload budget (lib/relay-server.js `MAX_UPLOAD_BYTES`), which the
|
|
168
|
+
* better-sidebar upload route rides. Wider than the cooperative
|
|
169
|
+
* declaration's 64 MiB because raw media uploads are exactly what an
|
|
170
|
+
* adapter exists to carry; test/adapter-better-sidebar.test.cjs pins the
|
|
171
|
+
* two numbers together. */
|
|
172
|
+
export const MAX_ADAPTER_BODY_BYTES = 100 * 1024 * 1024
|
|
173
|
+
|
|
174
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
175
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function fail(field: string, detail: string): never {
|
|
179
|
+
throw new Error(`invalid adapter, ${field}: ${detail}`)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Validate one adapter's route table and fold it, together with the
|
|
183
|
+
* INSTALLED plugin version, into the registry-ready form.
|
|
184
|
+
*
|
|
185
|
+
* Route paths follow the cooperative declaration's rules verbatim (plain
|
|
186
|
+
* absolute paths, off the host-owned `/_dsh/` and `/api` prefixes) — an
|
|
187
|
+
* adapter must not be able to reach anything a declaration cannot. The
|
|
188
|
+
* installed version must be listed EXACTLY (`versions` are what the
|
|
189
|
+
* adapter was written against); anything else refuses the adapter whole.
|
|
190
|
+
* @throws with a field-naming message on any illegal shape.
|
|
191
|
+
*/
|
|
192
|
+
export function normalizeAdapter(input: unknown, installedVersion: string): NormalizedAdapter {
|
|
193
|
+
if (!isRecord(input)) fail('adapter', 'expected an object')
|
|
194
|
+
for (const key of Object.keys(input)) {
|
|
195
|
+
if (key !== 'plugin' && key !== 'versions' && key !== 'routes' && key !== 'client' && key !== 'server') {
|
|
196
|
+
fail(key, 'unknown field (typo guard)')
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const { plugin, versions, routes } = input
|
|
200
|
+
if (typeof plugin !== 'string' || plugin === '' || plugin.length > 214 || !PLUGIN_PATTERN.test(plugin)) {
|
|
201
|
+
fail('plugin', `"${String(plugin)}" is not an npm package name`)
|
|
202
|
+
}
|
|
203
|
+
if (!Array.isArray(versions) || versions.length === 0 || versions.some((v) => typeof v !== 'string' || v === '')) {
|
|
204
|
+
fail('versions', 'must be a non-empty array of version strings')
|
|
205
|
+
}
|
|
206
|
+
if (!versions.includes(installedVersion)) {
|
|
207
|
+
fail('versions', `installed version "${installedVersion}" is not among the adapter's versions`)
|
|
208
|
+
}
|
|
209
|
+
if (!Array.isArray(routes) || routes.length === 0) fail('routes', 'must be a non-empty array')
|
|
210
|
+
if (routes.length > MAX_ROUTES) fail('routes', `${routes.length} routes exceeds the ${MAX_ROUTES} cap`)
|
|
211
|
+
|
|
212
|
+
const seen = new Set<string>()
|
|
213
|
+
const normalized: NormalizedAdapterRoute[] = routes.map((route, index): NormalizedAdapterRoute => {
|
|
214
|
+
if (!isRecord(route)) fail(`routes[${index}]`, 'expected an object')
|
|
215
|
+
for (const key of Object.keys(route)) {
|
|
216
|
+
if (key !== 'path' && key !== 'match' && key !== 'methods' && key !== 'maxBody' && key !== 'stream' && key !== 'upgrade' && key !== 'locator' && key !== 'local' && key !== 'deny') {
|
|
217
|
+
fail(`routes[${index}].${key}`, 'unknown field (typo guard)')
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const { path, match, methods, maxBody, stream, upgrade, locator, local, deny } = route
|
|
221
|
+
if (typeof path !== 'string' || path === '' || !path.startsWith('/') || path.includes('?') || path.includes('#') || path.includes('..') || /\s/u.test(path)) {
|
|
222
|
+
fail(`routes[${index}].path`, `"${String(path)}" must be a plain absolute path`)
|
|
223
|
+
}
|
|
224
|
+
if (path.startsWith('/_dsh/') || path.startsWith('/api')) {
|
|
225
|
+
fail(`routes[${index}].path`, `"${path}" starts with a host-owned prefix (/_dsh/ or /api)`)
|
|
226
|
+
}
|
|
227
|
+
if (match !== 'exact' && match !== 'prefix') fail(`routes[${index}].match`, 'must be "exact" or "prefix"')
|
|
228
|
+
if (seen.has(path)) fail(`routes[${index}].path`, `duplicate path "${path}"`)
|
|
229
|
+
seen.add(path)
|
|
230
|
+
let normalizedMethods: string[]
|
|
231
|
+
if (methods === undefined) {
|
|
232
|
+
normalizedMethods = ['GET']
|
|
233
|
+
} else {
|
|
234
|
+
if (!Array.isArray(methods) || methods.length === 0) fail(`routes[${index}].methods`, 'must be a non-empty array of HTTP methods')
|
|
235
|
+
const upper: string[] = []
|
|
236
|
+
for (const entry of methods) {
|
|
237
|
+
if (typeof entry !== 'string' || !(BRIDGE_METHODS as readonly string[]).includes(entry)) {
|
|
238
|
+
fail(`routes[${index}].methods`, `${JSON.stringify(entry ?? null)} is not one of ${BRIDGE_METHODS.join(', ')}`)
|
|
239
|
+
}
|
|
240
|
+
if (!upper.includes(entry)) upper.push(entry)
|
|
241
|
+
}
|
|
242
|
+
upper.sort()
|
|
243
|
+
normalizedMethods = upper
|
|
244
|
+
}
|
|
245
|
+
const normalizedMaxBody = maxBody === undefined ? DEFAULT_ROUTE_BODY_BYTES : maxBody
|
|
246
|
+
if (typeof normalizedMaxBody !== 'number' || !Number.isInteger(normalizedMaxBody) || normalizedMaxBody <= 0 || normalizedMaxBody > MAX_ADAPTER_BODY_BYTES) {
|
|
247
|
+
fail(`routes[${index}].maxBody`, `must be a positive integer up to ${MAX_ADAPTER_BODY_BYTES}`)
|
|
248
|
+
}
|
|
249
|
+
const normalizedStream = stream === undefined ? false : stream
|
|
250
|
+
if (typeof normalizedStream !== 'boolean') fail(`routes[${index}].stream`, 'must be a boolean')
|
|
251
|
+
const normalizedUpgrade = upgrade === undefined ? false : upgrade
|
|
252
|
+
if (typeof normalizedUpgrade !== 'boolean') fail(`routes[${index}].upgrade`, 'must be a boolean')
|
|
253
|
+
if (normalizedUpgrade) {
|
|
254
|
+
if (normalizedStream) fail(`routes[${index}].upgrade`, 'cannot be combined with stream')
|
|
255
|
+
if (normalizedMethods.some((entry) => entry !== 'GET')) fail(`routes[${index}].upgrade`, 'upgrade routes are GET-only')
|
|
256
|
+
}
|
|
257
|
+
if (!isRecord(locator)) fail(`routes[${index}].locator`, 'expected an object')
|
|
258
|
+
if (locator.in === 'query') {
|
|
259
|
+
if (typeof locator.name !== 'string' || !/^[A-Za-z][A-Za-z0-9_]{0,63}$/u.test(locator.name)) {
|
|
260
|
+
fail(`routes[${index}].locator.name`, 'must be a query parameter name')
|
|
261
|
+
}
|
|
262
|
+
} else if (locator.in === 'path-segment') {
|
|
263
|
+
if (typeof locator.index !== 'number' || !Number.isInteger(locator.index) || locator.index < 0) {
|
|
264
|
+
fail(`routes[${index}].locator.index`, 'must be a non-negative integer')
|
|
265
|
+
}
|
|
266
|
+
if (match !== 'prefix') fail(`routes[${index}].locator`, 'path-segment locating needs a prefix route')
|
|
267
|
+
} else if (locator.in === 'json-body') {
|
|
268
|
+
if (!Array.isArray(locator.fields) || locator.fields.length === 0 || locator.fields.some((f) => typeof f !== 'string' || f === '')) {
|
|
269
|
+
fail(`routes[${index}].locator.fields`, 'must be a non-empty array of top-level field names')
|
|
270
|
+
}
|
|
271
|
+
} else {
|
|
272
|
+
fail(`routes[${index}].locator.in`, 'must be "query", "path-segment" or "json-body"')
|
|
273
|
+
}
|
|
274
|
+
if (local !== undefined && local !== true && local !== false) fail(`routes[${index}].local`, 'must be a boolean')
|
|
275
|
+
const normalizedLocal: boolean = local === true
|
|
276
|
+
if (deny !== undefined && typeof deny !== 'function') fail(`routes[${index}].deny`, 'must be a function or omitted')
|
|
277
|
+
return {
|
|
278
|
+
path,
|
|
279
|
+
match,
|
|
280
|
+
methods: normalizedMethods,
|
|
281
|
+
maxBody: normalizedMaxBody,
|
|
282
|
+
stream: normalizedStream,
|
|
283
|
+
upgrade: normalizedUpgrade,
|
|
284
|
+
locator: locator as AdapterLocator,
|
|
285
|
+
local: normalizedLocal,
|
|
286
|
+
...(deny !== undefined && typeof deny === 'function' ? { deny: deny as AdapterRoute['deny'] } : {}),
|
|
287
|
+
}
|
|
288
|
+
})
|
|
289
|
+
normalized.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
|
|
290
|
+
|
|
291
|
+
const digest = adapterDigestOf(plugin, installedVersion, normalized)
|
|
292
|
+
// The registry entry: server-scope routes only (an adapter is whole-machine
|
|
293
|
+
// data by definition — the consent wall applies per plugin, default off),
|
|
294
|
+
// local routes left out (they never forward, so the server half must never
|
|
295
|
+
// see them).
|
|
296
|
+
const declaration: NormalizedDeclaration = {
|
|
297
|
+
plugin,
|
|
298
|
+
version: installedVersion,
|
|
299
|
+
routes: normalized
|
|
300
|
+
.filter((route) => !route.local)
|
|
301
|
+
.map((route): NormalizedBridgeRoute => ({
|
|
302
|
+
path: route.path,
|
|
303
|
+
scope: 'server',
|
|
304
|
+
sessionParam: 'sessionId',
|
|
305
|
+
stream: route.stream,
|
|
306
|
+
methods: route.methods.length === 1 && route.methods[0] === 'GET' ? undefined : [...route.methods],
|
|
307
|
+
maxBody: route.maxBody === DEFAULT_ROUTE_BODY_BYTES ? undefined : route.maxBody,
|
|
308
|
+
upgrade: route.upgrade ? true : undefined,
|
|
309
|
+
})),
|
|
310
|
+
digest,
|
|
311
|
+
}
|
|
312
|
+
const hooks: AdapterHooks = {}
|
|
313
|
+
if (isRecord(input.client) || input.client === undefined) {
|
|
314
|
+
const client = input.client as AdapterHooks['client']
|
|
315
|
+
if (client !== undefined && (typeof client.request === 'function' || typeof client.response === 'function')) {
|
|
316
|
+
hooks.client = {
|
|
317
|
+
...(typeof client.request === 'function' ? { request: (ctx) => client.request!(ctx) } : {}),
|
|
318
|
+
...(typeof client.response === 'function' ? { response: (ctx) => client.response!(ctx) } : {}),
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (isRecord(input.server) || input.server === undefined) {
|
|
323
|
+
const server = input.server as AdapterHooks['server']
|
|
324
|
+
if (server !== undefined && typeof server.request === 'function') {
|
|
325
|
+
hooks.server = { request: (ctx) => server.request!(ctx) }
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return { plugin, versions: [...versions] as string[], routes: normalized, digest, declaration, hooks }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** The digest of one adapter's route table: sha256 over a canonical JSON of
|
|
332
|
+
* `{plugin, version, routes}` — the locator and the match mode INCLUDED (a
|
|
333
|
+
* different locating rule is a different adapter), hex, first 16 chars.
|
|
334
|
+
* Routes arrive path-sorted, so order never moves the hash. */
|
|
335
|
+
export function adapterDigestOf(plugin: string, version: string, routes: NormalizedAdapterRoute[]): string {
|
|
336
|
+
const canonical = JSON.stringify({
|
|
337
|
+
plugin,
|
|
338
|
+
version,
|
|
339
|
+
routes: routes.map((route) => ({
|
|
340
|
+
path: route.path,
|
|
341
|
+
match: route.match,
|
|
342
|
+
methods: route.methods,
|
|
343
|
+
maxBody: route.maxBody,
|
|
344
|
+
stream: route.stream,
|
|
345
|
+
upgrade: route.upgrade,
|
|
346
|
+
locator: route.locator,
|
|
347
|
+
local: route.local,
|
|
348
|
+
})),
|
|
349
|
+
})
|
|
350
|
+
return createHash('sha256').update(canonical, 'utf8').digest('hex').slice(0, 16)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** The prefix-match rule, shared verbatim by both halves: equality or one
|
|
354
|
+
* path segment deeper, on the RAW undecoded pathname. */
|
|
355
|
+
export function adapterPathMatches(route: NormalizedAdapterRoute, pathname: string): boolean {
|
|
356
|
+
if (route.match === 'exact') return pathname === route.path
|
|
357
|
+
return pathname === route.path || pathname.startsWith(`${route.path}/`)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** The adapter route a forwarded request hit, or `undefined`. `local`
|
|
361
|
+
* routes never match here — they stay on the sub-client's own machine in
|
|
362
|
+
* both directions, so the server half must never see (nor serve) them. */
|
|
363
|
+
export function matchAdapterRoute(adapters: readonly NormalizedAdapter[] | undefined, pathname: string): { adapter: NormalizedAdapter; route: NormalizedAdapterRoute } | undefined {
|
|
364
|
+
if (adapters === undefined) return undefined
|
|
365
|
+
for (const adapter of adapters) {
|
|
366
|
+
for (const route of adapter.routes) {
|
|
367
|
+
if (route.local) continue
|
|
368
|
+
if (adapterPathMatches(route, pathname)) return { adapter, route }
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return undefined
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** One located session id: where it sat and the value as found. */
|
|
375
|
+
export interface AdapterIdHit {
|
|
376
|
+
kind: 'query' | 'path-segment' | 'json-body'
|
|
377
|
+
/** The query parameter or JSON field name; undefined for a path segment. */
|
|
378
|
+
key: string | undefined
|
|
379
|
+
value: string
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Thrown when a query parameter the locator reads appears with MORE than
|
|
383
|
+
* one value: the cooperative bridge refuses such requests as `bad-query`
|
|
384
|
+
* (a duplicated id is a shape violation, never a forward of whichever
|
|
385
|
+
* duplicate a parser happens to keep), and so does the adapter (PB-12
|
|
386
|
+
* review note — the old behavior silently collapsed to `set`, rewriting
|
|
387
|
+
* only one of the values). Callers catch this and answer 503/400
|
|
388
|
+
* `bad-query`. */
|
|
389
|
+
export class AdapterMultiValueQueryError extends Error {
|
|
390
|
+
constructor(name: string) {
|
|
391
|
+
super(`query parameter "${name}" appears more than once`)
|
|
392
|
+
this.name = 'AdapterMultiValueQueryError'
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Locate the session ids one request carries for one route. A query
|
|
397
|
+
* parameter counts once per value it appears with; a JSON body field counts
|
|
398
|
+
* when present with a non-empty string value; a path segment counts when
|
|
399
|
+
* the segment exists (decoded once off the wire). */
|
|
400
|
+
export function locateAdapterIds(route: NormalizedAdapterRoute, pathname: string, search: URLSearchParams, body: unknown): AdapterIdHit[] {
|
|
401
|
+
const hits: AdapterIdHit[] = []
|
|
402
|
+
const locator = route.locator
|
|
403
|
+
if (locator.in === 'query') {
|
|
404
|
+
const values = search.getAll(locator.name)
|
|
405
|
+
if (values.length > 1) throw new AdapterMultiValueQueryError(locator.name)
|
|
406
|
+
for (const value of values) {
|
|
407
|
+
if (value !== '') hits.push({ kind: 'query', key: locator.name, value })
|
|
408
|
+
}
|
|
409
|
+
} else if (locator.in === 'path-segment') {
|
|
410
|
+
// `index` counts the segments AFTER the declared path: /p/api + open/X
|
|
411
|
+
// → index 0 is "open", index 1 is "X". In split('/') terms the prefix
|
|
412
|
+
// occupies subscripts 1..prefixDepth, so the target sits one past it.
|
|
413
|
+
const prefixDepth = route.path.split('/').length - 1
|
|
414
|
+
const segments = pathname.split('/')
|
|
415
|
+
const at = prefixDepth + 1 + locator.index
|
|
416
|
+
if (at < segments.length && segments[at] !== '') {
|
|
417
|
+
let decoded = segments[at]
|
|
418
|
+
try {
|
|
419
|
+
decoded = decodeURIComponent(segments[at])
|
|
420
|
+
} catch {
|
|
421
|
+
// A malformed escape stays verbatim — the value simply will not be
|
|
422
|
+
// a session id.
|
|
423
|
+
}
|
|
424
|
+
hits.push({ kind: 'path-segment', key: undefined, value: decoded })
|
|
425
|
+
}
|
|
426
|
+
} else {
|
|
427
|
+
if (isRecord(body)) {
|
|
428
|
+
for (const field of locator.fields) {
|
|
429
|
+
const value = body[field]
|
|
430
|
+
if (typeof value === 'string' && value !== '') hits.push({ kind: 'json-body', key: field, value })
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return hits
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** The remote/local verdict of one request (the design's rule, verbatim):
|
|
438
|
+
* every located id virtual → remote; none located, or none virtual →
|
|
439
|
+
* local; a MIX of virtual and plain ids → the request is malformed for the
|
|
440
|
+
* bridge and refuses rather than guessing which half travels. */
|
|
441
|
+
export type AdapterRemoteVerdict =
|
|
442
|
+
| { kind: 'remote'; hits: AdapterIdHit[] }
|
|
443
|
+
| { kind: 'local' }
|
|
444
|
+
| { kind: 'mixed' }
|
|
445
|
+
|
|
446
|
+
export function classifyAdapterRequest(route: NormalizedAdapterRoute, pathname: string, search: URLSearchParams, body: unknown): AdapterRemoteVerdict {
|
|
447
|
+
const hits = locateAdapterIds(route, pathname, search, body)
|
|
448
|
+
if (hits.length === 0) return { kind: 'local' }
|
|
449
|
+
const virtual = hits.map((hit) => isVirtual(hit.value))
|
|
450
|
+
if (virtual.every(Boolean)) return { kind: 'remote', hits }
|
|
451
|
+
if (virtual.every((entry) => !entry)) return { kind: 'local' }
|
|
452
|
+
return { kind: 'mixed' }
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** What {@link rewriteAdapterRequest} produces: the request coordinates
|
|
456
|
+
* with every located virtual id swapped for its real half. */
|
|
457
|
+
export interface AdapterRewrite {
|
|
458
|
+
pathname: string
|
|
459
|
+
query: URLSearchParams
|
|
460
|
+
/** The re-serialized JSON body for a body-locating route that arrived
|
|
461
|
+
* with a body; `undefined` when there is nothing to re-serialize. */
|
|
462
|
+
bodyText: string | undefined
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Swap every located virtual id for its real half (the pure client-half
|
|
466
|
+
* rewrite): the query is re-serialized, the path segment re-encoded, the
|
|
467
|
+
* JSON body re-serialized. The re-encode direction is the wire contract —
|
|
468
|
+
* `encodeURIComponent` of the real id is what the server decodes again. */
|
|
469
|
+
export function rewriteAdapterRequest(
|
|
470
|
+
route: NormalizedAdapterRoute,
|
|
471
|
+
pathname: string,
|
|
472
|
+
search: URLSearchParams,
|
|
473
|
+
body: unknown,
|
|
474
|
+
hits: AdapterIdHit[],
|
|
475
|
+
realIdOf: (virtualId: string) => string,
|
|
476
|
+
): AdapterRewrite {
|
|
477
|
+
const rewrittenQuery = new URLSearchParams(search)
|
|
478
|
+
let rewrittenPathname = pathname
|
|
479
|
+
let bodyText: string | undefined
|
|
480
|
+
// The body copy is made ONCE: several fields of one body can locate ids,
|
|
481
|
+
// and re-copying per hit would drop every earlier field's rewrite.
|
|
482
|
+
let bodyCopy: Record<string, unknown> | undefined
|
|
483
|
+
for (const hit of hits) {
|
|
484
|
+
const parts = fromVirtual(hit.value)
|
|
485
|
+
if (parts === undefined) continue
|
|
486
|
+
const real = realIdOf(hit.value)
|
|
487
|
+
if (hit.kind === 'query') {
|
|
488
|
+
rewrittenQuery.set(hit.key!, real)
|
|
489
|
+
} else if (hit.kind === 'path-segment') {
|
|
490
|
+
const prefixDepth = route.path.split('/').length - 1
|
|
491
|
+
const segments = rewrittenPathname.split('/')
|
|
492
|
+
segments[prefixDepth + 1 + (route.locator as { index: number }).index] = encodeURIComponent(real)
|
|
493
|
+
rewrittenPathname = segments.join('/')
|
|
494
|
+
} else {
|
|
495
|
+
if (bodyCopy === undefined && isRecord(body)) bodyCopy = { ...body }
|
|
496
|
+
if (bodyCopy !== undefined) {
|
|
497
|
+
bodyCopy[hit.key!] = real
|
|
498
|
+
bodyText = JSON.stringify(bodyCopy)
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return { pathname: rewrittenPathname, query: rewrittenQuery, bodyText }
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Whether the adapter still owns its plugin's registry entry, or a
|
|
506
|
+
* cooperative declaration (the plugin's OWN `zenRemote.bridge(...)`, which
|
|
507
|
+
* always wins) has taken the seat since: an entry with a different digest
|
|
508
|
+
* means the adapter yielded. */
|
|
509
|
+
export function adapterOwnsRegistryEntry(adapter: NormalizedAdapter, registryEntry: { version: string; digest: string } | undefined): boolean {
|
|
510
|
+
return registryEntry !== undefined && registryEntry.digest === adapter.digest && registryEntry.version === adapter.declaration.version
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** The largest JSON answer the client half will buffer for a response
|
|
514
|
+
* rewrite: past this the body passes through untouched (8 MB). */
|
|
515
|
+
export const MAX_ADAPTER_RESPONSE_BUFFER_BYTES = 8 * 1024 * 1024
|
|
516
|
+
|
|
517
|
+
/** Response headers an ADAPTER answer may carry through VERBATIM, beyond
|
|
518
|
+
* the three every bridged forward passes (content-type, content-length,
|
|
519
|
+
* cache-control): the better-sidebar HTML preview ships a `content-
|
|
520
|
+
* security-policy` sandbox and downloads carry `content-disposition` —
|
|
521
|
+
* stripping those breaks the very panels the adapter exists to serve.
|
|
522
|
+
* Cooperative declaration routes keep the three-header behavior. */
|
|
523
|
+
export const ADAPTER_RESPONSE_HEADER_ALLOWLIST = [
|
|
524
|
+
'content-security-policy',
|
|
525
|
+
'content-disposition',
|
|
526
|
+
'etag',
|
|
527
|
+
'last-modified',
|
|
528
|
+
'accept-ranges',
|
|
529
|
+
'content-range',
|
|
530
|
+
] as const
|