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.
- package/AGENTS.md +15 -1
- package/README.md +11 -5
- package/README.zh-CN.md +11 -5
- package/docs/adr/0001-plugin-bridge-forwards-plugin-routes.md +15 -0
- package/docs/adr/0002-relay-emulates-websocket.md +8 -0
- package/docs/interface.md +3 -1
- package/docs/plugin-bridge.md +140 -0
- package/docs/remote-access.en.md +57 -0
- package/docs/remote-access.md +57 -0
- package/lib/adapters/dsh-better-sidebar/index.js +144 -0
- package/lib/adapters/dsh-better-sidebar/index.js.map +1 -0
- package/lib/adapters/dsh-better-sidebar/methods.js +198 -0
- package/lib/adapters/dsh-better-sidebar/methods.js.map +1 -0
- package/lib/adapters/index.js +17 -0
- package/lib/adapters/index.js.map +1 -0
- package/lib/adapters/types.js +17 -0
- package/lib/adapters/types.js.map +1 -0
- package/lib/admin-routes.js +51 -0
- package/lib/admin-routes.js.map +1 -1
- package/lib/client-routes.js +18 -1
- package/lib/client-routes.js.map +1 -1
- package/lib/client.js +449 -45
- package/lib/config.js +27 -0
- package/lib/config.js.map +1 -1
- package/lib/http.js +10 -0
- package/lib/http.js.map +1 -1
- package/lib/index.js +246 -0
- package/lib/index.js.map +1 -1
- package/lib/plugin-adapter-core.js +385 -0
- package/lib/plugin-adapter-core.js.map +1 -0
- package/lib/plugin-bridge.js +318 -0
- package/lib/plugin-bridge.js.map +1 -0
- package/lib/plugin-loopback.js +207 -0
- package/lib/plugin-loopback.js.map +1 -0
- package/lib/plugin-route-intercept.js +1103 -0
- package/lib/plugin-route-intercept.js.map +1 -0
- package/lib/plugin-tunnel.js +416 -0
- package/lib/plugin-tunnel.js.map +1 -0
- package/lib/relay-client.js +319 -2
- package/lib/relay-client.js.map +1 -1
- package/lib/relay-server.js +633 -0
- package/lib/relay-server.js.map +1 -1
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts +27 -0
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -0
- package/lib/types/adapters/dsh-better-sidebar/methods.d.ts +51 -0
- package/lib/types/adapters/dsh-better-sidebar/methods.d.ts.map +1 -0
- package/lib/types/adapters/index.d.ts +17 -0
- package/lib/types/adapters/index.d.ts.map +1 -0
- package/lib/types/adapters/types.d.ts +42 -0
- package/lib/types/adapters/types.d.ts.map +1 -0
- package/lib/types/admin-routes.d.ts +16 -0
- package/lib/types/admin-routes.d.ts.map +1 -1
- package/lib/types/client/effects/remote-session.d.ts +0 -22
- package/lib/types/client/effects/remote-session.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +16 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
- package/lib/types/client-data/plugin-bridge.d.ts +79 -0
- package/lib/types/client-data/plugin-bridge.d.ts.map +1 -0
- package/lib/types/client-data/remote-status.d.ts +20 -0
- package/lib/types/client-data/remote-status.d.ts.map +1 -1
- package/lib/types/client-data/settings-form.d.ts +37 -1
- package/lib/types/client-data/settings-form.d.ts.map +1 -1
- package/lib/types/client-routes.d.ts +13 -0
- package/lib/types/client-routes.d.ts.map +1 -1
- package/lib/types/config.d.ts +8 -0
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/http.d.ts +7 -0
- package/lib/types/http.d.ts.map +1 -1
- package/lib/types/index.d.ts +4 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/plugin-adapter-core.d.ts +263 -0
- package/lib/types/plugin-adapter-core.d.ts.map +1 -0
- package/lib/types/plugin-bridge.d.ts +187 -0
- package/lib/types/plugin-bridge.d.ts.map +1 -0
- package/lib/types/plugin-loopback.d.ts +104 -0
- package/lib/types/plugin-loopback.d.ts.map +1 -0
- package/lib/types/plugin-route-intercept.d.ts +114 -0
- package/lib/types/plugin-route-intercept.d.ts.map +1 -0
- package/lib/types/plugin-tunnel.d.ts +96 -0
- package/lib/types/plugin-tunnel.d.ts.map +1 -0
- package/lib/types/relay-client.d.ts +76 -0
- package/lib/types/relay-client.d.ts.map +1 -1
- package/lib/types/relay-server.d.ts +32 -1
- package/lib/types/relay-server.d.ts.map +1 -1
- package/llms.txt +1 -0
- package/package.json +2 -2
- package/scripts/check-plugin-bridge-ui.mjs +168 -0
- package/scripts/check-settings-form.mjs +3 -1
- package/src/adapters/dsh-better-sidebar/index.ts +144 -0
- package/src/adapters/dsh-better-sidebar/methods.ts +215 -0
- package/src/adapters/index.ts +19 -0
- package/src/adapters/types.ts +44 -0
- package/src/admin-routes.ts +64 -0
- package/src/client/effects/remote-session.ts +82 -10
- package/src/client/locales.ts +36 -0
- package/src/client/settings/SettingsSection.tsx +107 -3
- package/src/client/styles/remote-session.css.ts +70 -31
- package/src/client-data/plugin-bridge.ts +150 -0
- package/src/client-data/remote-status.ts +30 -0
- package/src/client-data/settings-form.ts +78 -0
- package/src/client-routes.ts +29 -1
- package/src/config.ts +32 -0
- package/src/http.ts +12 -0
- package/src/index.ts +252 -0
- package/src/plugin-adapter-core.ts +530 -0
- package/src/plugin-bridge.ts +435 -0
- package/src/plugin-loopback.ts +299 -0
- package/src/plugin-route-intercept.ts +1193 -0
- package/src/plugin-tunnel.ts +470 -0
- package/src/relay-client.ts +394 -2
- package/src/relay-server.ts +656 -1
package/src/relay-server.ts
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
|
30
|
+
import { Readable, Transform } from 'node:stream'
|
|
30
31
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
31
32
|
import { createRequire } from 'node:module'
|
|
32
33
|
import { join } from 'node:path'
|
|
@@ -42,6 +43,13 @@ import {
|
|
|
42
43
|
filterWorkspaceFrame,
|
|
43
44
|
} from './relay-filter.js'
|
|
44
45
|
import { SESSION_REFERENCE_URI, collectReferenceTexts, decodeSessionReferenceUri } from './session-reference.js'
|
|
46
|
+
import type { ServerPluginEntry, BridgeRegistry } from './plugin-bridge.js'
|
|
47
|
+
import { routeMethodsOf, routeMaxBodyOf } from './plugin-bridge.js'
|
|
48
|
+
import { createPluginTunnel, validatePluginCall, type PluginTunnelRoutes } from './plugin-tunnel.js'
|
|
49
|
+
import type { PluginLoopback } from './plugin-loopback.js'
|
|
50
|
+
import { isLoopbackAuthError } from './plugin-loopback.js'
|
|
51
|
+
import type { NormalizedAdapter, AdapterVerdict } from './plugin-adapter-core.js'
|
|
52
|
+
import { matchAdapterRoute, locateAdapterIds, adapterOwnsRegistryEntry, AdapterMultiValueQueryError } from './plugin-adapter-core.js'
|
|
45
53
|
|
|
46
54
|
/**
|
|
47
55
|
* Registration prefix on the host webServer. Deliberately WITHOUT the
|
|
@@ -134,6 +142,9 @@ const MAX_INVOKES_PER_DEVICE = 32
|
|
|
134
142
|
*/
|
|
135
143
|
const MAX_UPLOADS_PER_DEVICE = 8
|
|
136
144
|
|
|
145
|
+
/** Concurrent bridged plugin GETs one device may keep in flight (PB-3). */
|
|
146
|
+
const MAX_PLUGIN_REQUESTS_PER_DEVICE = 8
|
|
147
|
+
|
|
137
148
|
/** Most recent forwarded waterfall events remembered per `$zr/events`
|
|
138
149
|
* subscription (T32-fix): the registry is only an ownership record, so this
|
|
139
150
|
* bounds a pathological burst the same way the job cache does — past the
|
|
@@ -224,12 +235,17 @@ export interface RelayGateway {
|
|
|
224
235
|
* callback too (T42), awaited per handshake so a just-restarted server never
|
|
225
236
|
* serves a stale map; a THROWN compute degrades to the empty map here so a
|
|
226
237
|
* broken fingerprint source can never fail the handshake itself — the
|
|
227
|
-
* client's comparison judges every group `unavailable`, never `different`.
|
|
238
|
+
* client's comparison judges every group `unavailable`, never `different`.
|
|
239
|
+
* `plugins` is the same discipline for the plugin bridge (PB-2): computed
|
|
240
|
+
* FRESH per handshake (the local registry and the row's consent map are both
|
|
241
|
+
* live), a THROWN compute degrades to the empty table — the handshake still
|
|
242
|
+
* succeeds and the client reads every local plugin `not-on-server`. */
|
|
228
243
|
export interface RelayServerInfo {
|
|
229
244
|
serverId: string
|
|
230
245
|
serverName: () => string
|
|
231
246
|
dshVersion: string
|
|
232
247
|
fingerprints?: () => Record<string, string> | Promise<Record<string, string>>
|
|
248
|
+
plugins?: () => Record<string, ServerPluginEntry>
|
|
233
249
|
}
|
|
234
250
|
|
|
235
251
|
export interface RelayHandlerOptions {
|
|
@@ -268,6 +284,28 @@ export interface RelayHandlerOptions {
|
|
|
268
284
|
* destroyed (the {@link DRAIN_DEADLINE_MS} bound); defaults to 10000.
|
|
269
285
|
* Tests inject a small value so the deadline path stays cheap to drive. */
|
|
270
286
|
drainDeadlineMs?: number
|
|
287
|
+
/** The plugin bridge's server half (PB-3). Absent — a wiring that never
|
|
288
|
+
* mounted it, old tests — and `POST relay/v1/plugin` answers 501
|
|
289
|
+
* `unsupported`, exactly like the `/api` passthrough without a dispatcher. */
|
|
290
|
+
pluginBridge?: {
|
|
291
|
+
/** THIS process's bridge declaration registry (the `zenRemote` service's
|
|
292
|
+
* backing table) — the source of truth for "which plugin, which version,
|
|
293
|
+
* which routes". */
|
|
294
|
+
registry: BridgeRegistry
|
|
295
|
+
/** The row's per-plugin whole-machine-data consent, read LIVE per request
|
|
296
|
+
* (a volatile row field — never snapshotted). */
|
|
297
|
+
getConsent: () => Record<string, boolean>
|
|
298
|
+
/** The loopback executor performing the actual plugin GET. */
|
|
299
|
+
loopback: PluginLoopback
|
|
300
|
+
/** The WebSocket byte tunnel's server half (PB-10). Absent — a wiring
|
|
301
|
+
* that predates the tunnels — and the three `plugin-tunnel` routes
|
|
302
|
+
* answer 501 `unsupported` like an unwired bridge. */
|
|
303
|
+
tunnel?: PluginTunnelRoutes
|
|
304
|
+
/** The validated adapters (PB-11), as the wiring registered them.
|
|
305
|
+
* Absent/empty — the pluggable default — and `v1/plugin` serves
|
|
306
|
+
* cooperative declarations only, exactly as before. */
|
|
307
|
+
adapters?: readonly NormalizedAdapter[]
|
|
308
|
+
}
|
|
271
309
|
}
|
|
272
310
|
|
|
273
311
|
/**
|
|
@@ -358,6 +396,18 @@ function responseJson(res: ServerResponse, status: number, body: unknown): void
|
|
|
358
396
|
res.end(bytes)
|
|
359
397
|
}
|
|
360
398
|
|
|
399
|
+
/**
|
|
400
|
+
* One plugin-bridge refusal (PB-3): the same JSON envelope as every other
|
|
401
|
+
* relay refusal, plus the `x-zen-remote-error` marker header — the
|
|
402
|
+
* sub-client's passthrough layer reads the CODE off a response whose body
|
|
403
|
+
* it must not have to parse (the success path streams opaque plugin bytes;
|
|
404
|
+
* keeping the refusal marker a header lets one code path classify both).
|
|
405
|
+
*/
|
|
406
|
+
function relayPluginError(res: ServerResponse, status: number, code: string): void {
|
|
407
|
+
res.setHeader('x-zen-remote-error', code)
|
|
408
|
+
responseJson(res, status, { ok: false, error: { code } })
|
|
409
|
+
}
|
|
410
|
+
|
|
361
411
|
/** First (and in practice only) value of one request header. */
|
|
362
412
|
function headerValue(req: IncomingMessage, name: string): string | undefined {
|
|
363
413
|
const raw = req.headers[name]
|
|
@@ -746,6 +796,17 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
|
|
|
746
796
|
/** Uploads currently in flight per device id, for the upload budget (T51-fix). */
|
|
747
797
|
const uploadsByDevice = new Map<string, number>()
|
|
748
798
|
|
|
799
|
+
/**
|
|
800
|
+
* Bridged plugin GETs currently in flight per device id (PB-3): the same
|
|
801
|
+
* per-device budget discipline as the invoke and upload counters above.
|
|
802
|
+
* The cap is 8 — the upload budget's number — because a bridged call is
|
|
803
|
+
* one panel's data fetch, a burst of a handful per session view; the slot
|
|
804
|
+
* stays taken for the WHOLE stream (a slow plugin response pins one slot
|
|
805
|
+
* until its last byte), which is exactly what the budget bounds. Past the
|
|
806
|
+
* cap the request answers 429 `busy` without touching the counter.
|
|
807
|
+
*/
|
|
808
|
+
const pluginsByDevice = new Map<string, number>()
|
|
809
|
+
|
|
749
810
|
/**
|
|
750
811
|
* The device NAME this process last saw per device id (T59): every relay
|
|
751
812
|
* request rides the gateway's fresh `x-zen-remote-device-name` marking
|
|
@@ -1031,6 +1092,19 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
|
|
|
1031
1092
|
} catch {
|
|
1032
1093
|
fingerprints = {}
|
|
1033
1094
|
}
|
|
1095
|
+
// PB-2: the plugin bridge table, computed FRESH per handshake (the
|
|
1096
|
+
// registry and the row's consent map are both live state), with the
|
|
1097
|
+
// SAME guard: a throwing source answers the empty table — the field
|
|
1098
|
+
// still travels, so the client reads "no plugins on this server"
|
|
1099
|
+
// (not-on-server), never a failed handshake. The field is ALWAYS
|
|
1100
|
+
// present in the reply: only its ABSENCE (an older server) can ever
|
|
1101
|
+
// mean server-too-old on the client.
|
|
1102
|
+
let plugins: Record<string, ServerPluginEntry> = {}
|
|
1103
|
+
try {
|
|
1104
|
+
plugins = serverInfo.plugins?.() ?? {}
|
|
1105
|
+
} catch {
|
|
1106
|
+
plugins = {}
|
|
1107
|
+
}
|
|
1034
1108
|
responseJson(res, 200, {
|
|
1035
1109
|
ok: true,
|
|
1036
1110
|
relayProtocol: RELAY_PROTOCOL,
|
|
@@ -1040,6 +1114,9 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
|
|
|
1040
1114
|
// T42: the server's own interface fingerprints (empty when the wiring
|
|
1041
1115
|
// had nothing to compute — the client judges group by group).
|
|
1042
1116
|
fingerprints,
|
|
1117
|
+
// PB-2: the plugin bridge table (empty when the wiring had nothing to
|
|
1118
|
+
// register — the client judges plugin by plugin).
|
|
1119
|
+
plugins,
|
|
1043
1120
|
// T59: the CALLING device's name, as the gateway's device table holds
|
|
1044
1121
|
// it at this moment (the header is written fresh per forward, so a
|
|
1045
1122
|
// just-renamed device reads its new name here). Undefined without the
|
|
@@ -1374,6 +1451,584 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
|
|
|
1374
1451
|
return
|
|
1375
1452
|
}
|
|
1376
1453
|
|
|
1454
|
+
if (req.method === 'POST' && pathname === `${RELAY_PREFIX}/v1/plugin`) {
|
|
1455
|
+
// The plugin bridge's forwarding route (PB-3, write requests since
|
|
1456
|
+
// PB-9): the sub-client hands over one call against a BRIDGED plugin's
|
|
1457
|
+
// route (coordinates in the x-zr-* headers), and this server re-issues
|
|
1458
|
+
// it against its OWN plugin over loopback HTTP (docs/adr/0001),
|
|
1459
|
+
// streaming the answer back. The METHOD rides `x-zr-method` (PB-9,
|
|
1460
|
+
// default GET); a body rides as the raw byte stream, forwarded
|
|
1461
|
+
// verbatim.
|
|
1462
|
+
const bridge = options.pluginBridge
|
|
1463
|
+
if (bridge === undefined) {
|
|
1464
|
+
responseJson(res, 501, { ok: false, error: { code: 'unsupported' } })
|
|
1465
|
+
return
|
|
1466
|
+
}
|
|
1467
|
+
// The method, before anything else — it decides whether a body is
|
|
1468
|
+
// even legal. GET/HEAD stay bodyless by protocol: the coordinates
|
|
1469
|
+
// ride the headers, so anything at all in the body is a shape
|
|
1470
|
+
// violation. One byte settles it; a real body is drained (bounded,
|
|
1471
|
+
// timed — the upload route's discipline) AFTER the answer, like every
|
|
1472
|
+
// oversized read here.
|
|
1473
|
+
const pluginMethod = headerValue(req, 'x-zr-method') ?? 'GET'
|
|
1474
|
+
const bodylessMethod = pluginMethod === 'GET' || pluginMethod === 'HEAD'
|
|
1475
|
+
let bodyBytes = 0
|
|
1476
|
+
if (bodylessMethod) {
|
|
1477
|
+
for await (const chunk of req) {
|
|
1478
|
+
bodyBytes += (chunk as Buffer).length
|
|
1479
|
+
break
|
|
1480
|
+
}
|
|
1481
|
+
if (bodyBytes > 0) {
|
|
1482
|
+
relayPluginError(res, 400, 'bad-request')
|
|
1483
|
+
await drainUpload(req, 0, drainDeadlineMs)
|
|
1484
|
+
return
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
// The coordinates, verbatim off the wire — `x-zr-path` is matched
|
|
1488
|
+
// UNDECODED (the module-wide no-decode discipline), the query parsed
|
|
1489
|
+
// exactly once below.
|
|
1490
|
+
const pluginName = headerValue(req, 'x-zr-plugin')
|
|
1491
|
+
const pluginVersion = headerValue(req, 'x-zr-plugin-version')
|
|
1492
|
+
const pluginDigest = headerValue(req, 'x-zr-plugin-digest')
|
|
1493
|
+
const routePath = headerValue(req, 'x-zr-path')
|
|
1494
|
+
const rawQuery = headerValue(req, 'x-zr-query') ?? ''
|
|
1495
|
+
if (
|
|
1496
|
+
pluginName === undefined || pluginName === '' ||
|
|
1497
|
+
pluginVersion === undefined || pluginVersion === '' ||
|
|
1498
|
+
pluginDigest === undefined || pluginDigest === '' ||
|
|
1499
|
+
routePath === undefined || !routePath.startsWith('/')
|
|
1500
|
+
) {
|
|
1501
|
+
relayPluginError(res, 400, 'bad-request')
|
|
1502
|
+
return
|
|
1503
|
+
}
|
|
1504
|
+
// ---- PB-11: the adapter routes claim the request BEFORE the
|
|
1505
|
+
// cooperative chain. An adapter route matches by equality or one
|
|
1506
|
+
// segment deeper (not the cooperative exact equality), its ids live
|
|
1507
|
+
// wherever ITS locator says (query, path segment, JSON body), and the
|
|
1508
|
+
// loopback receives the coordinates VERBATIM — the sub-client has
|
|
1509
|
+
// already rewritten every virtual id to its real half, so what gets
|
|
1510
|
+
// validated here is exactly what the local plugin will see. A YIELDED
|
|
1511
|
+
// adapter (the plugin registered its own declaration) falls through to
|
|
1512
|
+
// the cooperative chain below, whose digest check refuses the stale
|
|
1513
|
+
// adapter coordinates with `plugin-mismatch`.
|
|
1514
|
+
const adapterHit = matchAdapterRoute(options.pluginBridge?.adapters, routePath)
|
|
1515
|
+
if (adapterHit !== undefined) {
|
|
1516
|
+
const { adapter, route: adapterRoute } = adapterHit
|
|
1517
|
+
if (adapterOwnsRegistryEntry(adapter, bridge.registry.get(adapter.plugin))) {
|
|
1518
|
+
// Coordinates first: the forwarded version/digest must be the
|
|
1519
|
+
// adapter's registered pair.
|
|
1520
|
+
if (pluginVersion !== adapter.declaration.version || pluginDigest !== adapter.digest) {
|
|
1521
|
+
relayPluginError(res, 409, 'plugin-mismatch')
|
|
1522
|
+
return
|
|
1523
|
+
}
|
|
1524
|
+
// Whole-machine data by definition: the live consent wall, the
|
|
1525
|
+
// same knob the cooperative server-scope routes read.
|
|
1526
|
+
let adapterConsent: Record<string, boolean> = {}
|
|
1527
|
+
try {
|
|
1528
|
+
adapterConsent = bridge.getConsent()
|
|
1529
|
+
} catch {
|
|
1530
|
+
adapterConsent = {}
|
|
1531
|
+
}
|
|
1532
|
+
if (adapterConsent[adapter.plugin] !== true) {
|
|
1533
|
+
relayPluginError(res, 403, 'consent-off')
|
|
1534
|
+
return
|
|
1535
|
+
}
|
|
1536
|
+
if (!adapterRoute.methods.includes(pluginMethod)) {
|
|
1537
|
+
relayPluginError(res, 405, 'method-undeclared')
|
|
1538
|
+
return
|
|
1539
|
+
}
|
|
1540
|
+
// The per-device budget — the SAME table the cooperative forwards
|
|
1541
|
+
// share, held for the whole response stream.
|
|
1542
|
+
const adapterDevice = headerValue(req, 'x-zen-remote-device') ?? ''
|
|
1543
|
+
if ((pluginsByDevice.get(adapterDevice) ?? 0) >= MAX_PLUGIN_REQUESTS_PER_DEVICE) {
|
|
1544
|
+
relayPluginError(res, 429, 'busy')
|
|
1545
|
+
return
|
|
1546
|
+
}
|
|
1547
|
+
pluginsByDevice.set(adapterDevice, (pluginsByDevice.get(adapterDevice) ?? 0) + 1)
|
|
1548
|
+
let adapterGone = false
|
|
1549
|
+
let multiValueRefusal = false
|
|
1550
|
+
let adapterQueryOverride: string | undefined
|
|
1551
|
+
const adapterHangUp = new AbortController()
|
|
1552
|
+
const onAdapterGone = (): void => {
|
|
1553
|
+
adapterGone = true
|
|
1554
|
+
if (!res.writableEnded) adapterHangUp.abort()
|
|
1555
|
+
}
|
|
1556
|
+
res.on('close', onAdapterGone)
|
|
1557
|
+
res.on('error', onAdapterGone)
|
|
1558
|
+
try {
|
|
1559
|
+
// Locate ONCE, over the same routing rule the sub-client used:
|
|
1560
|
+
// the query parses once, the path segments decode off the
|
|
1561
|
+
// rewritten pathname, and a body-locating route buffers its
|
|
1562
|
+
// (bounded) JSON body. A duplicated query id throws — the
|
|
1563
|
+
// cooperative shape violation (PB-12 review).
|
|
1564
|
+
const adapterSearch = new URLSearchParams(rawQuery)
|
|
1565
|
+
let parsedBody: unknown
|
|
1566
|
+
let bodyGiven = false
|
|
1567
|
+
let bufferedBody: Buffer | undefined
|
|
1568
|
+
if (!bodylessMethod && adapterRoute.locator.in === 'json-body') {
|
|
1569
|
+
const chunks: Buffer[] = []
|
|
1570
|
+
let size = 0
|
|
1571
|
+
let over = false
|
|
1572
|
+
for await (const chunk of req) {
|
|
1573
|
+
size += (chunk as Buffer).length
|
|
1574
|
+
if (size > adapterRoute.maxBody) {
|
|
1575
|
+
over = true
|
|
1576
|
+
break
|
|
1577
|
+
}
|
|
1578
|
+
chunks.push(Buffer.from(chunk as Uint8Array))
|
|
1579
|
+
}
|
|
1580
|
+
if (over) {
|
|
1581
|
+
relayPluginError(res, 413, 'body-too-large')
|
|
1582
|
+
void drainUpload(req, 0, drainDeadlineMs)
|
|
1583
|
+
return
|
|
1584
|
+
}
|
|
1585
|
+
bufferedBody = Buffer.concat(chunks)
|
|
1586
|
+
if (bufferedBody.length > 0) {
|
|
1587
|
+
try {
|
|
1588
|
+
parsedBody = JSON.parse(bufferedBody.toString('utf8'))
|
|
1589
|
+
bodyGiven = true
|
|
1590
|
+
} catch {
|
|
1591
|
+
relayPluginError(res, 400, 'bad-request')
|
|
1592
|
+
return
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
const hits = (() => {
|
|
1597
|
+
try {
|
|
1598
|
+
return locateAdapterIds(adapterRoute, routePath, adapterSearch, bodyGiven ? parsedBody : undefined)
|
|
1599
|
+
} catch (error) {
|
|
1600
|
+
if (error instanceof AdapterMultiValueQueryError) {
|
|
1601
|
+
multiValueRefusal = true
|
|
1602
|
+
return []
|
|
1603
|
+
}
|
|
1604
|
+
throw error
|
|
1605
|
+
}
|
|
1606
|
+
})()
|
|
1607
|
+
if (multiValueRefusal) {
|
|
1608
|
+
relayPluginError(res, 400, 'bad-query')
|
|
1609
|
+
return
|
|
1610
|
+
}
|
|
1611
|
+
// The sub-client only forwards a classified-remote request, so
|
|
1612
|
+
// an adapter request that locates NOTHING is malformed here.
|
|
1613
|
+
if (hits.length === 0) {
|
|
1614
|
+
relayPluginError(res, 400, 'bad-request')
|
|
1615
|
+
return
|
|
1616
|
+
}
|
|
1617
|
+
// EVERY located id must be a shared session — one unshared id
|
|
1618
|
+
// among several refuses the whole request.
|
|
1619
|
+
for (const hit of hits) {
|
|
1620
|
+
if (!isAccessible(hit.value)) {
|
|
1621
|
+
relayPluginError(res, 403, 'not-shared')
|
|
1622
|
+
return
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
// The server hook: refuse, or replace the parsed body (strip a
|
|
1626
|
+
// `cwd`, pin a field…). The loopback coordinates stay verbatim.
|
|
1627
|
+
let loopbackBody: Buffer | undefined
|
|
1628
|
+
if (bodyGiven) loopbackBody = Buffer.from(JSON.stringify(parsedBody), 'utf8')
|
|
1629
|
+
const serverHook = adapter.hooks.server?.request
|
|
1630
|
+
if (serverHook !== undefined) {
|
|
1631
|
+
let verdict: AdapterVerdict
|
|
1632
|
+
try {
|
|
1633
|
+
verdict = serverHook({ method: pluginMethod, pathname: routePath, route: adapterRoute, query: adapterSearch, body: bodyGiven ? parsedBody : undefined })
|
|
1634
|
+
} catch {
|
|
1635
|
+
relayPluginError(res, 403, 'internal')
|
|
1636
|
+
return
|
|
1637
|
+
}
|
|
1638
|
+
if (!verdict.allow) {
|
|
1639
|
+
relayPluginError(res, 403, verdict.reason)
|
|
1640
|
+
return
|
|
1641
|
+
}
|
|
1642
|
+
if (bodyGiven && verdict.body !== undefined) {
|
|
1643
|
+
loopbackBody = Buffer.from(JSON.stringify(verdict.body), 'utf8')
|
|
1644
|
+
}
|
|
1645
|
+
// A hook may also rewrite the query (the file/upload routes'
|
|
1646
|
+
// `cwd` strip rides this) — the loopback then sends the
|
|
1647
|
+
// hook's serialization instead of the verbatim rawQuery.
|
|
1648
|
+
adapterQueryOverride = verdict.query !== undefined ? verdict.query.toString() : undefined
|
|
1649
|
+
}
|
|
1650
|
+
// The loopback body: a body-locating route sends its (possibly
|
|
1651
|
+
// hook-rewritten) buffer; any other body rides the cooperative
|
|
1652
|
+
// counting gate.
|
|
1653
|
+
const adapterAccept = headerValue(req, 'accept')
|
|
1654
|
+
const adapterRange = headerValue(req, 'range')
|
|
1655
|
+
const adapterContentType = headerValue(req, 'content-type')
|
|
1656
|
+
let adapterBodyStream: import('node:stream').Readable | null = null
|
|
1657
|
+
let adapterBodyGateError: Error | undefined
|
|
1658
|
+
if (!bodylessMethod && adapterRoute.locator.in === 'json-body') {
|
|
1659
|
+
adapterBodyStream = Readable.from(loopbackBody ?? Buffer.alloc(0))
|
|
1660
|
+
} else if (!bodylessMethod) {
|
|
1661
|
+
let pumped = 0
|
|
1662
|
+
const gate = new Transform({
|
|
1663
|
+
transform(chunk: Buffer, _encoding, callback) {
|
|
1664
|
+
pumped += chunk.length
|
|
1665
|
+
if (pumped > adapterRoute.maxBody) {
|
|
1666
|
+
callback(new Error('body-too-large'))
|
|
1667
|
+
return
|
|
1668
|
+
}
|
|
1669
|
+
callback(null, chunk)
|
|
1670
|
+
},
|
|
1671
|
+
})
|
|
1672
|
+
gate.on('error', (error) => {
|
|
1673
|
+
adapterBodyGateError = error
|
|
1674
|
+
adapterHangUp.abort()
|
|
1675
|
+
})
|
|
1676
|
+
req.pipe(gate)
|
|
1677
|
+
adapterBodyStream = gate
|
|
1678
|
+
}
|
|
1679
|
+
// The loopback: the coordinates travel VERBATIM, on purpose —
|
|
1680
|
+
// the sub-client has already rewritten every located virtual id
|
|
1681
|
+
// to its real half (query re-serialized, path segment
|
|
1682
|
+
// re-encoded, JSON body re-serialized), so what the server
|
|
1683
|
+
// validated (the share check below ran over the SAME locate)
|
|
1684
|
+
// is byte-for-byte what the local plugin will see. Re-locating
|
|
1685
|
+
// here would only risk divergence. The ONE exception is an
|
|
1686
|
+
// explicit hook verdict (`query` above): the better-sidebar
|
|
1687
|
+
// adapter strips the file/upload routes' `cwd` so the backend
|
|
1688
|
+
// resolves it from the session.
|
|
1689
|
+
const outbound = await bridge.loopback.request(
|
|
1690
|
+
pluginMethod,
|
|
1691
|
+
routePath,
|
|
1692
|
+
adapterQueryOverride ?? rawQuery,
|
|
1693
|
+
{
|
|
1694
|
+
...(adapterAccept !== undefined ? { accept: adapterAccept } : {}),
|
|
1695
|
+
...(adapterRange !== undefined ? { range: adapterRange } : {}),
|
|
1696
|
+
...(adapterContentType !== undefined ? { contentType: adapterContentType } : {}),
|
|
1697
|
+
},
|
|
1698
|
+
adapterBodyStream,
|
|
1699
|
+
adapterHangUp.signal,
|
|
1700
|
+
)
|
|
1701
|
+
if (adapterBodyGateError !== undefined && !res.headersSent) {
|
|
1702
|
+
relayPluginError(res, 413, 'body-too-large')
|
|
1703
|
+
void drainUpload(req, 0, drainDeadlineMs)
|
|
1704
|
+
return
|
|
1705
|
+
}
|
|
1706
|
+
// The adapter header allowlist (PB-12): the HTML preview's CSP
|
|
1707
|
+
// sandbox and download dispositions ride through to the phone.
|
|
1708
|
+
// The loopback collected them into `extraHeaders`.
|
|
1709
|
+
res.writeHead(outbound.status, {
|
|
1710
|
+
...(outbound.contentType !== undefined ? { 'Content-Type': outbound.contentType } : {}),
|
|
1711
|
+
...(outbound.contentLength !== undefined ? { 'Content-Length': outbound.contentLength } : {}),
|
|
1712
|
+
...(outbound.cacheControl !== undefined ? { 'Cache-Control': outbound.cacheControl } : {}),
|
|
1713
|
+
...outbound.extraHeaders,
|
|
1714
|
+
})
|
|
1715
|
+
await new Promise<void>((resolve, reject) => {
|
|
1716
|
+
const upstream = outbound.stream
|
|
1717
|
+
upstream.on('data', (chunk: Buffer) => {
|
|
1718
|
+
if (adapterGone || res.destroyed || res.writableEnded) {
|
|
1719
|
+
upstream.destroy()
|
|
1720
|
+
return
|
|
1721
|
+
}
|
|
1722
|
+
if (!res.write(chunk)) {
|
|
1723
|
+
upstream.pause()
|
|
1724
|
+
res.once('drain', () => upstream.resume())
|
|
1725
|
+
}
|
|
1726
|
+
})
|
|
1727
|
+
upstream.on('end', () => resolve())
|
|
1728
|
+
upstream.on('error', reject)
|
|
1729
|
+
})
|
|
1730
|
+
await new Promise<void>((resolve) => {
|
|
1731
|
+
if (res.writableEnded || res.destroyed) {
|
|
1732
|
+
resolve()
|
|
1733
|
+
return
|
|
1734
|
+
}
|
|
1735
|
+
const watchdog = setTimeout(() => {
|
|
1736
|
+
try {
|
|
1737
|
+
res.destroy()
|
|
1738
|
+
} catch {
|
|
1739
|
+
// Already gone.
|
|
1740
|
+
}
|
|
1741
|
+
}, endDrainTimeoutMs)
|
|
1742
|
+
if (typeof watchdog.unref === 'function') watchdog.unref()
|
|
1743
|
+
const settled = (): void => {
|
|
1744
|
+
clearTimeout(watchdog)
|
|
1745
|
+
resolve()
|
|
1746
|
+
}
|
|
1747
|
+
res.once('finish', settled)
|
|
1748
|
+
res.once('close', settled)
|
|
1749
|
+
res.end()
|
|
1750
|
+
})
|
|
1751
|
+
} catch (error) {
|
|
1752
|
+
if (adapterHangUp.signal.aborted) {
|
|
1753
|
+
try {
|
|
1754
|
+
res.destroy()
|
|
1755
|
+
} catch {
|
|
1756
|
+
// Already gone.
|
|
1757
|
+
}
|
|
1758
|
+
} else if (isLoopbackAuthError(error)) {
|
|
1759
|
+
relayPluginError(res, 502, 'loopback-auth')
|
|
1760
|
+
} else if (!res.headersSent) {
|
|
1761
|
+
relayPluginError(res, 502, 'internal')
|
|
1762
|
+
} else {
|
|
1763
|
+
try {
|
|
1764
|
+
res.destroy()
|
|
1765
|
+
} catch {
|
|
1766
|
+
// Already gone.
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
} finally {
|
|
1770
|
+
res.off('close', onAdapterGone)
|
|
1771
|
+
res.off('error', onAdapterGone)
|
|
1772
|
+
const remaining = (pluginsByDevice.get(adapterDevice) ?? 1) - 1
|
|
1773
|
+
if (remaining <= 0) pluginsByDevice.delete(adapterDevice)
|
|
1774
|
+
else pluginsByDevice.set(adapterDevice, remaining)
|
|
1775
|
+
}
|
|
1776
|
+
return
|
|
1777
|
+
}
|
|
1778
|
+
// A yielded adapter falls through: the cooperative chain's digest
|
|
1779
|
+
// check refuses these stale coordinates below.
|
|
1780
|
+
}
|
|
1781
|
+
// ---- the validation chain, in order, first failure answers --------
|
|
1782
|
+
// Steps 1/2/3 and 4-6 (registry, version+digest, exact route, the
|
|
1783
|
+
// parse-once query, the share table, the consent wall) are SHARED with
|
|
1784
|
+
// the tunnel open route — see validatePluginCall. This route's own
|
|
1785
|
+
// extras are the method check (3b) and the body gate (3c) below.
|
|
1786
|
+
const validation = validatePluginCall(
|
|
1787
|
+
bridge,
|
|
1788
|
+
{ pluginName, pluginVersion, pluginDigest, routePath, rawQuery },
|
|
1789
|
+
isAccessible,
|
|
1790
|
+
)
|
|
1791
|
+
if (!validation.ok) {
|
|
1792
|
+
relayPluginError(res, validation.status, validation.code)
|
|
1793
|
+
return
|
|
1794
|
+
}
|
|
1795
|
+
const route = validation.route
|
|
1796
|
+
// 3b. (PB-9) the METHOD must be one the route declared — the write
|
|
1797
|
+
// surface is exactly as explicit as the read one was in 2.1: a
|
|
1798
|
+
// route that declared no `methods` forwards GET only, and an
|
|
1799
|
+
// undeclared method answers 405 rather than reaching the plugin.
|
|
1800
|
+
if (!routeMethodsOf(route).includes(pluginMethod)) {
|
|
1801
|
+
relayPluginError(res, 405, 'method-undeclared')
|
|
1802
|
+
return
|
|
1803
|
+
}
|
|
1804
|
+
// 3c. (PB-9) the body gate: a declared Content-Length over the route's
|
|
1805
|
+
// `maxBody` refuses BEFORE a byte moves (the T51 pre-check
|
|
1806
|
+
// discipline — the tail is drained after the answer); a chunked
|
|
1807
|
+
// body carries no length header and is counted while it streams,
|
|
1808
|
+
// in the pump below.
|
|
1809
|
+
const maxBody = routeMaxBodyOf(route)
|
|
1810
|
+
const declaredLength = Number(headerValue(req, 'content-length'))
|
|
1811
|
+
if (!bodylessMethod && Number.isFinite(declaredLength) && declaredLength > maxBody) {
|
|
1812
|
+
relayPluginError(res, 413, 'body-too-large')
|
|
1813
|
+
await drainUpload(req, 0, drainDeadlineMs)
|
|
1814
|
+
return
|
|
1815
|
+
}
|
|
1816
|
+
const params = validation.params
|
|
1817
|
+
const normalizedQuery = validation.normalizedQuery
|
|
1818
|
+
// 7. the per-device budget — taken only after every wall above, held
|
|
1819
|
+
// for the whole response stream, released in the finally.
|
|
1820
|
+
const device = headerValue(req, 'x-zen-remote-device') ?? ''
|
|
1821
|
+
if ((pluginsByDevice.get(device) ?? 0) >= MAX_PLUGIN_REQUESTS_PER_DEVICE) {
|
|
1822
|
+
relayPluginError(res, 429, 'busy')
|
|
1823
|
+
return
|
|
1824
|
+
}
|
|
1825
|
+
pluginsByDevice.set(device, (pluginsByDevice.get(device) ?? 0) + 1)
|
|
1826
|
+
// A sub-client hang-up cancels the loopback request mid-stream, like
|
|
1827
|
+
// the invoke route's wiring; the pump below reads the same flag (and
|
|
1828
|
+
// 'error' rides along, v1/stream's discipline).
|
|
1829
|
+
let clientGone = false
|
|
1830
|
+
// The counting gate's recorded failure (PB-9), read by the catch —
|
|
1831
|
+
// declared here because the gate itself lives inside the try.
|
|
1832
|
+
let bodyGateError: Error | undefined
|
|
1833
|
+
const hangUp = new AbortController()
|
|
1834
|
+
const onClientGone = (): void => {
|
|
1835
|
+
clientGone = true
|
|
1836
|
+
if (!res.writableEnded) hangUp.abort()
|
|
1837
|
+
}
|
|
1838
|
+
res.on('close', onClientGone)
|
|
1839
|
+
res.on('error', onClientGone)
|
|
1840
|
+
try {
|
|
1841
|
+
const accept = headerValue(req, 'accept')
|
|
1842
|
+
const range = headerValue(req, 'range')
|
|
1843
|
+
const contentType = headerValue(req, 'content-type')
|
|
1844
|
+
// The forwarded body (PB-9): the relay request's body streamed
|
|
1845
|
+
// through a counting gate — node's pipe owns the backpressure, and
|
|
1846
|
+
// the route's `maxBody` is enforced ON THE STREAM (a chunked body
|
|
1847
|
+
// carries no length header, so the declared-length pre-check above
|
|
1848
|
+
// could not see it). Past the cap the gate errors, which tears the
|
|
1849
|
+
// loopback request down mid-write and lands as 413 below (headers
|
|
1850
|
+
// not yet sent — the plugin's response cannot have arrived while we
|
|
1851
|
+
// are still sending its request body).
|
|
1852
|
+
let bodyStream: import('node:http').IncomingMessage | import('node:stream').Readable | null = null
|
|
1853
|
+
// The gate's own failure must REACH the loopback request (tearing it
|
|
1854
|
+
// down mid-write) and this route's catch — an unhandled Transform
|
|
1855
|
+
// 'error' would crash the process instead. The dedicated abort
|
|
1856
|
+
// signal does both: the loopback dials hang on it, and the catch
|
|
1857
|
+
// reads the recorded error first.
|
|
1858
|
+
const bodyAbort = new AbortController()
|
|
1859
|
+
const bodySignal = AbortSignal.any([hangUp.signal, bodyAbort.signal])
|
|
1860
|
+
if (!bodylessMethod) {
|
|
1861
|
+
let pumped = 0
|
|
1862
|
+
const gate = new Transform({
|
|
1863
|
+
transform(chunk: Buffer, _encoding, callback) {
|
|
1864
|
+
pumped += chunk.length
|
|
1865
|
+
if (pumped > maxBody) {
|
|
1866
|
+
req.pause()
|
|
1867
|
+
callback(new Error('body-too-large'))
|
|
1868
|
+
return
|
|
1869
|
+
}
|
|
1870
|
+
callback(null, chunk)
|
|
1871
|
+
},
|
|
1872
|
+
})
|
|
1873
|
+
gate.on('error', (error) => {
|
|
1874
|
+
if (bodyGateError === undefined) bodyGateError = error
|
|
1875
|
+
bodyAbort.abort()
|
|
1876
|
+
})
|
|
1877
|
+
req.on('error', (error) => {
|
|
1878
|
+
if (bodyGateError === undefined) bodyGateError = error
|
|
1879
|
+
gate.destroy(error)
|
|
1880
|
+
})
|
|
1881
|
+
gate.on('close', () => { if (!res.writableEnded) req.pause() })
|
|
1882
|
+
bodyStream = gate
|
|
1883
|
+
req.pipe(gate)
|
|
1884
|
+
}
|
|
1885
|
+
const outbound = await bridge.loopback.request(
|
|
1886
|
+
pluginMethod,
|
|
1887
|
+
routePath,
|
|
1888
|
+
normalizedQuery,
|
|
1889
|
+
{
|
|
1890
|
+
...(accept !== undefined ? { accept } : {}),
|
|
1891
|
+
...(range !== undefined ? { range } : {}),
|
|
1892
|
+
...(contentType !== undefined ? { contentType } : {}),
|
|
1893
|
+
},
|
|
1894
|
+
bodyStream,
|
|
1895
|
+
bodySignal,
|
|
1896
|
+
)
|
|
1897
|
+
// Success: status plus the three whitelisted headers travel, the
|
|
1898
|
+
// body streams — and NO x-zen-remote-error (the marker is refusal-
|
|
1899
|
+
// only, so the client can classify with one header read).
|
|
1900
|
+
res.writeHead(outbound.status, {
|
|
1901
|
+
...(outbound.contentType !== undefined ? { 'Content-Type': outbound.contentType } : {}),
|
|
1902
|
+
...(outbound.contentLength !== undefined ? { 'Content-Length': outbound.contentLength } : {}),
|
|
1903
|
+
...(outbound.cacheControl !== undefined ? { 'Cache-Control': outbound.cacheControl } : {}),
|
|
1904
|
+
})
|
|
1905
|
+
// The pump: chunk-for-chunk with real backpressure — a full socket
|
|
1906
|
+
// pauses the plugin's response instead of buffering it here, so a
|
|
1907
|
+
// slow phone reads a big file without this process ever holding
|
|
1908
|
+
// more than a socket buffer of it. Every chunk re-checks the
|
|
1909
|
+
// response's liveness first (the v1/stream write discipline): a
|
|
1910
|
+
// client that died mid-stream stops the pump on its next chunk
|
|
1911
|
+
// instead of racing one more write into a dead socket, and a
|
|
1912
|
+
// response 'error' (a write racing a destroyed socket surfaces
|
|
1913
|
+
// there) reacts exactly like the hang-up.
|
|
1914
|
+
let clientGoneSeen = false
|
|
1915
|
+
await new Promise<void>((resolve, reject) => {
|
|
1916
|
+
const upstream = outbound.stream
|
|
1917
|
+
upstream.on('data', (chunk: Buffer) => {
|
|
1918
|
+
if (clientGone || res.destroyed || res.writableEnded) {
|
|
1919
|
+
upstream.destroy()
|
|
1920
|
+
return
|
|
1921
|
+
}
|
|
1922
|
+
if (!res.write(chunk)) {
|
|
1923
|
+
upstream.pause()
|
|
1924
|
+
res.once('drain', () => upstream.resume())
|
|
1925
|
+
}
|
|
1926
|
+
})
|
|
1927
|
+
upstream.on('end', () => resolve())
|
|
1928
|
+
upstream.on('error', reject)
|
|
1929
|
+
})
|
|
1930
|
+
// The v1/stream finish discipline (T43-A): the tail is in the
|
|
1931
|
+
// kernel's hands now, and a client that stopped reading must not
|
|
1932
|
+
// pin this device slot forever — one watchdog, disarmed by `finish`
|
|
1933
|
+
// (the honest drained signal) or `close`, destroys otherwise.
|
|
1934
|
+
await new Promise<void>((resolve) => {
|
|
1935
|
+
if (res.writableEnded || res.destroyed) {
|
|
1936
|
+
resolve()
|
|
1937
|
+
return
|
|
1938
|
+
}
|
|
1939
|
+
const watchdog = setTimeout(() => {
|
|
1940
|
+
try {
|
|
1941
|
+
res.destroy()
|
|
1942
|
+
} catch {
|
|
1943
|
+
// Already gone.
|
|
1944
|
+
}
|
|
1945
|
+
}, endDrainTimeoutMs)
|
|
1946
|
+
if (typeof watchdog.unref === 'function') watchdog.unref()
|
|
1947
|
+
const settled = (): void => {
|
|
1948
|
+
clearTimeout(watchdog)
|
|
1949
|
+
resolve()
|
|
1950
|
+
}
|
|
1951
|
+
res.once('finish', settled)
|
|
1952
|
+
res.once('close', settled)
|
|
1953
|
+
res.end()
|
|
1954
|
+
})
|
|
1955
|
+
} catch (error) {
|
|
1956
|
+
if (!res.headersSent && bodyGateError !== undefined && bodyGateError.message === 'body-too-large') {
|
|
1957
|
+
// The counting gate tripped mid-forward (PB-9): a chunked body
|
|
1958
|
+
// past the route's `maxBody`. The plugin's answer cannot have
|
|
1959
|
+
// started while we were still writing its request body, so the
|
|
1960
|
+
// honest 413 still fits on the wire; the relay body's tail is
|
|
1961
|
+
// drained after the answer, bounded and timed like every
|
|
1962
|
+
// oversized read here.
|
|
1963
|
+
relayPluginError(res, 413, 'body-too-large')
|
|
1964
|
+
void drainUpload(req, 0, drainDeadlineMs)
|
|
1965
|
+
} else if (hangUp.signal.aborted) {
|
|
1966
|
+
// The sub-client is gone mid-stream: its socket is dead, and the
|
|
1967
|
+
// abort already tore the loopback request down.
|
|
1968
|
+
try {
|
|
1969
|
+
res.destroy()
|
|
1970
|
+
} catch {
|
|
1971
|
+
// Already gone.
|
|
1972
|
+
}
|
|
1973
|
+
} else if (isLoopbackAuthError(error)) {
|
|
1974
|
+
// The cookie could not be obtained (no token URL on this host, a
|
|
1975
|
+
// failed exchange) — the relay's own wall, not a business answer.
|
|
1976
|
+
relayPluginError(res, 502, 'loopback-auth')
|
|
1977
|
+
} else if (!res.headersSent) {
|
|
1978
|
+
// Any other loopback failure before the answer started (the web
|
|
1979
|
+
// server moved, the plugin route crashed early) is this server's
|
|
1980
|
+
// fault: the bare internal code, no message.
|
|
1981
|
+
relayPluginError(res, 502, 'internal')
|
|
1982
|
+
} else {
|
|
1983
|
+
// Mid-stream failure with headers already on the wire: the status
|
|
1984
|
+
// cannot be changed, so the response is cut — the client sees a
|
|
1985
|
+
// truncated body, the honest shape of a broken stream.
|
|
1986
|
+
try {
|
|
1987
|
+
res.destroy()
|
|
1988
|
+
} catch {
|
|
1989
|
+
// Already gone.
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
} finally {
|
|
1993
|
+
res.off('close', onClientGone)
|
|
1994
|
+
res.off('error', onClientGone)
|
|
1995
|
+
const remaining = (pluginsByDevice.get(device) ?? 1) - 1
|
|
1996
|
+
if (remaining <= 0) pluginsByDevice.delete(device)
|
|
1997
|
+
else pluginsByDevice.set(device, remaining)
|
|
1998
|
+
}
|
|
1999
|
+
return
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
if (req.method === 'POST' && pathname === `${RELAY_PREFIX}/v1/plugin-tunnel/open`) {
|
|
2003
|
+
const tunnel = options.pluginBridge?.tunnel
|
|
2004
|
+
if (tunnel === undefined) {
|
|
2005
|
+
responseJson(res, 501, { ok: false, error: { code: 'unsupported' } })
|
|
2006
|
+
return
|
|
2007
|
+
}
|
|
2008
|
+
await tunnel.open(req, res)
|
|
2009
|
+
return
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
if (req.method === 'POST' && pathname === `${RELAY_PREFIX}/v1/plugin-tunnel/send`) {
|
|
2013
|
+
const tunnel = options.pluginBridge?.tunnel
|
|
2014
|
+
if (tunnel === undefined) {
|
|
2015
|
+
responseJson(res, 501, { ok: false, error: { code: 'unsupported' } })
|
|
2016
|
+
return
|
|
2017
|
+
}
|
|
2018
|
+
await tunnel.send(req, res)
|
|
2019
|
+
return
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
if (req.method === 'POST' && pathname === `${RELAY_PREFIX}/v1/plugin-tunnel/close`) {
|
|
2023
|
+
const tunnel = options.pluginBridge?.tunnel
|
|
2024
|
+
if (tunnel === undefined) {
|
|
2025
|
+
responseJson(res, 501, { ok: false, error: { code: 'unsupported' } })
|
|
2026
|
+
return
|
|
2027
|
+
}
|
|
2028
|
+
await tunnel.close(req, res)
|
|
2029
|
+
return
|
|
2030
|
+
}
|
|
2031
|
+
|
|
1377
2032
|
if (req.method === 'POST' && pathname === `${RELAY_PREFIX}/v1/upload`) {
|
|
1378
2033
|
// The binary upload channel (T51): the sub-client's wrapped
|
|
1379
2034
|
// `/api/session/uploadFileBinary` forwards a remote session's raw
|