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,1193 @@
1
+ /**
2
+ * The plugin bridge's route interception (PB-4): the sub-client's local
3
+ * DSH process owns a webServer whose `match(pathname)` every request goes
4
+ * through (RT dsh-host-webserver lib/index.js:350-364 — `handle` computes
5
+ * the RAW, undecoded pathname and calls `this.match`). Overriding that one
6
+ * method intercepts every route lookup — including routes registered AFTER
7
+ * us, because the host re-reads the instance method per request. A request
8
+ * aimed at a BRIDGED plugin's declared route, carrying a VIRTUAL session id,
9
+ * cannot be served locally (the session lives on the server): the surrogate
10
+ * handler forwards it through `relay.pluginRequest` (PB-3's
11
+ * `relay/v1/plugin`) with the real id restored, and streams the answer
12
+ * back. Everything else — undeclared routes, local sessions — flows to the
13
+ * original route untouched.
14
+ *
15
+ * The un-bridged refusals answer a UNIFORM 503 shape (header
16
+ * `x-zen-remote: unbridged`, JSON body naming the reason) — and, decisive
17
+ * for the security posture, they answer WITHOUT the local plugin handler
18
+ * ever running: a remote session must never execute a plugin route against
19
+ * local state, not even when the bridge is down (docs/adr/0001).
20
+ *
21
+ * The wrap is gated on a structural shape check like the typert and
22
+ * fetch-route ones; uninstall restores `match` only while the instance
23
+ * still holds OUR wrapper — a later wrapper chained over us keeps working,
24
+ * and ours goes inert instead (the same posture as intercept.ts and
25
+ * fetch-route-intercept.ts).
26
+ */
27
+
28
+ import type { IncomingMessage, ServerResponse } from 'node:http'
29
+ import type { BridgeRegistry, BridgeStatus } from './plugin-bridge.js'
30
+ import { routeMethodsOf, routeMaxBodyOf, routeUpgradesOf } from './plugin-bridge.js'
31
+ import { PassThrough, Readable, Transform } from 'node:stream'
32
+ import type { Socket } from 'node:net'
33
+ import type { RelayClient } from './relay-client.js'
34
+ import { fromVirtual, isVirtual, toVirtual } from './virtual-id.js'
35
+ import type { NormalizedAdapter, NormalizedAdapterRoute, AdapterIdHit, AdapterVerdict, AdapterRemoteVerdict } from './plugin-adapter-core.js'
36
+ import { adapterPathMatches, classifyAdapterRequest, rewriteAdapterRequest, adapterOwnsRegistryEntry, MAX_ADAPTER_RESPONSE_BUFFER_BYTES, ADAPTER_RESPONSE_HEADER_ALLOWLIST, AdapterMultiValueQueryError } from './plugin-adapter-core.js'
37
+
38
+ /** Structural verdict of {@link checkWebServerShape}: the facts the wrap
39
+ * reads per request — the `match` function the host's handle calls, the two
40
+ * route tables, and (PB-10) the `upgrades` table the host's upgrade handler
41
+ * reads `this.upgrades.get(pathname)` from (both Maps, RT
42
+ * lib/index.js:467-478 and 298-308; the probe verified them on real
43
+ * 0.2.0-rc.2 / 0.2.1-alpha.2 instances). */
44
+ export type WebServerShapeCheck = { ok: true } | { ok: false; reasons: string[] }
45
+
46
+ function isRecord(value: unknown): value is Record<string, unknown> {
47
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
48
+ }
49
+
50
+ /** Pure inspection of the RAW webServer instance — nothing is mutated, so a
51
+ * failed check leaves every route exactly as it was. */
52
+ export function checkWebServerShape(webServer: unknown): WebServerShapeCheck {
53
+ if (!isRecord(webServer)) return { ok: false, reasons: ['webServer: not an object'] }
54
+ const reasons: string[] = []
55
+ if (typeof webServer.match !== 'function') reasons.push('match: not a function')
56
+ if (!(webServer.exact instanceof Map)) reasons.push('exact: not a Map')
57
+ if (!(webServer.prefixes instanceof Map)) reasons.push('prefixes: not a Map')
58
+ if (!(webServer.upgrades instanceof Map)) reasons.push('upgrades: not a Map')
59
+ if (reasons.length > 0) return { ok: false, reasons }
60
+ return { ok: true }
61
+ }
62
+
63
+ /** One failure of the bridge answered to a browser, in the diagnostics
64
+ * ring. The query NEVER travels — only the plugin name and the reason. */
65
+ export interface PluginRouteFailureRecord {
66
+ time: string
67
+ plugin: string
68
+ reason: string
69
+ }
70
+
71
+ /** What the client status route surfaces about this interception. */
72
+ export interface PluginRouteInterceptDiagnostics {
73
+ installed: boolean
74
+ shape: WebServerShapeCheck
75
+ /** Requests forwarded through the relay (virtual ids on declared routes,
76
+ * everything green). */
77
+ forwarded: number
78
+ /** Requests answered with the uniform unbridged 503. */
79
+ refused: number
80
+ /** The refusals keyed by reason. */
81
+ refusalsByReason: Record<string, number>
82
+ /** The most recent refusals, oldest first, capped — time, plugin, reason
83
+ * only, never a query, never a session id. */
84
+ recentFailures: PluginRouteFailureRecord[]
85
+ /** PB-10: tunnels live RIGHT NOW. */
86
+ tunnelsCurrent: number
87
+ /** PB-10: tunnels opened / torn down since the install. */
88
+ tunnelsOpened: number
89
+ tunnelsClosed: number
90
+ /** PB-11: adapter requests forwarded through the relay. */
91
+ adapterForwarded: number
92
+ /** PB-11: adapter JSON answers rewritten by a `client.response` hook, and
93
+ * answers that passed through UNTOUCHED because the buffer cap (8 MB) or
94
+ * a parse failure made a rewrite impossible. */
95
+ adapterResponseRewrites: number
96
+ adapterResponsePassthroughs: number
97
+ }
98
+
99
+ export interface InstallPluginRouteInterceptOptions {
100
+ /** The RAW webServer instance (`ctx.webServer[symbols.original]`). */
101
+ webServer: object
102
+ /** The local declaration registry (the `zenRemote` service's table). */
103
+ registry: BridgeRegistry
104
+ /** The client relay client; `pluginRequest` is the forwarding door. */
105
+ relay: RelayClient
106
+ /** The CURRENT handshake's server id, read live per call; a virtual id
107
+ * minted for another server (or before any handshake) refuses
108
+ * `server-changed`. */
109
+ getServerId: () => string | undefined
110
+ /** The PB-2 bridge verdicts, computed live per call; a plugin that is
111
+ * not bridged right now refuses with the status's own reason. */
112
+ getStatus: () => BridgeStatus[]
113
+ /** The validated adapters (PB-11), as the wiring registered them.
114
+ * Absent/empty — the pluggable default — means no adapter routes exist
115
+ * and this interception behaves exactly as before. */
116
+ adapters?: readonly NormalizedAdapter[]
117
+ /** The session id the browser is CURRENTLY looking at (T62's report,
118
+ * read live per call). A VIRTUAL id means a remote session's panel is
119
+ * the request's origin — the signal an adapter route's `deny` hook uses
120
+ * to refuse requests (like better-sidebar's `open.external`) that carry
121
+ * no session field of their own. Absent → deny hooks see `undefined`
122
+ * and judge conservatively. */
123
+ getCurrentSession?: () => string | undefined
124
+ /** Progress logging, wired to the context logger by index.ts. */
125
+ log?: (format: string, ...args: unknown[]) => void
126
+ }
127
+
128
+ export interface PluginRouteInterceptHandle {
129
+ /** Restore `match` — ONLY while the instance still holds our wrapper
130
+ * (chained-over wraps go inert instead). Idempotent. */
131
+ uninstall(): void
132
+ diagnostics(): PluginRouteInterceptDiagnostics
133
+ }
134
+
135
+ /** Longest failure ring kept for the status surface. */
136
+ const MAX_FAILURES = 10
137
+
138
+ /** What one declared-route hit carries from the match lookup into its
139
+ * surrogate handler. */
140
+ interface DeclaredHit {
141
+ plugin: string
142
+ version: string
143
+ digest: string
144
+ /** The declared route path — the exact string the forward uses. */
145
+ path: string
146
+ sessionParam: string
147
+ /** The EFFECTIVE forwardable methods (PB-9): the declared list, or
148
+ * `['GET']` when the route declared none. */
149
+ methods: string[]
150
+ /** The EFFECTIVE body cap (PB-9), in bytes. */
151
+ maxBody: number
152
+ }
153
+
154
+ export function installPluginRouteIntercept(options: InstallPluginRouteInterceptOptions): PluginRouteInterceptHandle {
155
+ // Shape FIRST, like every wrap: a caller that skipped checkWebServerShape
156
+ // (or a host whose shape changed between the check and this call) gets an
157
+ // INERT handle — nothing on the instance is touched — instead of a
158
+ // TypeError from a missing table midway through the install.
159
+ const shape = checkWebServerShape(options.webServer)
160
+ if (!shape.ok) {
161
+ options.log?.('plugin route intercept refused: %s', shape.reasons.join('; '))
162
+ return {
163
+ uninstall() {},
164
+ diagnostics(): PluginRouteInterceptDiagnostics {
165
+ return {
166
+ installed: false,
167
+ shape,
168
+ forwarded: 0,
169
+ refused: 0,
170
+ refusalsByReason: {},
171
+ recentFailures: [],
172
+ adapterForwarded: 0,
173
+ adapterResponseRewrites: 0,
174
+ adapterResponsePassthroughs: 0,
175
+ tunnelsCurrent: 0,
176
+ tunnelsOpened: 0,
177
+ tunnelsClosed: 0,
178
+ }
179
+ },
180
+ }
181
+ }
182
+ const { webServer, registry, relay, getServerId, getStatus, log } = options
183
+ const adapters = options.adapters ?? []
184
+ const getCurrentSession = options.getCurrentSession
185
+ const target = webServer as { match: (this: unknown, pathname: string) => unknown }
186
+ const savedMatch = target.match
187
+
188
+ let installed = true
189
+ let forwarded = 0
190
+ let refused = 0
191
+ let adapterForwarded = 0
192
+ let adapterResponseRewrites = 0
193
+ let adapterResponsePassthroughs = 0
194
+ let tunnelsCurrent = 0
195
+ let tunnelsOpened = 0
196
+ let tunnelsClosed = 0
197
+ const refusalsByReason: Record<string, number> = {}
198
+ const recentFailures: PluginRouteFailureRecord[] = []
199
+
200
+ /** The unbridged refusal written straight onto an upgrading socket
201
+ * (PB-10): the WebSocket handshake never happened, so the "response" is
202
+ * hand-written HTTP — same shape, same marker, then the socket dies
203
+ * without the local plugin ever seeing the connection. */
204
+ const answerUnbridgedSocket = (socket: Socket, plugin: string, reason: string): void => {
205
+ recordRefusal(plugin, reason)
206
+ log?.('plugin upgrade refused for %s: %s', plugin, reason)
207
+ const body = Buffer.from(JSON.stringify({ ok: false, error: { code: 'zr-plugin-unbridged', reason, plugin } }), 'utf8')
208
+ try {
209
+ socket.write(
210
+ `HTTP/1.1 503 Service Unavailable\r\n` +
211
+ `x-zen-remote: unbridged\r\n` +
212
+ `content-type: application/json; charset=utf-8\r\n` +
213
+ `content-length: ${body.length}\r\n` +
214
+ `connection: close\r\n` +
215
+ `\r\n`,
216
+ )
217
+ socket.end(body)
218
+ } catch {
219
+ // The socket was already gone.
220
+ }
221
+ }
222
+
223
+ const recordRefusal = (plugin: string, reason: string): void => {
224
+ refused += 1
225
+ refusalsByReason[reason] = (refusalsByReason[reason] ?? 0) + 1
226
+ if (recentFailures.length >= MAX_FAILURES) recentFailures.shift()
227
+ recentFailures.push({ time: new Date().toISOString(), plugin, reason })
228
+ }
229
+
230
+ /** The uniform unbridged 503: one shape for every refusal, the local
231
+ * plugin handler never reached. */
232
+ const answerUnbridged = (res: ServerResponse, plugin: string, reason: string): void => {
233
+ recordRefusal(plugin, reason)
234
+ log?.('plugin route refused for %s: %s', plugin, reason)
235
+ res.setHeader('x-zen-remote', 'unbridged')
236
+ res.setHeader('content-type', 'application/json; charset=utf-8')
237
+ res.writeHead(503)
238
+ res.end(JSON.stringify({ ok: false, error: { code: 'zr-plugin-unbridged', reason, plugin } }))
239
+ }
240
+
241
+ /** The original route handler when it is worth calling at all. */
242
+ const handlerOf = (route: unknown): ((req: IncomingMessage, res: ServerResponse) => Promise<void>) | undefined =>
243
+ route !== undefined && isRecord(route) && typeof (route as { handler?: unknown }).handler === 'function'
244
+ ? (route as { handler: (req: IncomingMessage, res: ServerResponse) => Promise<void> }).handler
245
+ : undefined
246
+
247
+ /** The declared route this pathname hits — EXACT equality only this task
248
+ * (a declared prefix does not cover longer paths; that arrives with
249
+ * PB-12). First plugin in registry order wins, deterministically. */
250
+ const declaredRouteOf = (pathname: string): DeclaredHit | undefined => {
251
+ for (const declaration of registry.list()) {
252
+ for (const route of declaration.routes) {
253
+ if (route.path === pathname) {
254
+ return {
255
+ plugin: declaration.plugin,
256
+ version: declaration.version,
257
+ digest: declaration.digest,
258
+ path: route.path,
259
+ sessionParam: route.sessionParam,
260
+ methods: routeMethodsOf(route),
261
+ maxBody: routeMaxBodyOf(route),
262
+ }
263
+ }
264
+ }
265
+ }
266
+ return undefined
267
+ }
268
+
269
+ /** The surrogate handler: ONE query parse, the virtual-id gate, the
270
+ * unbridged walls in contract order, then the relay round-trip. */
271
+ const surrogateHandler = (declared: DeclaredHit, originalHandler: ((req: IncomingMessage, res: ServerResponse) => Promise<void>) | undefined) =>
272
+ async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
273
+ const passthrough = async (): Promise<void> => {
274
+ if (originalHandler !== undefined) {
275
+ await originalHandler(req, res)
276
+ return
277
+ }
278
+ res.writeHead(404)
279
+ res.end()
280
+ }
281
+ // Chained-over: inert passthrough, exactly the posture of the other
282
+ // intercepts.
283
+ if (!installed) {
284
+ await passthrough()
285
+ return
286
+ }
287
+ // ONE parse of the query (the session parameter's home — `match` only
288
+ // ever saw the pathname): everything below reads THIS parse.
289
+ let searchParams: URLSearchParams
290
+ try {
291
+ searchParams = new URL(req.url ?? '/', 'http://local').searchParams
292
+ } catch {
293
+ answerUnbridged(res, declared.plugin, 'bad-query')
294
+ return
295
+ }
296
+ const values = searchParams.getAll(declared.sessionParam)
297
+ // The parameter appearing MORE than once is a shape violation the
298
+ // bridge refuses (bad-query) — never a passthrough, and never a
299
+ // forward of whichever duplicate a parser happens to keep. Its TOTAL
300
+ // absence, though, is the LOCAL route's business (a whole-machine
301
+ // route may simply not take one).
302
+ if (values.length > 1) {
303
+ answerUnbridged(res, declared.plugin, 'bad-query')
304
+ return
305
+ }
306
+ const value = values.length === 1 ? values[0] : undefined
307
+ // A LOCAL id (or none at all): the original route's business — its
308
+ // handler runs exactly as if we were never here.
309
+ if (value === undefined || value === '' || !value.startsWith('zr~')) {
310
+ await passthrough()
311
+ return
312
+ }
313
+ const parts = fromVirtual(value)
314
+ if (parts === undefined || parts.id === '') {
315
+ answerUnbridged(res, declared.plugin, 'bad-query')
316
+ return
317
+ }
318
+ if (parts.serverId !== getServerId()) {
319
+ answerUnbridged(res, declared.plugin, 'server-changed')
320
+ return
321
+ }
322
+ // PB-9: the method must be one the route declared — the write surface
323
+ // is exactly as explicit as the read one was in 2.1, and an undeclared
324
+ // method never reaches the local plugin OR the relay.
325
+ const method = req.method ?? 'GET'
326
+ if (!declared.methods.includes(method)) {
327
+ answerUnbridged(res, declared.plugin, 'method-not-bridged')
328
+ return
329
+ }
330
+ // PB-9: the body gate, BEFORE a byte moves — a declared
331
+ // Content-Length past the route's `maxBody` is refused locally (the
332
+ // relay would refuse it at the streaming count anyway; refusing here
333
+ // saves the round-trip and keeps the plugin out of it entirely).
334
+ const hasBody = method !== 'GET' && method !== 'HEAD'
335
+ const declaredLength = Number(req.headers['content-length'])
336
+ if (hasBody && Number.isFinite(declaredLength) && declaredLength > declared.maxBody) {
337
+ res.setHeader('content-type', 'application/json; charset=utf-8')
338
+ res.writeHead(413)
339
+ res.end(JSON.stringify({ ok: false, error: { code: 'zr-plugin-body-too-large' } }))
340
+ recordRefusal(declared.plugin, 'body-too-large')
341
+ log?.('plugin request for %s refused locally: body-too-large (%s declared)', declared.path, req.headers['content-length'])
342
+ return
343
+ }
344
+ const status = getStatus().find((entry) => entry.plugin === declared.plugin)
345
+ if (status === undefined || !status.bridged) {
346
+ answerUnbridged(res, declared.plugin, status?.reason ?? 'not-on-server')
347
+ return
348
+ }
349
+ forwarded += 1
350
+ // Logged HERE, after the virtual-id gate (PB-5-fix): a local panel
351
+ // polling its route every second must not log a line a second — the
352
+ // bridge only speaks when a request actually leaves for the relay (or
353
+ // is refused below, which answerUnbridged logs).
354
+ log?.('plugin route %s forwards for %s', declared.path, declared.plugin)
355
+ // The session id rides the CHECKED parse, restored to its ORIGINAL
356
+ // form, re-serialized once — what was judged is what is forwarded.
357
+ searchParams.set(declared.sessionParam, parts.id)
358
+ const controller = new AbortController()
359
+ // The counting gate's failure has its OWN abort signal (PB-9-fix, the
360
+ // mirror of the relay route's bodyGateError): tripping the gate is a
361
+ // LOCAL verdict — it must tear the relay round-trip down AND be
362
+ // readable in the catch below without depending on the error MESSAGE
363
+ // surviving undici's fetch rejection. It also tells the relay client
364
+ // (through the aborted signal) that this is a caller abort, never a
365
+ // transport death.
366
+ let bodyGateFailed = false
367
+ const bodyAbort = new AbortController()
368
+ const forwardSignal = AbortSignal.any([controller.signal, bodyAbort.signal])
369
+ const onClientGone = (): void => {
370
+ if (!res.writableEnded) controller.abort()
371
+ }
372
+ res.on('close', onClientGone)
373
+ try {
374
+ const accept = typeof req.headers.accept === 'string' ? req.headers.accept : undefined
375
+ const range = typeof req.headers.range === 'string' ? req.headers.range : undefined
376
+ const requestContentType = typeof req.headers['content-type'] === 'string' ? req.headers['content-type'] : undefined
377
+ // PB-9: the request body streams through a counting Transform —
378
+ // node's pipe owns the backpressure, and the route's `maxBody` is
379
+ // enforced ON THE STREAM for a chunked body (no Content-Length for
380
+ // the pre-check above to read). Past the cap the gate errors, which
381
+ // aborts the relay round-trip mid-flight and answers the local 413
382
+ // below (the browser's headers are not out — nothing ever started).
383
+ let bodyStream: ReadableStream<Uint8Array> | null = null
384
+ if (hasBody) {
385
+ let pumped = 0
386
+ const gate = new Transform({
387
+ transform(chunk: Buffer, _encoding, callback) {
388
+ pumped += chunk.length
389
+ if (pumped > declared.maxBody) {
390
+ callback(new Error('zr-plugin-body-too-large'))
391
+ return
392
+ }
393
+ callback(null, chunk)
394
+ },
395
+ })
396
+ req.on('error', (error) => gate.destroy(error))
397
+ gate.on('error', () => {
398
+ if (!bodyGateFailed) bodyGateFailed = true
399
+ bodyAbort.abort()
400
+ })
401
+ // The fetch body must be a WEB ReadableStream (undici's contract);
402
+ // Readable.toWeb adapts the counting gate, preserving backpressure.
403
+ bodyStream = Readable.toWeb(gate) as ReadableStream<Uint8Array>
404
+ req.pipe(gate)
405
+ }
406
+ const response = await relay.pluginRequest(
407
+ {
408
+ plugin: declared.plugin,
409
+ version: declared.version,
410
+ digest: declared.digest,
411
+ path: declared.path,
412
+ query: searchParams.toString(),
413
+ method,
414
+ ...(hasBody ? { body: bodyStream } : {}),
415
+ ...(hasBody && requestContentType !== undefined ? { contentType: requestContentType } : {}),
416
+ ...(hasBody && Number.isFinite(declaredLength) && declaredLength > 0 ? { contentLength: declaredLength } : {}),
417
+ ...(accept !== undefined ? { accept } : {}),
418
+ ...(range !== undefined ? { range } : {}),
419
+ },
420
+ forwardSignal,
421
+ )
422
+ // A relay-side refusal travels its OWN marker header (PB-3); the
423
+ // browser sees the uniform unbridged shape with that code as the
424
+ // reason. The relay's JSON body is disposed unread.
425
+ const refusal = response.headers.get('x-zen-remote-error')
426
+ if (refusal !== null) {
427
+ await response.body?.cancel().catch(() => {})
428
+ answerUnbridged(res, declared.plugin, refusal)
429
+ return
430
+ }
431
+ const contentType = response.headers.get('content-type')
432
+ const contentLength = response.headers.get('content-length')
433
+ const cacheControl = response.headers.get('cache-control')
434
+ res.writeHead(response.status, {
435
+ ...(contentType !== null ? { 'content-type': contentType } : {}),
436
+ ...(contentLength !== null ? { 'content-length': contentLength } : {}),
437
+ ...(cacheControl !== null ? { 'cache-control': cacheControl } : {}),
438
+ })
439
+ // Stream the body through with real backpressure; a mid-stream
440
+ // failure with headers already out can only cut the response.
441
+ const reader = response.body?.getReader()
442
+ if (reader !== undefined) {
443
+ while (true) {
444
+ const { done, value } = await reader.read()
445
+ if (done) break
446
+ if (!res.write(value)) {
447
+ await new Promise<void>((resolve) => {
448
+ const settled = (): void => {
449
+ res.off('drain', settled)
450
+ res.off('close', settled)
451
+ resolve()
452
+ }
453
+ res.once('drain', settled)
454
+ res.once('close', settled)
455
+ })
456
+ }
457
+ }
458
+ }
459
+ res.end()
460
+ } catch (error) {
461
+ // The gate's local flag FIRST (PB-9-fix): the counting gate tripped
462
+ // mid-forward — the chunked body outgrew the route's `maxBody` while
463
+ // the relay round-trip was still being written. The browser's
464
+ // headers are not out, so the local 413 still fits — same shape the
465
+ // pre-check above answers. The flag is local on purpose: the gate's
466
+ // error cannot be relied on to survive undici's fetch rejection
467
+ // wrapper, and the relay client has (correctly) NOT judged the link
468
+ // offline for a caller abort — answering from the wire's error would
469
+ // misreport this as 503 offline.
470
+ if (bodyGateFailed && !res.headersSent) {
471
+ res.setHeader('content-type', 'application/json; charset=utf-8')
472
+ res.writeHead(413)
473
+ res.end(JSON.stringify({ ok: false, error: { code: 'zr-plugin-body-too-large' } }))
474
+ recordRefusal(declared.plugin, 'body-too-large')
475
+ return
476
+ }
477
+ if (controller.signal.aborted || res.destroyed || res.headersSent) {
478
+ // The browser is gone (its abort tore the relay round-trip down)
479
+ // or the body already started: the status cannot change, so the
480
+ // response is cut.
481
+ try {
482
+ res.destroy()
483
+ } catch {
484
+ // Already gone.
485
+ }
486
+ return
487
+ }
488
+ const code = typeof (error as { code?: unknown } | null)?.code === 'string' ? (error as { code: string }).code : 'offline'
489
+ answerUnbridged(res, declared.plugin, code === 'aborted' ? 'offline' : code)
490
+ } finally {
491
+ res.off('close', onClientGone)
492
+ }
493
+ }
494
+
495
+ // ---- PB-11: the adapter interception ---------------------------------------
496
+ //
497
+ // An adapter (src/adapters/, validated into a NormalizedAdapter by the
498
+ // wiring) claims routes of a NON-cooperative plugin: the match lookup
499
+ // below hands one of those requests to a surrogate that locates the
500
+ // session ids (query / path segment / JSON body), classifies the request
501
+ // — every id virtual → forward, none → local passthrough, a mix → the
502
+ // uniform 503 (docs/adr/0001: a remote session never reaches a local
503
+ // plugin) — rewrites the ids to their real halves and forwards through
504
+ // the SAME relay door the cooperative surrogate uses. On the way back a
505
+ // `client.response` hook maps real ids to virtual form inside a buffered
506
+ // JSON answer (8 MB cap; past it the answer passes through untouched).
507
+
508
+ /** The adapter route this pathname hits, if any: only an adapter that
509
+ * STILL owns its plugin's registry entry qualifies — a plugin's own
510
+ * cooperative declaration always wins, and the adapter yields silently
511
+ * here (the registration-time yield logged a line already). */
512
+ const adapterRouteOf = (pathname: string): { adapter: NormalizedAdapter; route: NormalizedAdapterRoute } | undefined => {
513
+ if (!installed || adapters.length === 0) return undefined
514
+ for (const adapter of adapters) {
515
+ if (!adapterOwnsRegistryEntry(adapter, registry.get(adapter.plugin))) continue
516
+ for (const route of adapter.routes) {
517
+ if (route.local) continue
518
+ if (adapterPathMatches(route, pathname)) return { adapter, route }
519
+ }
520
+ }
521
+ return undefined
522
+ }
523
+
524
+ /** The adapter surrogate: locate → classify → walls → rewrite → hooks →
525
+ * the relay round-trip, then the buffered JSON rewrite on the way back. */
526
+ const adapterSurrogateHandler = (hit: { adapter: NormalizedAdapter; route: NormalizedAdapterRoute }, originalHandler: ((req: IncomingMessage, res: ServerResponse) => Promise<void>) | undefined) =>
527
+ async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
528
+ const { adapter, route } = hit
529
+ const plugin = adapter.plugin
530
+ // The local passthrough. A body-locating route has ALREADY consumed
531
+ // the request body by the time this runs, so the replay hands the
532
+ // buffered bytes back behind the original request's metadata — the
533
+ // shape a local handler actually reads (method/url/headers/pipe).
534
+ const passthrough = async (replayBody?: Buffer): Promise<void> => {
535
+ if (originalHandler === undefined) {
536
+ res.writeHead(404)
537
+ res.end()
538
+ return
539
+ }
540
+ if (replayBody !== undefined) {
541
+ const replay = new PassThrough()
542
+ replay.end(replayBody)
543
+ Object.assign(replay, { method: req.method, url: req.url, headers: req.headers })
544
+ await originalHandler(replay as unknown as IncomingMessage, res)
545
+ return
546
+ }
547
+ await originalHandler(req, res)
548
+ }
549
+ // Chained-over: inert passthrough, exactly the posture of the other
550
+ // intercepts.
551
+ if (!installed) {
552
+ await passthrough()
553
+ return
554
+ }
555
+ let controller: AbortController | undefined
556
+ const onClientGone = (): void => {
557
+ if (controller !== undefined && !res.writableEnded) controller.abort()
558
+ }
559
+ res.on('close', onClientGone)
560
+ try {
561
+ let url: URL
562
+ try {
563
+ url = new URL(req.url ?? '/', 'http://local')
564
+ } catch {
565
+ answerUnbridged(res, plugin, 'bad-query')
566
+ return
567
+ }
568
+ const pathname = url.pathname
569
+ const searchParams = url.searchParams
570
+ const method = req.method ?? 'GET'
571
+ const hasBody = method !== 'GET' && method !== 'HEAD'
572
+
573
+ // The route's deny hook runs BEFORE any id locating: a request the
574
+ // adapter refuses on path grounds (better-sidebar's open.external
575
+ // from a remote panel, the side-chat methods) answers here — these
576
+ // requests often carry NO session field, so the classify below
577
+ // would otherwise read them as local and let them through.
578
+ if (route.deny !== undefined) {
579
+ const denial = route.deny({ pathname, method, currentSessionId: getCurrentSession?.() })
580
+ if (denial !== undefined) {
581
+ answerUnbridged(res, plugin, denial)
582
+ return
583
+ }
584
+ }
585
+
586
+ // A body-locating route buffers the request body up front (bounded
587
+ // by the route's own maxBody — the cooperative 413 shape, answered
588
+ // locally, the plugin never reached). Any other locator's body
589
+ // streams through the cooperative counting gate below.
590
+ let bufferedBody: Buffer | undefined
591
+ if (hasBody && route.locator.in === 'json-body') {
592
+ const chunks: Buffer[] = []
593
+ let size = 0
594
+ let over = false
595
+ for await (const chunk of req) {
596
+ size += (chunk as Buffer).length
597
+ if (size > route.maxBody) {
598
+ over = true
599
+ break
600
+ }
601
+ chunks.push(Buffer.from(chunk as Uint8Array))
602
+ }
603
+ if (over) {
604
+ res.setHeader('content-type', 'application/json; charset=utf-8')
605
+ res.writeHead(413)
606
+ res.end(JSON.stringify({ ok: false, error: { code: 'zr-plugin-body-too-large' } }))
607
+ recordRefusal(plugin, 'body-too-large')
608
+ return
609
+ }
610
+ bufferedBody = Buffer.concat(chunks)
611
+ }
612
+ // A present body must be a JSON object when the route locates in
613
+ // the body: a remote request with a corrupted body refuses rather
614
+ // than falling through to the local plugin.
615
+ let parsedBody: unknown
616
+ let bodyGiven = false
617
+ if (hasBody && route.locator.in === 'json-body' && bufferedBody !== undefined && bufferedBody.length > 0) {
618
+ try {
619
+ parsedBody = JSON.parse(bufferedBody.toString('utf8'))
620
+ bodyGiven = true
621
+ } catch {
622
+ answerUnbridged(res, plugin, 'bad-query')
623
+ return
624
+ }
625
+ }
626
+
627
+ // The remote/local verdict on the ids as they arrived. A duplicated
628
+ // query id refuses rather than silently collapsing (PB-12 review).
629
+ let verdict: AdapterRemoteVerdict
630
+ try {
631
+ verdict = classifyAdapterRequest(route, pathname, searchParams, bodyGiven ? parsedBody : undefined)
632
+ } catch (error) {
633
+ if (error instanceof AdapterMultiValueQueryError) {
634
+ answerUnbridged(res, plugin, 'bad-query')
635
+ return
636
+ }
637
+ throw error
638
+ }
639
+ if (verdict.kind === 'local') {
640
+ await passthrough(bufferedBody)
641
+ return
642
+ }
643
+ if (verdict.kind === 'mixed') {
644
+ answerUnbridged(res, plugin, 'bad-query')
645
+ return
646
+ }
647
+ // Remote: the route's method wall, then per-id server walls.
648
+ if (!route.methods.includes(method)) {
649
+ answerUnbridged(res, plugin, 'method-not-bridged')
650
+ return
651
+ }
652
+ const serverId = getServerId()
653
+ for (const idHit of verdict.hits) {
654
+ const parts = fromVirtual(idHit.value)
655
+ if (parts === undefined || parts.id === '') {
656
+ answerUnbridged(res, plugin, 'bad-query')
657
+ return
658
+ }
659
+ if (parts.serverId !== serverId) {
660
+ answerUnbridged(res, plugin, 'server-changed')
661
+ return
662
+ }
663
+ }
664
+ // The bridge verdicts (version skew, consent, offline…) judged from
665
+ // the SAME live table the cooperative surrogate reads — the
666
+ // adapter's registry entry makes the plugin indistinguishable from
667
+ // a cooperative one here.
668
+ const status = getStatus().find((entry) => entry.plugin === plugin)
669
+ if (status === undefined || !status.bridged) {
670
+ answerUnbridged(res, plugin, status?.reason ?? 'not-on-server')
671
+ return
672
+ }
673
+
674
+ // Real ids in, coordinates rewritten.
675
+ const rewrite = rewriteAdapterRequest(route, pathname, searchParams, bodyGiven ? parsedBody : undefined, verdict.hits, (virtualId) => fromVirtual(virtualId)!.id)
676
+ // The client hook: refuse, or replace the parsed body wholesale.
677
+ // The hook sees the REWRITTEN body — the ids are already real (the
678
+ // design's order: locate → rewrite → hook → forward).
679
+ const hook = adapter.hooks.client?.request
680
+ if (hook !== undefined) {
681
+ const hookedBody = bodyGiven && rewrite.bodyText !== undefined ? JSON.parse(rewrite.bodyText) : bodyGiven ? parsedBody : undefined
682
+ let hookVerdict: AdapterVerdict
683
+ try {
684
+ hookVerdict = hook({ method, pathname, route, query: rewrite.query, body: hookedBody })
685
+ } catch {
686
+ answerUnbridged(res, plugin, 'internal')
687
+ return
688
+ }
689
+ if (!hookVerdict.allow) {
690
+ answerUnbridged(res, plugin, hookVerdict.reason)
691
+ return
692
+ }
693
+ if (hookedBody !== undefined && hookVerdict.body !== undefined) {
694
+ rewrite.bodyText = JSON.stringify(hookVerdict.body)
695
+ }
696
+ }
697
+
698
+ controller = new AbortController()
699
+ adapterForwarded += 1
700
+ // The forward. A body-locating route sends its (possibly rewritten)
701
+ // buffer; any other body rides the cooperative counting gate.
702
+ const accept = typeof req.headers.accept === 'string' ? req.headers.accept : undefined
703
+ const range = typeof req.headers.range === 'string' ? req.headers.range : undefined
704
+ const requestContentType = typeof req.headers['content-type'] === 'string' ? req.headers['content-type'] : undefined
705
+ let bodyStream: ReadableStream<Uint8Array> | null = null
706
+ let contentLength: number | undefined
707
+ if (hasBody && route.locator.in === 'json-body') {
708
+ const payload = rewrite.bodyText !== undefined ? Buffer.from(rewrite.bodyText, 'utf8') : bufferedBody ?? Buffer.alloc(0)
709
+ contentLength = payload.length
710
+ bodyStream = Readable.toWeb(Readable.from(payload)) as ReadableStream<Uint8Array>
711
+ } else if (hasBody) {
712
+ let pumped = 0
713
+ const gate = new Transform({
714
+ transform(chunk: Buffer, _encoding, callback) {
715
+ pumped += chunk.length
716
+ if (pumped > route.maxBody) {
717
+ callback(new Error('zr-plugin-body-too-large'))
718
+ return
719
+ }
720
+ callback(null, chunk)
721
+ },
722
+ })
723
+ gate.on('error', () => controller?.abort())
724
+ bodyStream = Readable.toWeb(gate) as ReadableStream<Uint8Array>
725
+ req.pipe(gate)
726
+ }
727
+ const response = await relay.pluginRequest(
728
+ {
729
+ plugin,
730
+ version: adapter.declaration.version,
731
+ digest: adapter.digest,
732
+ path: rewrite.pathname,
733
+ query: rewrite.query.toString(),
734
+ method,
735
+ ...(bodyStream !== null ? { body: bodyStream } : {}),
736
+ ...(bodyStream !== null && requestContentType !== undefined ? { contentType: requestContentType } : {}),
737
+ ...(bodyStream !== null && contentLength !== undefined ? { contentLength } : {}),
738
+ ...(accept !== undefined ? { accept } : {}),
739
+ ...(range !== undefined ? { range } : {}),
740
+ },
741
+ controller.signal,
742
+ )
743
+ const refusal = response.headers.get('x-zen-remote-error')
744
+ if (refusal !== null) {
745
+ await response.body?.cancel().catch(() => {})
746
+ answerUnbridged(res, plugin, refusal)
747
+ return
748
+ }
749
+ const contentType = response.headers.get('content-type')
750
+ const responseLength = response.headers.get('content-length')
751
+ const cacheControl = response.headers.get('cache-control')
752
+ // The adapter header allowlist (PB-12): beyond the three cooperative
753
+ // headers, the answers adapters carry may need their own — the HTML
754
+ // preview's CSP sandbox, download dispositions, range headers. The
755
+ // plugin wrote them; the browser needs them.
756
+ const allowlistedHeaders = ADAPTER_RESPONSE_HEADER_ALLOWLIST.flatMap((name) => {
757
+ const value = response.headers.get(name)
758
+ return value !== null ? [{ [name]: value }] : []
759
+ })
760
+ const responseHook = adapter.hooks.client?.response
761
+ const rewriteCandidate = responseHook !== undefined && !route.stream && contentType !== null && contentType.includes('application/json')
762
+ if (!rewriteCandidate) {
763
+ // The verbatim pump: chunk-for-chunk with real backpressure, the
764
+ // cooperative surrogate's discipline.
765
+ res.writeHead(response.status, {
766
+ ...(contentType !== null ? { 'content-type': contentType } : {}),
767
+ ...(responseLength !== null ? { 'content-length': responseLength } : {}),
768
+ ...(cacheControl !== null ? { 'cache-control': cacheControl } : {}),
769
+ ...Object.assign({}, ...allowlistedHeaders),
770
+ })
771
+ const reader = response.body?.getReader()
772
+ if (reader !== undefined) {
773
+ while (true) {
774
+ const { done, value } = await reader.read()
775
+ if (done) break
776
+ if (!res.write(value)) {
777
+ await new Promise<void>((resolve) => {
778
+ const settled = (): void => {
779
+ res.off('drain', settled)
780
+ res.off('close', settled)
781
+ resolve()
782
+ }
783
+ res.once('drain', settled)
784
+ res.once('close', settled)
785
+ })
786
+ }
787
+ }
788
+ }
789
+ res.end()
790
+ return
791
+ }
792
+ // The buffered rewrite: the whole answer (≤ 8 MB), parsed, hooked,
793
+ // re-serialized with a corrected content-length. Past the cap — or
794
+ // on a parse/hook failure — the answer passes through untouched.
795
+ const reader = response.body!.getReader()
796
+ const chunks: Buffer[] = []
797
+ let size = 0
798
+ let overflow = false
799
+ while (true) {
800
+ const { done, value } = await reader.read()
801
+ if (done) break
802
+ size += value.length
803
+ // The over-cap chunk ITSELF is body content — it lands in the
804
+ // passthrough buffer too; only the REWRITE is cancelled.
805
+ chunks.push(Buffer.from(value))
806
+ if (size > MAX_ADAPTER_RESPONSE_BUFFER_BYTES) {
807
+ overflow = true
808
+ break
809
+ }
810
+ }
811
+ if (overflow) {
812
+ adapterResponsePassthroughs += 1
813
+ res.writeHead(response.status, {
814
+ ...(contentType !== null ? { 'content-type': contentType } : {}),
815
+ ...(responseLength !== null ? { 'content-length': responseLength } : {}),
816
+ ...(cacheControl !== null ? { 'cache-control': cacheControl } : {}),
817
+ ...Object.assign({}, ...allowlistedHeaders),
818
+ })
819
+ for (const chunk of chunks) {
820
+ if (!res.write(chunk)) {
821
+ await new Promise<void>((resolve) => {
822
+ const settled = (): void => {
823
+ res.off('drain', settled)
824
+ res.off('close', settled)
825
+ resolve()
826
+ }
827
+ res.once('drain', settled)
828
+ res.once('close', settled)
829
+ })
830
+ }
831
+ }
832
+ while (true) {
833
+ const { done, value } = await reader.read()
834
+ if (done) break
835
+ if (!res.write(value)) {
836
+ await new Promise<void>((resolve) => {
837
+ const settled = (): void => {
838
+ res.off('drain', settled)
839
+ res.off('close', settled)
840
+ resolve()
841
+ }
842
+ res.once('drain', settled)
843
+ res.once('close', settled)
844
+ })
845
+ }
846
+ }
847
+ res.end()
848
+ return
849
+ }
850
+ const text = Buffer.concat(chunks).toString('utf8')
851
+ let rewrittenBody: unknown
852
+ try {
853
+ rewrittenBody = responseHook({
854
+ status: response.status,
855
+ pathname,
856
+ body: JSON.parse(text),
857
+ virtualize: (id: string) => toVirtual(serverId ?? '', id),
858
+ isVirtual: (value: unknown) => isVirtual(value),
859
+ })
860
+ } catch {
861
+ adapterResponsePassthroughs += 1
862
+ const passthroughBody = Buffer.from(text, 'utf8')
863
+ res.writeHead(response.status, {
864
+ ...(contentType !== null ? { 'content-type': contentType } : {}),
865
+ ...(cacheControl !== null ? { 'cache-control': cacheControl } : {}),
866
+ ...Object.assign({}, ...allowlistedHeaders),
867
+ 'content-length': String(passthroughBody.length),
868
+ })
869
+ res.end(passthroughBody)
870
+ return
871
+ }
872
+ adapterResponseRewrites += 1
873
+ const outBody = Buffer.from(JSON.stringify(rewrittenBody), 'utf8')
874
+ res.writeHead(response.status, {
875
+ ...(contentType !== null ? { 'content-type': contentType } : {}),
876
+ ...(cacheControl !== null ? { 'cache-control': cacheControl } : {}),
877
+ ...Object.assign({}, ...allowlistedHeaders),
878
+ 'content-length': String(outBody.length),
879
+ })
880
+ res.end(outBody)
881
+ } catch (error) {
882
+ if (controller?.signal.aborted || res.destroyed || res.headersSent) {
883
+ try {
884
+ res.destroy()
885
+ } catch {
886
+ // Already gone.
887
+ }
888
+ return
889
+ }
890
+ const code = typeof (error as { code?: unknown } | null)?.code === 'string' ? (error as { code: string }).code : 'offline'
891
+ answerUnbridged(res, plugin, code === 'aborted' ? 'offline' : code)
892
+ } finally {
893
+ res.off('close', onClientGone)
894
+ }
895
+ }
896
+
897
+ /** The wrapped match: original verdict first, then the adapter routes
898
+ * (PB-11 — BEFORE the declaration lookup: the adapter's registry entry
899
+ * IS a declaration, and the cooperative surrogate's query-sessionParam
900
+ * logic is not how an adapter request is judged), then the exact
901
+ * declaration lookup — an undeclared pathname returns whatever the
902
+ * original match answered, untouched. */
903
+ const wrappedMatch = function match(this: unknown, pathname: string): unknown {
904
+ const route = savedMatch.call(this, pathname)
905
+ if (!installed) return route
906
+ if (typeof pathname !== 'string' || !pathname.startsWith('/')) return route
907
+ const adapterHit = adapterRouteOf(pathname)
908
+ if (adapterHit !== undefined) {
909
+ return {
910
+ kind: 'exact',
911
+ path: pathname,
912
+ handler: adapterSurrogateHandler(adapterHit, handlerOf(route)),
913
+ }
914
+ }
915
+ const declared = declaredRouteOf(pathname)
916
+ if (declared !== undefined) {
917
+ return {
918
+ kind: 'exact',
919
+ path: pathname,
920
+ handler: surrogateHandler(declared, handlerOf(route)),
921
+ }
922
+ }
923
+ return route
924
+ }
925
+
926
+ // ---- PB-10: the WebSocket upgrade interception -----------------------------
927
+ //
928
+ // The host's upgrade handler reads `this.upgrades.get(pathname)` (RT
929
+ // dsh-host-webserver — the probe verified the shape on real instances).
930
+ // Shadowing that ONE method on the raw instance intercepts every upgrade
931
+ // lookup, including upgrades registered after us. Declared `upgrade`
932
+ // routes with a virtual session id become a byte tunnel to the server:
933
+ // the browser's WebSocket speaks to THIS process, the bytes travel the
934
+ // tunnel, and the REAL handshake (101, frames, close) is produced by the
935
+ // plugin route on the server machine — neither end parses WebSocket
936
+ // frames (docs/adr/0002).
937
+
938
+ const targetWithUpgrades = webServer as { upgrades: Map<string, unknown> }
939
+ const savedUpgradesGet = targetWithUpgrades.upgrades.get
940
+ const upgradeRoutesOf = (pathname: string): { plugin: string; version: string; digest: string; path: string; sessionParam: string } | undefined => {
941
+ if (!installed) return undefined
942
+ for (const declaration of registry.list()) {
943
+ for (const route of declaration.routes) {
944
+ if (route.path === pathname && routeUpgradesOf(route)) {
945
+ return {
946
+ plugin: declaration.plugin,
947
+ version: declaration.version,
948
+ digest: declaration.digest,
949
+ path: route.path,
950
+ sessionParam: route.sessionParam,
951
+ }
952
+ }
953
+ }
954
+ }
955
+ return undefined
956
+ }
957
+
958
+ /** The tunnel's surrogate upgrade handler. Every outcome — refusal,
959
+ * teardown, the byte pumping — owns the socket's whole remaining life. */
960
+ const surrogateUpgrade = (declared: { plugin: string; version: string; digest: string; path: string; sessionParam: string }, originalHandler: unknown) =>
961
+ (req: IncomingMessage, socket: Socket, head: Buffer): void => {
962
+ // The host never awaits an upgrade handler — the async body runs on
963
+ // its own and every failure path owns the socket's teardown.
964
+ void (async () => {
965
+ log?.('upgrade entered for %s', declared.path)
966
+ const socketGone = (): boolean => socket.destroyed || !socket.writable
967
+ const passthrough = (): void => {
968
+ if (typeof originalHandler === 'function') {
969
+ originalHandler(req, socket, head)
970
+ return
971
+ }
972
+ try {
973
+ socket.destroy()
974
+ } catch {
975
+ // Already gone.
976
+ }
977
+ }
978
+ try {
979
+ // ONE parse of the query — the session parameter's home.
980
+ let searchParams: URLSearchParams
981
+ try {
982
+ searchParams = new URL(req.url ?? '/', 'http://local').searchParams
983
+ } catch {
984
+ answerUnbridgedSocket(socket, declared.plugin, 'bad-query')
985
+ return
986
+ }
987
+ const values = searchParams.getAll(declared.sessionParam)
988
+ if (values.length > 1) {
989
+ answerUnbridgedSocket(socket, declared.plugin, 'bad-query')
990
+ return
991
+ }
992
+ const value = values.length === 1 ? values[0] : undefined
993
+ // A LOCAL session (or no parameter at all): the original upgrade
994
+ // handler's business, the head bytes untouched.
995
+ if (value === undefined || value === '' || !value.startsWith('zr~')) {
996
+ passthrough()
997
+ return
998
+ }
999
+ const parts = fromVirtual(value)
1000
+ if (parts === undefined || parts.id === '') {
1001
+ answerUnbridgedSocket(socket, declared.plugin, 'bad-query')
1002
+ return
1003
+ }
1004
+ if (parts.serverId !== getServerId()) {
1005
+ answerUnbridgedSocket(socket, declared.plugin, 'server-changed')
1006
+ return
1007
+ }
1008
+ // The PB-2 bridge verdict, the SAME wall the HTTP surrogate raises:
1009
+ // the tunnel opens only for a plugin bridged RIGHT NOW — an absent
1010
+ // or unbridged status refuses with its own reason, and the local
1011
+ // plugin handler is never reached.
1012
+ const bridgeStatus = getStatus().find((entry) => entry.plugin === declared.plugin)
1013
+ if (bridgeStatus === undefined || !bridgeStatus.bridged) {
1014
+ answerUnbridgedSocket(socket, declared.plugin, bridgeStatus?.reason ?? 'not-on-server')
1015
+ return
1016
+ }
1017
+ // ---- bridged: open the byte tunnel (PB-10) ---------------------
1018
+ const controller = new AbortController()
1019
+ socket.on('close', () => controller.abort())
1020
+ const wsKey = typeof req.headers['sec-websocket-key'] === 'string' ? req.headers['sec-websocket-key'] : undefined
1021
+ const wsVersion = typeof req.headers['sec-websocket-version'] === 'string' ? req.headers['sec-websocket-version'] : undefined
1022
+ if (wsKey === undefined || wsVersion === undefined) {
1023
+ answerUnbridgedSocket(socket, declared.plugin, 'bad-query')
1024
+ return
1025
+ }
1026
+ const protocol = typeof req.headers['sec-websocket-protocol'] === 'string' ? req.headers['sec-websocket-protocol'] : undefined
1027
+ const extensions = typeof req.headers['sec-websocket-extensions'] === 'string' ? req.headers['sec-websocket-extensions'] : undefined
1028
+ const query = new URLSearchParams(searchParams)
1029
+ query.set(declared.sessionParam, parts.id)
1030
+ let tunnel: { tunnel: string; frames: AsyncIterable<Uint8Array> }
1031
+ try {
1032
+ log?.('opening tunnel for %s', declared.path)
1033
+ tunnel = await relay.pluginTunnelOpen(
1034
+ {
1035
+ plugin: declared.plugin,
1036
+ version: declared.version,
1037
+ digest: declared.digest,
1038
+ path: declared.path,
1039
+ query: query.toString(),
1040
+ wsHeaders: {
1041
+ 'sec-websocket-key': wsKey,
1042
+ 'sec-websocket-version': wsVersion,
1043
+ ...(protocol !== undefined ? { 'sec-websocket-protocol': protocol } : {}),
1044
+ ...(extensions !== undefined ? { 'sec-websocket-extensions': extensions } : {}),
1045
+ },
1046
+ ...(head.length > 0 ? { head: head.toString('base64') } : {}),
1047
+ },
1048
+ controller.signal,
1049
+ )
1050
+ } catch (error) {
1051
+ // The relay refused the open (unbridged walls arrive with their
1052
+ // code in the error). The browser's handshake never completes.
1053
+ const rawCode = (error as { code?: unknown } | null)?.code
1054
+ const code = typeof rawCode === 'string' && rawCode !== '' ? rawCode : 'offline'
1055
+ answerUnbridgedSocket(socket, declared.plugin, code)
1056
+ return
1057
+ }
1058
+ tunnelsCurrent += 1
1059
+ tunnelsOpened += 1
1060
+ log?.('tunnel open for %s: %s', declared.path, tunnel.tunnel)
1061
+ // ---- downstream: tunnel frames → the browser socket -------------
1062
+ void (async () => {
1063
+ try {
1064
+ for await (const chunk of tunnel.frames) {
1065
+ if (socketGone()) break
1066
+ if (!socket.write(chunk)) {
1067
+ // Real backpressure: the NEXT frame is not pulled until the
1068
+ // browser socket drained — the tunnel's server half pauses
1069
+ // its upstream read in turn (the same discipline the NDJSON
1070
+ // streams run).
1071
+ await new Promise<void>((resolve) => {
1072
+ const settled = (): void => {
1073
+ socket.off('drain', settled)
1074
+ socket.off('close', settled)
1075
+ resolve()
1076
+ }
1077
+ socket.once('drain', settled)
1078
+ socket.once('close', settled)
1079
+ })
1080
+ if (socketGone()) break
1081
+ }
1082
+ }
1083
+ } catch {
1084
+ // The tunnel died (transport or server-side failure) — the
1085
+ // browser socket follows, below.
1086
+ }
1087
+ // Upstream is over: the browser's WebSocket ends here. A WebSocket
1088
+ // close frame cannot be synthesized (this is a byte tunnel — the
1089
+ // upstream's own close bytes, when the plugin sent any, already
1090
+ // travelled as data), so the socket is cut the honest way.
1091
+ if (!socketGone()) socket.destroy()
1092
+ })()
1093
+ // ---- upstream: browser bytes → ordered sends --------------------
1094
+ let sendSeq = 0
1095
+ let upstreamBroken = false
1096
+ let pending = Promise.resolve()
1097
+ socket.on('data', (chunk: Buffer) => {
1098
+ let offset = 0
1099
+ while (offset < chunk.length) {
1100
+ const part = chunk.subarray(offset, Math.min(offset + 65536, chunk.length))
1101
+ offset += part.length
1102
+ const bytes = new Uint8Array(part)
1103
+ const seq = sendSeq
1104
+ sendSeq += 1
1105
+ // ONE in-flight send at a time: each chunk chains behind the
1106
+ // previous answer, which is what keeps the server-side sequence
1107
+ // gate satisfied and the byte order intact.
1108
+ pending = pending.then(async () => {
1109
+ if (upstreamBroken) return
1110
+ try {
1111
+ await relay.pluginTunnelSend(tunnel.tunnel, seq, bytes, controller.signal)
1112
+ } catch {
1113
+ upstreamBroken = true
1114
+ }
1115
+ })
1116
+ }
1117
+ })
1118
+ const teardownTunnel = (): void => {
1119
+ upstreamBroken = true
1120
+ tunnelsCurrent -= 1
1121
+ tunnelsClosed += 1
1122
+ void relay.pluginTunnelClose(tunnel.tunnel).catch(() => {})
1123
+ }
1124
+ socket.on('close', teardownTunnel)
1125
+ socket.on('error', teardownTunnel)
1126
+ } catch {
1127
+ // Any unexpected failure in the bridged path: the socket goes.
1128
+ try {
1129
+ socket.destroy()
1130
+ } catch {
1131
+ // Already gone.
1132
+ }
1133
+ }
1134
+ })().catch(() => {
1135
+ try {
1136
+ socket.destroy()
1137
+ } catch {
1138
+ // Already gone.
1139
+ }
1140
+ })
1141
+ void originalHandler
1142
+ }
1143
+
1144
+ const wrappedUpgradesGet = function get(this: Map<string, unknown>, pathname: string): unknown {
1145
+ const entry = savedUpgradesGet.call(this, pathname)
1146
+ if (!installed) return entry
1147
+ if (typeof pathname !== 'string' || !pathname.startsWith('/')) return entry
1148
+ const declared = upgradeRoutesOf(pathname)
1149
+ if (declared === undefined) return entry
1150
+ const originalHandler = entry !== undefined && isRecord(entry) && typeof (entry as { handler?: unknown }).handler === 'function'
1151
+ ? (entry as { handler: unknown }).handler
1152
+ : undefined
1153
+ return {
1154
+ path: pathname,
1155
+ handler: surrogateUpgrade(declared, originalHandler),
1156
+ }
1157
+ } as unknown as typeof targetWithUpgrades.upgrades.get
1158
+
1159
+ target.match = wrappedMatch as typeof target.match
1160
+ // Instance-owning property shadows Map.prototype.get — exactly the seam
1161
+ // the probe verified (the host calls `this.upgrades.get(pathname)`).
1162
+ targetWithUpgrades.upgrades.get = wrappedUpgradesGet
1163
+
1164
+ return {
1165
+ uninstall() {
1166
+ if (!installed) return
1167
+ installed = false
1168
+ // Only restore while the instance still holds OUR wrapper — a later
1169
+ // wrapper chained over us captured this function, and restoring the
1170
+ // original would silently drop ITS wrap. Ours goes inert instead.
1171
+ if (target.match === wrappedMatch) target.match = savedMatch
1172
+ if (targetWithUpgrades.upgrades.get === wrappedUpgradesGet) {
1173
+ targetWithUpgrades.upgrades.get = savedUpgradesGet
1174
+ }
1175
+ },
1176
+ diagnostics(): PluginRouteInterceptDiagnostics {
1177
+ return {
1178
+ installed,
1179
+ shape: checkWebServerShape(webServer),
1180
+ forwarded,
1181
+ refused,
1182
+ refusalsByReason: { ...refusalsByReason },
1183
+ recentFailures: [...recentFailures],
1184
+ adapterForwarded,
1185
+ adapterResponseRewrites,
1186
+ adapterResponsePassthroughs,
1187
+ tunnelsCurrent,
1188
+ tunnelsOpened,
1189
+ tunnelsClosed,
1190
+ }
1191
+ },
1192
+ }
1193
+ }