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,470 @@
1
+ /**
2
+ * The plugin bridge's WebSocket byte tunnel (PB-10), server half. Per
3
+ * docs/adr/0002 the relay NEVER speaks WebSocket: a bridged upgrade route is
4
+ * carried as ONE NDJSON streaming response (server → client bytes) plus
5
+ * ordinary POSTs per chunk (client → server bytes), both ends treating the
6
+ * payload as OPAQUE BYTES — the WebSocket handshake and every frame are
7
+ * produced by the real plugin route on the server machine and arrive at the
8
+ * browser untouched.
9
+ *
10
+ * Open: `POST relay/v1/plugin-tunnel/open` — the same coordinate headers and
11
+ * the SAME validation chain as `v1/plugin` (shared {@link validatePluginCall};
12
+ * the route must additionally be declared `upgrade: true`), plus a small
13
+ * JSON body carrying exactly the four `sec-websocket-*` headers and the
14
+ * optional early head bytes. On success the relay dials the local web server
15
+ * itself (net/tls, loopback only), writes a hand-built upgrade request with
16
+ * the operator's cookie, and pumps socket bytes downstream line by line.
17
+ * Send/close: `POST relay/v1/plugin-tunnel/{send,close}` — ordered chunks
18
+ * upstream (one in-flight per tunnel, sequence-gated) and idempotent
19
+ * teardown.
20
+ *
21
+ * Every tunnel belongs to the device that opened it; another device's
22
+ * send/close is 404. Budgets, heartbeats and the slow-reader discipline
23
+ * mirror the routes this module sits beside.
24
+ */
25
+
26
+ import { connect as netConnect } from 'node:net'
27
+ import { connect as tlsConnect } from 'node:tls'
28
+ import type { IncomingMessage, ServerResponse } from 'node:http'
29
+ import type { Socket } from 'node:net'
30
+ import { pluginBridgeError, responseJson } from './http.js'
31
+ import { routeUpgradesOf } from './plugin-bridge.js'
32
+ import type { BridgeRegistry, NormalizedBridgeRoute, NormalizedDeclaration } from './plugin-bridge.js'
33
+ import type { PluginLoopback } from './plugin-loopback.js'
34
+
35
+ /** The shared validation verdict for the two plugin routes (PB-3, reused by
36
+ * the tunnel open in PB-10): the whole chain from the coordinate headers to
37
+ * the consent wall, minus each route's own extras (the method check for
38
+ * `v1/plugin`, the upgrade mark here). First failure wins, in this order. */
39
+ export type PluginCallValidation =
40
+ | { ok: true; declaration: NormalizedDeclaration; route: NormalizedBridgeRoute; params: URLSearchParams; sessionId: string; normalizedQuery: string }
41
+ | { ok: false; status: number; code: string }
42
+
43
+ /** Validate one plugin-bridge call's coordinates — the parse-once query
44
+ * discipline (the re-serialization of THIS parse is the only string the
45
+ * tunnel's upgrade request may carry), the share table with its ancestor
46
+ * walk, and the live consent wall. `bridge.getConsent()` throwing reads as
47
+ * unknown consent, and unknown consent stays home. */
48
+ export function validatePluginCall(
49
+ bridge: { registry: BridgeRegistry; getConsent: () => Record<string, boolean> },
50
+ coords: { pluginName: string; pluginVersion: string; pluginDigest: string; routePath: string; rawQuery: string },
51
+ isAccessible: (sessionId: string) => boolean,
52
+ ): PluginCallValidation {
53
+ const declaration = bridge.registry.get(coords.pluginName)
54
+ if (declaration === undefined) return { ok: false, status: 404, code: 'plugin-unknown' }
55
+ if (declaration.version !== coords.pluginVersion || declaration.digest !== coords.pluginDigest) {
56
+ return { ok: false, status: 409, code: 'plugin-mismatch' }
57
+ }
58
+ const route = declaration.routes.find((candidate) => candidate.path === coords.routePath)
59
+ if (route === undefined) return { ok: false, status: 404, code: 'route-undeclared' }
60
+ let params: URLSearchParams
61
+ try {
62
+ params = new URLSearchParams(coords.rawQuery)
63
+ } catch {
64
+ return { ok: false, status: 400, code: 'bad-query' }
65
+ }
66
+ const ids = params.getAll(route.sessionParam)
67
+ const sessionId = ids.length === 1 ? ids[0] : undefined
68
+ if (sessionId === undefined || sessionId === '') return { ok: false, status: 400, code: 'bad-query' }
69
+ if (!isAccessible(sessionId)) return { ok: false, status: 403, code: 'not-shared' }
70
+ if (route.scope === 'server') {
71
+ let consent: Record<string, boolean> = {}
72
+ try {
73
+ consent = bridge.getConsent()
74
+ } catch {
75
+ consent = {}
76
+ }
77
+ if (consent[coords.pluginName] !== true) return { ok: false, status: 403, code: 'consent-off' }
78
+ }
79
+ return { ok: true, declaration, route, params, sessionId, normalizedQuery: params.toString() }
80
+ }
81
+
82
+ /** Largest single downstream data LINE (base64 will inflate by 4/3 — the cap
83
+ * is on the RAW bytes carried, and the line stays well under typical proxy
84
+ * line limits this way). */
85
+ export const TUNNEL_DOWN_CHUNK_BYTES = 48 * 1024
86
+
87
+ /** Largest upstream send chunk (the wire body of one `send`). */
88
+ export const TUNNEL_UP_CHUNK_BYTES = 64 * 1024
89
+
90
+ /** Concurrent tunnels per device. */
91
+ export const MAX_TUNNELS_PER_DEVICE = 8
92
+
93
+ /** The four WebSocket headers the open body may carry, verbatim. */
94
+ const TUNNEL_CLIENT_HEADERS = ['sec-websocket-key', 'sec-websocket-version', 'sec-websocket-protocol', 'sec-websocket-extensions'] as const
95
+
96
+ function isRecord(value: unknown): value is Record<string, unknown> {
97
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
98
+ }
99
+
100
+ /** Header values must be plain strings — a CR/LF anywhere would inject into
101
+ * the hand-built upgrade request. */
102
+ function cleanHeaderValue(value: unknown): string | undefined {
103
+ if (typeof value !== 'string' || value === '' || /[\r\n]/.test(value)) return undefined
104
+ return value
105
+ }
106
+
107
+ /** One live tunnel. */
108
+ interface Tunnel {
109
+ device: string
110
+ upstream: Socket
111
+ /** The open request's NDJSON response — the downstream river. */
112
+ res: ServerResponse
113
+ /** The next upstream sequence the client must send. */
114
+ expectedSeq: number
115
+ heartbeat: ReturnType<typeof setInterval>
116
+ }
117
+
118
+ export interface CreatePluginTunnelOptions {
119
+ bridge: { registry: BridgeRegistry; getConsent: () => Record<string, boolean>; loopback: PluginLoopback }
120
+ /** The loopback web server bind, re-read per tunnel (same live discipline
121
+ * as every target read in this plugin). */
122
+ getTarget: () => { protocol: 'http:' | 'https:'; port: number }
123
+ isAccessible: (sessionId: string) => boolean
124
+ /** Heartbeat interval for the open stream (the ping lines). */
125
+ heartbeatMs: number
126
+ }
127
+
128
+ export interface PluginTunnelRoutes {
129
+ /** `POST relay/v1/plugin-tunnel/open`. */
130
+ open(req: IncomingMessage, res: ServerResponse): Promise<void>
131
+ /** `POST relay/v1/plugin-tunnel/send`. */
132
+ send(req: IncomingMessage, res: ServerResponse): Promise<void>
133
+ /** `POST relay/v1/plugin-tunnel/close`. */
134
+ close(req: IncomingMessage, res: ServerResponse): Promise<void>
135
+ /** Live tunnel count (the settings diagnostics). */
136
+ tunnelCount(): number
137
+ }
138
+
139
+ /** The tunnel table: id → the live entry. Every teardown path removes its
140
+ * own entry exactly once. */
141
+ export function createPluginTunnel(options: CreatePluginTunnelOptions): PluginTunnelRoutes {
142
+ const tunnels = new Map<string, Tunnel>()
143
+
144
+ /** Teardown, idempotent: end line (when the downstream still wants one
145
+ * and none was written), upstream destroyed, heartbeat cleared, entry
146
+ * gone. */
147
+ const teardown = (tunnel: Tunnel, id: string, reason: 'upstream-closed' | 'upstream-error' | 'client-closed' | null): void => {
148
+ if (tunnels.get(id) !== tunnel) return
149
+ tunnels.delete(id)
150
+ clearInterval(tunnel.heartbeat)
151
+ if (reason !== null && !tunnel.res.writableEnded && !tunnel.res.destroyed) {
152
+ writeLine(tunnel.res, { type: 'end', reason })
153
+ }
154
+ try {
155
+ tunnel.res.end()
156
+ } catch {
157
+ // Already gone.
158
+ }
159
+ try {
160
+ tunnel.upstream.destroy()
161
+ } catch {
162
+ // Already gone.
163
+ }
164
+ }
165
+
166
+ const writeLine = (res: ServerResponse, value: Record<string, unknown>): boolean => {
167
+ if (res.destroyed || res.writableEnded) return false
168
+ return res.write(Buffer.from(`${JSON.stringify(value)}\n`, 'utf8'))
169
+ }
170
+
171
+ const deviceOf = (req: IncomingMessage): string => {
172
+ const raw = req.headers['x-zen-remote-device']
173
+ const value = Array.isArray(raw) ? raw[0] : raw
174
+ return typeof value === 'string' ? value : ''
175
+ }
176
+
177
+ /** One coordinate header of the open request. */
178
+ const coordOf = (req: IncomingMessage, name: string): string | undefined => {
179
+ const raw = req.headers[name]
180
+ const value = Array.isArray(raw) ? raw[0] : raw
181
+ return typeof value === 'string' ? value : undefined
182
+ }
183
+
184
+ return {
185
+ tunnelCount() {
186
+ return tunnels.size
187
+ },
188
+
189
+ async open(req, res) {
190
+ const bridge = options.bridge
191
+ // The open body is a SMALL JSON object (four headers + optional head);
192
+ // anything past the shared body cap is refused by the reader itself.
193
+ let body: Record<string, unknown>
194
+ try {
195
+ const chunks: Buffer[] = []
196
+ let size = 0
197
+ for await (const chunk of req) {
198
+ size += (chunk as Buffer).length
199
+ if (size > 1024 * 1024) {
200
+ pluginBridgeError(res, 413, 'body-too-large')
201
+ return
202
+ }
203
+ chunks.push(Buffer.from(chunk as Uint8Array))
204
+ }
205
+ const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')
206
+ if (!isRecord(parsed)) {
207
+ pluginBridgeError(res, 400, 'bad-request')
208
+ return
209
+ }
210
+ body = parsed
211
+ } catch {
212
+ pluginBridgeError(res, 400, 'bad-request')
213
+ return
214
+ }
215
+ const rawHeaders = isRecord(body.headers) ? body.headers : undefined
216
+ // ALL four headers pass the CR/LF check unconditionally — confining it
217
+ // to the missing-key branch would let a poisoned OPTIONAL header
218
+ // through when key/version are fine, and the hand-built request below
219
+ // would write a literal `undefined` header for it.
220
+ for (const optional of ['sec-websocket-protocol', 'sec-websocket-extensions']) {
221
+ if (rawHeaders?.[optional] !== undefined && cleanHeaderValue(rawHeaders[optional]) === undefined) {
222
+ pluginBridgeError(res, 400, 'bad-request')
223
+ return
224
+ }
225
+ }
226
+ const wsKey = rawHeaders === undefined ? undefined : cleanHeaderValue(rawHeaders['sec-websocket-key'])
227
+ const wsVersion = rawHeaders === undefined ? undefined : cleanHeaderValue(rawHeaders['sec-websocket-version'])
228
+ // The two headers a WebSocket handshake cannot exist without.
229
+ if (wsKey === undefined || wsVersion === undefined) {
230
+ pluginBridgeError(res, 400, 'bad-request')
231
+ return
232
+ }
233
+ let head: Buffer = Buffer.alloc(0)
234
+ if (body.head !== undefined) {
235
+ if (typeof body.head !== 'string') {
236
+ pluginBridgeError(res, 400, 'bad-request')
237
+ return
238
+ }
239
+ head = Buffer.from(body.head, 'base64')
240
+ if (head.length === 0 && body.head !== '') {
241
+ pluginBridgeError(res, 400, 'bad-request')
242
+ return
243
+ }
244
+ }
245
+
246
+ const coords = {
247
+ pluginName: coordOf(req, 'x-zr-plugin') ?? '',
248
+ pluginVersion: coordOf(req, 'x-zr-plugin-version') ?? '',
249
+ pluginDigest: coordOf(req, 'x-zr-plugin-digest') ?? '',
250
+ routePath: coordOf(req, 'x-zr-path') ?? '',
251
+ rawQuery: coordOf(req, 'x-zr-query') ?? '',
252
+ }
253
+ if (
254
+ coords.pluginName === '' || coords.pluginVersion === '' || coords.pluginDigest === '' ||
255
+ coords.routePath === '' || !coords.routePath.startsWith('/')
256
+ ) {
257
+ pluginBridgeError(res, 400, 'bad-request')
258
+ return
259
+ }
260
+ const validation = validatePluginCall(bridge, coords, options.isAccessible)
261
+ if (!validation.ok) {
262
+ pluginBridgeError(res, validation.status, validation.code)
263
+ return
264
+ }
265
+ // Tunnel routes must be DECLARED as upgrades: an ordinary (HTTP)
266
+ // route has no WebSocket endpoint behind it to dial.
267
+ if (!routeUpgradesOf(validation.route)) {
268
+ pluginBridgeError(res, 404, 'route-undeclared')
269
+ return
270
+ }
271
+ const device = deviceOf(req)
272
+ if (([...tunnels.values()].filter((tunnel) => tunnel.device === device)).length >= MAX_TUNNELS_PER_DEVICE) {
273
+ pluginBridgeError(res, 429, 'busy')
274
+ return
275
+ }
276
+
277
+ // Dial the local web server with a hand-built upgrade request. The
278
+ // cookie authenticates as the operator (same loopback discipline as
279
+ // every bridged call); Origin is deliberately NOT written.
280
+ let cookie: string
281
+ try {
282
+ cookie = await bridge.loopback.cookieHeader()
283
+ } catch {
284
+ pluginBridgeError(res, 502, 'loopback-auth')
285
+ return
286
+ }
287
+ const target = options.getTarget()
288
+ let upstream: Socket
289
+ try {
290
+ // Self-signed https on the literal loopback: the dial is the pinned
291
+ // 127.0.0.1, so certificate identity carries no information (the
292
+ // same rule plugin-loopback applies to its own hops).
293
+ upstream =
294
+ target.protocol === 'https:'
295
+ ? tlsConnect({ host: '127.0.0.1', port: target.port, rejectUnauthorized: false, servername: '127.0.0.1' })
296
+ : netConnect({ host: '127.0.0.1', port: target.port })
297
+ } catch {
298
+ pluginBridgeError(res, 502, 'internal')
299
+ return
300
+ }
301
+ const id = (await import('node:crypto')).randomBytes(16).toString('hex')
302
+ const tunnel: Tunnel = {
303
+ device,
304
+ upstream,
305
+ res,
306
+ expectedSeq: 0,
307
+ heartbeat: setInterval(() => {
308
+ writeLine(res, { type: 'ping' })
309
+ }, options.heartbeatMs),
310
+ }
311
+ if (typeof tunnel.heartbeat.unref === 'function') tunnel.heartbeat.unref()
312
+ tunnels.set(id, tunnel)
313
+
314
+ // The NDJSON river opens BEFORE the dial can succeed — the open line
315
+ // is the client's signal to stop HTTP-ing and start reading. A dial
316
+ // failure becomes an end line, not a lost connection.
317
+ res.writeHead(200, {
318
+ 'content-type': 'application/x-ndjson; charset=utf-8',
319
+ 'cache-control': 'no-store, no-transform',
320
+ 'x-accel-buffering': 'no',
321
+ })
322
+ res.write(Buffer.from(`${JSON.stringify({ type: 'open', tunnel: id })}\n`, 'utf8'))
323
+
324
+ const query = validation.normalizedQuery
325
+ const requestLines = [
326
+ `GET ${coords.routePath}${query === '' ? '' : `?${query}`} HTTP/1.1`,
327
+ `Host: 127.0.0.1:${target.port}`,
328
+ 'Connection: Upgrade',
329
+ 'Upgrade: websocket',
330
+ `Sec-WebSocket-Key: ${wsKey}`,
331
+ `Sec-WebSocket-Version: ${wsVersion}`,
332
+ ...(rawHeaders?.['sec-websocket-protocol'] !== undefined ? [`Sec-WebSocket-Protocol: ${cleanHeaderValue(rawHeaders['sec-websocket-protocol'])}`] : []),
333
+ ...(rawHeaders?.['sec-websocket-extensions'] !== undefined ? [`Sec-WebSocket-Extensions: ${cleanHeaderValue(rawHeaders['sec-websocket-extensions'])}`] : []),
334
+ ...(cookie !== '' ? [`Cookie: ${cookie}`] : []),
335
+ '\r\n',
336
+ ]
337
+ upstream.write(requestLines.join('\r\n'))
338
+ if (head.length > 0) upstream.write(head)
339
+
340
+ // Upstream bytes → downstream data lines, at most 48 KiB PER LINE, with
341
+ // the slow-reader discipline: a full socket pauses the UPSTREAM read, so
342
+ // a stalled client never grows this process's buffers. Whatever arrived
343
+ // is sent NOW — an interactive WebSocket speaks in tens of bytes, and
344
+ // holding a sub-chunk arrival for a fuller buffer would stall the
345
+ // handshake and every early frame (the PB-10 e2e hang).
346
+ let downstreamBuffer: Buffer = Buffer.alloc(0)
347
+ const pumpDownstream = (): void => {
348
+ while (downstreamBuffer.length > 0) {
349
+ const chunk = downstreamBuffer.subarray(0, TUNNEL_DOWN_CHUNK_BYTES)
350
+ downstreamBuffer = downstreamBuffer.subarray(chunk.length)
351
+ if (!writeLine(res, { type: 'data', b64: chunk.toString('base64') })) {
352
+ upstream.pause()
353
+ res.once('drain', () => upstream.resume())
354
+ return
355
+ }
356
+ }
357
+ }
358
+ upstream.on('data', (chunk: Buffer) => {
359
+ downstreamBuffer = Buffer.concat([downstreamBuffer, chunk])
360
+ pumpDownstream()
361
+ })
362
+ upstream.on('close', () => {
363
+ // Whatever bytes remained inside the last (sub-chunk) buffer still
364
+ // travel before the end line.
365
+ if (downstreamBuffer.length > 0 && !res.destroyed && !res.writableEnded) {
366
+ writeLine(res, { type: 'data', b64: downstreamBuffer.toString('base64') })
367
+ downstreamBuffer = Buffer.alloc(0)
368
+ }
369
+ teardown(tunnel, id, 'upstream-closed')
370
+ })
371
+ upstream.on('error', () => {
372
+ teardown(tunnel, id, 'upstream-error')
373
+ })
374
+ res.on('close', () => {
375
+ // The client is gone: no end line can be delivered.
376
+ teardown(tunnel, id, null)
377
+ })
378
+ },
379
+
380
+ async send(req, res) {
381
+ const tunnelId = coordOf(req, 'x-zr-tunnel') ?? ''
382
+ const seqRaw = coordOf(req, 'x-zr-seq') ?? ''
383
+ const tunnel = tunnels.get(tunnelId)
384
+ const device = deviceOf(req)
385
+ if (tunnel === undefined || tunnel.device !== device) {
386
+ pluginBridgeError(res, 404, 'tunnel-unknown')
387
+ // Drain the body so the client reads the refusal instead of a reset.
388
+ req.resume()
389
+ return
390
+ }
391
+ if (!/^\d+$/u.test(seqRaw)) {
392
+ pluginBridgeError(res, 400, 'bad-request')
393
+ req.resume()
394
+ return
395
+ }
396
+ const seq = Number(seqRaw)
397
+ if (seq !== tunnel.expectedSeq) {
398
+ // Out of order: the stream's ordering contract is broken — the
399
+ // tunnel closes (client-closed, the sending side's failure).
400
+ pluginBridgeError(res, 409, 'tunnel-out-of-order')
401
+ teardown(tunnel, tunnelId, 'client-closed')
402
+ req.resume()
403
+ return
404
+ }
405
+ const declaredLength = Number(req.headers['content-length'])
406
+ if (Number.isFinite(declaredLength) && declaredLength > TUNNEL_UP_CHUNK_BYTES) {
407
+ pluginBridgeError(res, 413, 'body-too-large')
408
+ req.resume()
409
+ return
410
+ }
411
+ const chunks: Buffer[] = []
412
+ let size = 0
413
+ let over = false
414
+ for await (const chunk of req) {
415
+ size += (chunk as Buffer).length
416
+ if (size > TUNNEL_UP_CHUNK_BYTES) {
417
+ over = true
418
+ break
419
+ }
420
+ chunks.push(Buffer.from(chunk as Uint8Array))
421
+ }
422
+ if (over) {
423
+ pluginBridgeError(res, 413, 'body-too-large')
424
+ req.resume()
425
+ return
426
+ }
427
+ const bytes = Buffer.concat(chunks)
428
+ tunnel.expectedSeq = seq + 1
429
+ // Backpressure: the answer waits for the upstream socket to take the
430
+ // bytes, so a stalled plugin makes THIS send slow instead of growing
431
+ // buffers here.
432
+ const flushed = tunnel.upstream.write(bytes)
433
+ if (!flushed) {
434
+ await new Promise<void>((resolve) => {
435
+ const settled = (): void => {
436
+ tunnel.upstream.off('drain', settled)
437
+ tunnel.upstream.off('close', settled)
438
+ tunnel.upstream.off('error', settled)
439
+ resolve()
440
+ }
441
+ tunnel.upstream.once('drain', settled)
442
+ tunnel.upstream.once('close', settled)
443
+ tunnel.upstream.once('error', settled)
444
+ })
445
+ }
446
+ if (tunnels.get(tunnelId) !== tunnel) {
447
+ // The tunnel died while the bytes were draining.
448
+ pluginBridgeError(res, 404, 'tunnel-unknown')
449
+ return
450
+ }
451
+ responseJson(res, 200, { ok: true })
452
+ },
453
+
454
+ async close(req, res) {
455
+ const tunnelId = coordOf(req, 'x-zr-tunnel') ?? ''
456
+ const tunnel = tunnels.get(tunnelId)
457
+ const device = deviceOf(req)
458
+ if (tunnel !== undefined && tunnel.device !== device) {
459
+ pluginBridgeError(res, 404, 'tunnel-unknown')
460
+ return
461
+ }
462
+ // Idempotent: a known tunnel closes (end line client-closed — the
463
+ // downstream's owner walked away), an unknown id answers ok.
464
+ if (tunnel !== undefined) {
465
+ teardown(tunnel, tunnelId, 'client-closed')
466
+ }
467
+ responseJson(res, 200, { ok: true })
468
+ },
469
+ }
470
+ }