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