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.
Files changed (114) hide show
  1. package/AGENTS.md +15 -1
  2. package/README.md +11 -5
  3. package/README.zh-CN.md +11 -5
  4. package/docs/adr/0001-plugin-bridge-forwards-plugin-routes.md +15 -0
  5. package/docs/adr/0002-relay-emulates-websocket.md +8 -0
  6. package/docs/interface.md +3 -1
  7. package/docs/plugin-bridge.md +140 -0
  8. package/docs/remote-access.en.md +57 -0
  9. package/docs/remote-access.md +57 -0
  10. package/lib/adapters/dsh-better-sidebar/index.js +144 -0
  11. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -0
  12. package/lib/adapters/dsh-better-sidebar/methods.js +198 -0
  13. package/lib/adapters/dsh-better-sidebar/methods.js.map +1 -0
  14. package/lib/adapters/index.js +17 -0
  15. package/lib/adapters/index.js.map +1 -0
  16. package/lib/adapters/types.js +17 -0
  17. package/lib/adapters/types.js.map +1 -0
  18. package/lib/admin-routes.js +51 -0
  19. package/lib/admin-routes.js.map +1 -1
  20. package/lib/client-routes.js +18 -1
  21. package/lib/client-routes.js.map +1 -1
  22. package/lib/client.js +449 -45
  23. package/lib/config.js +27 -0
  24. package/lib/config.js.map +1 -1
  25. package/lib/http.js +10 -0
  26. package/lib/http.js.map +1 -1
  27. package/lib/index.js +246 -0
  28. package/lib/index.js.map +1 -1
  29. package/lib/plugin-adapter-core.js +385 -0
  30. package/lib/plugin-adapter-core.js.map +1 -0
  31. package/lib/plugin-bridge.js +318 -0
  32. package/lib/plugin-bridge.js.map +1 -0
  33. package/lib/plugin-loopback.js +207 -0
  34. package/lib/plugin-loopback.js.map +1 -0
  35. package/lib/plugin-route-intercept.js +1103 -0
  36. package/lib/plugin-route-intercept.js.map +1 -0
  37. package/lib/plugin-tunnel.js +416 -0
  38. package/lib/plugin-tunnel.js.map +1 -0
  39. package/lib/relay-client.js +319 -2
  40. package/lib/relay-client.js.map +1 -1
  41. package/lib/relay-server.js +633 -0
  42. package/lib/relay-server.js.map +1 -1
  43. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +27 -0
  44. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -0
  45. package/lib/types/adapters/dsh-better-sidebar/methods.d.ts +51 -0
  46. package/lib/types/adapters/dsh-better-sidebar/methods.d.ts.map +1 -0
  47. package/lib/types/adapters/index.d.ts +17 -0
  48. package/lib/types/adapters/index.d.ts.map +1 -0
  49. package/lib/types/adapters/types.d.ts +42 -0
  50. package/lib/types/adapters/types.d.ts.map +1 -0
  51. package/lib/types/admin-routes.d.ts +16 -0
  52. package/lib/types/admin-routes.d.ts.map +1 -1
  53. package/lib/types/client/effects/remote-session.d.ts +0 -22
  54. package/lib/types/client/effects/remote-session.d.ts.map +1 -1
  55. package/lib/types/client/locales.d.ts +16 -0
  56. package/lib/types/client/locales.d.ts.map +1 -1
  57. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  58. package/lib/types/client/styles/remote-session.css.d.ts +1 -1
  59. package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
  60. package/lib/types/client-data/plugin-bridge.d.ts +79 -0
  61. package/lib/types/client-data/plugin-bridge.d.ts.map +1 -0
  62. package/lib/types/client-data/remote-status.d.ts +20 -0
  63. package/lib/types/client-data/remote-status.d.ts.map +1 -1
  64. package/lib/types/client-data/settings-form.d.ts +37 -1
  65. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  66. package/lib/types/client-routes.d.ts +13 -0
  67. package/lib/types/client-routes.d.ts.map +1 -1
  68. package/lib/types/config.d.ts +8 -0
  69. package/lib/types/config.d.ts.map +1 -1
  70. package/lib/types/http.d.ts +7 -0
  71. package/lib/types/http.d.ts.map +1 -1
  72. package/lib/types/index.d.ts +4 -0
  73. package/lib/types/index.d.ts.map +1 -1
  74. package/lib/types/plugin-adapter-core.d.ts +263 -0
  75. package/lib/types/plugin-adapter-core.d.ts.map +1 -0
  76. package/lib/types/plugin-bridge.d.ts +187 -0
  77. package/lib/types/plugin-bridge.d.ts.map +1 -0
  78. package/lib/types/plugin-loopback.d.ts +104 -0
  79. package/lib/types/plugin-loopback.d.ts.map +1 -0
  80. package/lib/types/plugin-route-intercept.d.ts +114 -0
  81. package/lib/types/plugin-route-intercept.d.ts.map +1 -0
  82. package/lib/types/plugin-tunnel.d.ts +96 -0
  83. package/lib/types/plugin-tunnel.d.ts.map +1 -0
  84. package/lib/types/relay-client.d.ts +76 -0
  85. package/lib/types/relay-client.d.ts.map +1 -1
  86. package/lib/types/relay-server.d.ts +32 -1
  87. package/lib/types/relay-server.d.ts.map +1 -1
  88. package/llms.txt +1 -0
  89. package/package.json +2 -2
  90. package/scripts/check-plugin-bridge-ui.mjs +168 -0
  91. package/scripts/check-settings-form.mjs +3 -1
  92. package/src/adapters/dsh-better-sidebar/index.ts +144 -0
  93. package/src/adapters/dsh-better-sidebar/methods.ts +215 -0
  94. package/src/adapters/index.ts +19 -0
  95. package/src/adapters/types.ts +44 -0
  96. package/src/admin-routes.ts +64 -0
  97. package/src/client/effects/remote-session.ts +82 -10
  98. package/src/client/locales.ts +36 -0
  99. package/src/client/settings/SettingsSection.tsx +107 -3
  100. package/src/client/styles/remote-session.css.ts +70 -31
  101. package/src/client-data/plugin-bridge.ts +150 -0
  102. package/src/client-data/remote-status.ts +30 -0
  103. package/src/client-data/settings-form.ts +78 -0
  104. package/src/client-routes.ts +29 -1
  105. package/src/config.ts +32 -0
  106. package/src/http.ts +12 -0
  107. package/src/index.ts +252 -0
  108. package/src/plugin-adapter-core.ts +530 -0
  109. package/src/plugin-bridge.ts +435 -0
  110. package/src/plugin-loopback.ts +299 -0
  111. package/src/plugin-route-intercept.ts +1193 -0
  112. package/src/plugin-tunnel.ts +470 -0
  113. package/src/relay-client.ts +394 -2
  114. package/src/relay-server.ts +656 -1
@@ -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