dsh-zen-remote 2.0.2 → 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 +63 -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/intercept.js +5 -2
- package/lib/intercept.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 +340 -4
- package/lib/relay-client.js.map +1 -1
- package/lib/relay-server.js +665 -7
- 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/intercept.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 +81 -1
- 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/intercept.ts +5 -2
- 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 +413 -4
- package/src/relay-server.ts +687 -9
package/lib/relay-server.js
CHANGED
|
@@ -26,12 +26,17 @@
|
|
|
26
26
|
* a 404.
|
|
27
27
|
*/
|
|
28
28
|
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
29
|
+
import { Readable, Transform } from 'node:stream';
|
|
29
30
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
30
31
|
import { createRequire } from 'node:module';
|
|
31
32
|
import { join } from 'node:path';
|
|
32
33
|
import { decideHttpRoute, decideInvoke, decideStream, decideUploadQuery, parseEventResultBody } from './relay-access.js';
|
|
33
34
|
import { createWorkspaceFollowState, filterControlFrame, filterJobListFrame, filterModelCatalogResult, filterSessionListResult, filterWorkspaceFrame, } from './relay-filter.js';
|
|
34
35
|
import { SESSION_REFERENCE_URI, collectReferenceTexts, decodeSessionReferenceUri } from './session-reference.js';
|
|
36
|
+
import { routeMethodsOf, routeMaxBodyOf } from './plugin-bridge.js';
|
|
37
|
+
import { validatePluginCall } from './plugin-tunnel.js';
|
|
38
|
+
import { isLoopbackAuthError } from './plugin-loopback.js';
|
|
39
|
+
import { matchAdapterRoute, locateAdapterIds, adapterOwnsRegistryEntry, AdapterMultiValueQueryError } from './plugin-adapter-core.js';
|
|
35
40
|
/**
|
|
36
41
|
* Registration prefix on the host webServer. Deliberately WITHOUT the
|
|
37
42
|
* trailing slash: the webserver matches a prefix route as `pathname === p ||
|
|
@@ -112,6 +117,8 @@ const MAX_INVOKES_PER_DEVICE = 32;
|
|
|
112
117
|
* in-process dispatch writing to the attachments store — behind this route.
|
|
113
118
|
*/
|
|
114
119
|
const MAX_UPLOADS_PER_DEVICE = 8;
|
|
120
|
+
/** Concurrent bridged plugin GETs one device may keep in flight (PB-3). */
|
|
121
|
+
const MAX_PLUGIN_REQUESTS_PER_DEVICE = 8;
|
|
115
122
|
/** Most recent forwarded waterfall events remembered per `$zr/events`
|
|
116
123
|
* subscription (T32-fix): the registry is only an ownership record, so this
|
|
117
124
|
* bounds a pathological burst the same way the job cache does — past the
|
|
@@ -214,6 +221,17 @@ function responseJson(res, status, body) {
|
|
|
214
221
|
res.writeHead(status);
|
|
215
222
|
res.end(bytes);
|
|
216
223
|
}
|
|
224
|
+
/**
|
|
225
|
+
* One plugin-bridge refusal (PB-3): the same JSON envelope as every other
|
|
226
|
+
* relay refusal, plus the `x-zen-remote-error` marker header — the
|
|
227
|
+
* sub-client's passthrough layer reads the CODE off a response whose body
|
|
228
|
+
* it must not have to parse (the success path streams opaque plugin bytes;
|
|
229
|
+
* keeping the refusal marker a header lets one code path classify both).
|
|
230
|
+
*/
|
|
231
|
+
function relayPluginError(res, status, code) {
|
|
232
|
+
res.setHeader('x-zen-remote-error', code);
|
|
233
|
+
responseJson(res, status, { ok: false, error: { code } });
|
|
234
|
+
}
|
|
217
235
|
/** First (and in practice only) value of one request header. */
|
|
218
236
|
function headerValue(req, name) {
|
|
219
237
|
const raw = req.headers[name];
|
|
@@ -395,6 +413,15 @@ function drainUpload(req, fromBytes, deadlineMs) {
|
|
|
395
413
|
req.resume();
|
|
396
414
|
});
|
|
397
415
|
}
|
|
416
|
+
/** The 200 error envelope for a failed gateway call, `details` included when
|
|
417
|
+
* the host error carried a sanitized one (see errorOf). */
|
|
418
|
+
function errorEnvelopeOf(error) {
|
|
419
|
+
const { code, message, details } = errorOf(error);
|
|
420
|
+
const inner = message === undefined ? { code } : { code, message };
|
|
421
|
+
if (details !== undefined)
|
|
422
|
+
inner.details = details;
|
|
423
|
+
return { ok: false, error: inner };
|
|
424
|
+
}
|
|
398
425
|
/**
|
|
399
426
|
* What may travel to the client about a failed gateway call. DSH's own errors
|
|
400
427
|
* always carry a `namespace/name`-shaped string `code` — those pass through
|
|
@@ -414,7 +441,26 @@ function errorOf(error) {
|
|
|
414
441
|
if (code.endsWith('/internal'))
|
|
415
442
|
return { code };
|
|
416
443
|
const text = error instanceof Error ? error.message : String(error);
|
|
417
|
-
|
|
444
|
+
// The structured `details` beside code/message (2026-10-10): the host
|
|
445
|
+
// rejects an image-bearing prompt with `session/attachment-invalid` whose
|
|
446
|
+
// `details.reason` (`MODEL_DOES_NOT_SUPPORT_IMAGES`, …) is what the client
|
|
447
|
+
// UI maps onto a user-facing line — dropped here, the UI prints
|
|
448
|
+
// `undefined`. Scalars and clipped strings only, bounded key count;
|
|
449
|
+
// nested objects/arrays stay out so server internals cannot ride this.
|
|
450
|
+
const raw = error.details;
|
|
451
|
+
const details = {};
|
|
452
|
+
if (isPlainObject(raw)) {
|
|
453
|
+
for (const [key, value] of Object.entries(raw).slice(0, 16)) {
|
|
454
|
+
if (typeof value === 'string')
|
|
455
|
+
details[key] = value.slice(0, MAX_MESSAGE_CHARS);
|
|
456
|
+
else if (typeof value === 'number' || typeof value === 'boolean' || value === null)
|
|
457
|
+
details[key] = value;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
const out = { code, message: text.slice(0, MAX_MESSAGE_CHARS) };
|
|
461
|
+
if (Object.keys(details).length > 0)
|
|
462
|
+
out.details = details;
|
|
463
|
+
return out;
|
|
418
464
|
}
|
|
419
465
|
/**
|
|
420
466
|
* Build the relay route handler mounted under {@link RELAY_PREFIX}. Exported
|
|
@@ -465,6 +511,16 @@ export function createRelayHandler(options) {
|
|
|
465
511
|
const invokesByDevice = new Map();
|
|
466
512
|
/** Uploads currently in flight per device id, for the upload budget (T51-fix). */
|
|
467
513
|
const uploadsByDevice = new Map();
|
|
514
|
+
/**
|
|
515
|
+
* Bridged plugin GETs currently in flight per device id (PB-3): the same
|
|
516
|
+
* per-device budget discipline as the invoke and upload counters above.
|
|
517
|
+
* The cap is 8 — the upload budget's number — because a bridged call is
|
|
518
|
+
* one panel's data fetch, a burst of a handful per session view; the slot
|
|
519
|
+
* stays taken for the WHOLE stream (a slow plugin response pins one slot
|
|
520
|
+
* until its last byte), which is exactly what the budget bounds. Past the
|
|
521
|
+
* cap the request answers 429 `busy` without touching the counter.
|
|
522
|
+
*/
|
|
523
|
+
const pluginsByDevice = new Map();
|
|
468
524
|
/**
|
|
469
525
|
* The device NAME this process last saw per device id (T59): every relay
|
|
470
526
|
* request rides the gateway's fresh `x-zen-remote-device-name` marking
|
|
@@ -740,6 +796,20 @@ export function createRelayHandler(options) {
|
|
|
740
796
|
catch {
|
|
741
797
|
fingerprints = {};
|
|
742
798
|
}
|
|
799
|
+
// PB-2: the plugin bridge table, computed FRESH per handshake (the
|
|
800
|
+
// registry and the row's consent map are both live state), with the
|
|
801
|
+
// SAME guard: a throwing source answers the empty table — the field
|
|
802
|
+
// still travels, so the client reads "no plugins on this server"
|
|
803
|
+
// (not-on-server), never a failed handshake. The field is ALWAYS
|
|
804
|
+
// present in the reply: only its ABSENCE (an older server) can ever
|
|
805
|
+
// mean server-too-old on the client.
|
|
806
|
+
let plugins = {};
|
|
807
|
+
try {
|
|
808
|
+
plugins = serverInfo.plugins?.() ?? {};
|
|
809
|
+
}
|
|
810
|
+
catch {
|
|
811
|
+
plugins = {};
|
|
812
|
+
}
|
|
743
813
|
responseJson(res, 200, {
|
|
744
814
|
ok: true,
|
|
745
815
|
relayProtocol: RELAY_PROTOCOL,
|
|
@@ -749,6 +819,9 @@ export function createRelayHandler(options) {
|
|
|
749
819
|
// T42: the server's own interface fingerprints (empty when the wiring
|
|
750
820
|
// had nothing to compute — the client judges group by group).
|
|
751
821
|
fingerprints,
|
|
822
|
+
// PB-2: the plugin bridge table (empty when the wiring had nothing to
|
|
823
|
+
// register — the client judges plugin by plugin).
|
|
824
|
+
plugins,
|
|
752
825
|
// T59: the CALLING device's name, as the gateway's device table holds
|
|
753
826
|
// it at this moment (the header is written fresh per forward, so a
|
|
754
827
|
// just-renamed device reads its new name here). Undefined without the
|
|
@@ -850,8 +923,7 @@ export function createRelayHandler(options) {
|
|
|
850
923
|
responseJson(res, 200, isPlainObject(envelope) ? envelope : { ok: false, error: { code: 'internal' } });
|
|
851
924
|
}
|
|
852
925
|
catch (error) {
|
|
853
|
-
|
|
854
|
-
responseJson(res, 200, message === undefined ? { ok: false, error: { code } } : { ok: false, error: { code, message } });
|
|
926
|
+
responseJson(res, 200, errorEnvelopeOf(error));
|
|
855
927
|
}
|
|
856
928
|
finally {
|
|
857
929
|
res.off('close', onClientGone);
|
|
@@ -999,8 +1071,7 @@ export function createRelayHandler(options) {
|
|
|
999
1071
|
// The gateway's own failures (unknown namespace, absent service) are
|
|
1000
1072
|
// business answers, not transport errors: they ride a 200 envelope
|
|
1001
1073
|
// like every other result, with the gateway's string code preserved.
|
|
1002
|
-
|
|
1003
|
-
responseJson(res, 200, message === undefined ? { ok: false, error: { code } } : { ok: false, error: { code, message } });
|
|
1074
|
+
responseJson(res, 200, errorEnvelopeOf(error));
|
|
1004
1075
|
}
|
|
1005
1076
|
finally {
|
|
1006
1077
|
res.off('close', onClientGone);
|
|
@@ -1086,6 +1157,594 @@ export function createRelayHandler(options) {
|
|
|
1086
1157
|
}
|
|
1087
1158
|
return;
|
|
1088
1159
|
}
|
|
1160
|
+
if (req.method === 'POST' && pathname === `${RELAY_PREFIX}/v1/plugin`) {
|
|
1161
|
+
// The plugin bridge's forwarding route (PB-3, write requests since
|
|
1162
|
+
// PB-9): the sub-client hands over one call against a BRIDGED plugin's
|
|
1163
|
+
// route (coordinates in the x-zr-* headers), and this server re-issues
|
|
1164
|
+
// it against its OWN plugin over loopback HTTP (docs/adr/0001),
|
|
1165
|
+
// streaming the answer back. The METHOD rides `x-zr-method` (PB-9,
|
|
1166
|
+
// default GET); a body rides as the raw byte stream, forwarded
|
|
1167
|
+
// verbatim.
|
|
1168
|
+
const bridge = options.pluginBridge;
|
|
1169
|
+
if (bridge === undefined) {
|
|
1170
|
+
responseJson(res, 501, { ok: false, error: { code: 'unsupported' } });
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
// The method, before anything else — it decides whether a body is
|
|
1174
|
+
// even legal. GET/HEAD stay bodyless by protocol: the coordinates
|
|
1175
|
+
// ride the headers, so anything at all in the body is a shape
|
|
1176
|
+
// violation. One byte settles it; a real body is drained (bounded,
|
|
1177
|
+
// timed — the upload route's discipline) AFTER the answer, like every
|
|
1178
|
+
// oversized read here.
|
|
1179
|
+
const pluginMethod = headerValue(req, 'x-zr-method') ?? 'GET';
|
|
1180
|
+
const bodylessMethod = pluginMethod === 'GET' || pluginMethod === 'HEAD';
|
|
1181
|
+
let bodyBytes = 0;
|
|
1182
|
+
if (bodylessMethod) {
|
|
1183
|
+
for await (const chunk of req) {
|
|
1184
|
+
bodyBytes += chunk.length;
|
|
1185
|
+
break;
|
|
1186
|
+
}
|
|
1187
|
+
if (bodyBytes > 0) {
|
|
1188
|
+
relayPluginError(res, 400, 'bad-request');
|
|
1189
|
+
await drainUpload(req, 0, drainDeadlineMs);
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
// The coordinates, verbatim off the wire — `x-zr-path` is matched
|
|
1194
|
+
// UNDECODED (the module-wide no-decode discipline), the query parsed
|
|
1195
|
+
// exactly once below.
|
|
1196
|
+
const pluginName = headerValue(req, 'x-zr-plugin');
|
|
1197
|
+
const pluginVersion = headerValue(req, 'x-zr-plugin-version');
|
|
1198
|
+
const pluginDigest = headerValue(req, 'x-zr-plugin-digest');
|
|
1199
|
+
const routePath = headerValue(req, 'x-zr-path');
|
|
1200
|
+
const rawQuery = headerValue(req, 'x-zr-query') ?? '';
|
|
1201
|
+
if (pluginName === undefined || pluginName === '' ||
|
|
1202
|
+
pluginVersion === undefined || pluginVersion === '' ||
|
|
1203
|
+
pluginDigest === undefined || pluginDigest === '' ||
|
|
1204
|
+
routePath === undefined || !routePath.startsWith('/')) {
|
|
1205
|
+
relayPluginError(res, 400, 'bad-request');
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
// ---- PB-11: the adapter routes claim the request BEFORE the
|
|
1209
|
+
// cooperative chain. An adapter route matches by equality or one
|
|
1210
|
+
// segment deeper (not the cooperative exact equality), its ids live
|
|
1211
|
+
// wherever ITS locator says (query, path segment, JSON body), and the
|
|
1212
|
+
// loopback receives the coordinates VERBATIM — the sub-client has
|
|
1213
|
+
// already rewritten every virtual id to its real half, so what gets
|
|
1214
|
+
// validated here is exactly what the local plugin will see. A YIELDED
|
|
1215
|
+
// adapter (the plugin registered its own declaration) falls through to
|
|
1216
|
+
// the cooperative chain below, whose digest check refuses the stale
|
|
1217
|
+
// adapter coordinates with `plugin-mismatch`.
|
|
1218
|
+
const adapterHit = matchAdapterRoute(options.pluginBridge?.adapters, routePath);
|
|
1219
|
+
if (adapterHit !== undefined) {
|
|
1220
|
+
const { adapter, route: adapterRoute } = adapterHit;
|
|
1221
|
+
if (adapterOwnsRegistryEntry(adapter, bridge.registry.get(adapter.plugin))) {
|
|
1222
|
+
// Coordinates first: the forwarded version/digest must be the
|
|
1223
|
+
// adapter's registered pair.
|
|
1224
|
+
if (pluginVersion !== adapter.declaration.version || pluginDigest !== adapter.digest) {
|
|
1225
|
+
relayPluginError(res, 409, 'plugin-mismatch');
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
// Whole-machine data by definition: the live consent wall, the
|
|
1229
|
+
// same knob the cooperative server-scope routes read.
|
|
1230
|
+
let adapterConsent = {};
|
|
1231
|
+
try {
|
|
1232
|
+
adapterConsent = bridge.getConsent();
|
|
1233
|
+
}
|
|
1234
|
+
catch {
|
|
1235
|
+
adapterConsent = {};
|
|
1236
|
+
}
|
|
1237
|
+
if (adapterConsent[adapter.plugin] !== true) {
|
|
1238
|
+
relayPluginError(res, 403, 'consent-off');
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
1241
|
+
if (!adapterRoute.methods.includes(pluginMethod)) {
|
|
1242
|
+
relayPluginError(res, 405, 'method-undeclared');
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
// The per-device budget — the SAME table the cooperative forwards
|
|
1246
|
+
// share, held for the whole response stream.
|
|
1247
|
+
const adapterDevice = headerValue(req, 'x-zen-remote-device') ?? '';
|
|
1248
|
+
if ((pluginsByDevice.get(adapterDevice) ?? 0) >= MAX_PLUGIN_REQUESTS_PER_DEVICE) {
|
|
1249
|
+
relayPluginError(res, 429, 'busy');
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1252
|
+
pluginsByDevice.set(adapterDevice, (pluginsByDevice.get(adapterDevice) ?? 0) + 1);
|
|
1253
|
+
let adapterGone = false;
|
|
1254
|
+
let multiValueRefusal = false;
|
|
1255
|
+
let adapterQueryOverride;
|
|
1256
|
+
const adapterHangUp = new AbortController();
|
|
1257
|
+
const onAdapterGone = () => {
|
|
1258
|
+
adapterGone = true;
|
|
1259
|
+
if (!res.writableEnded)
|
|
1260
|
+
adapterHangUp.abort();
|
|
1261
|
+
};
|
|
1262
|
+
res.on('close', onAdapterGone);
|
|
1263
|
+
res.on('error', onAdapterGone);
|
|
1264
|
+
try {
|
|
1265
|
+
// Locate ONCE, over the same routing rule the sub-client used:
|
|
1266
|
+
// the query parses once, the path segments decode off the
|
|
1267
|
+
// rewritten pathname, and a body-locating route buffers its
|
|
1268
|
+
// (bounded) JSON body. A duplicated query id throws — the
|
|
1269
|
+
// cooperative shape violation (PB-12 review).
|
|
1270
|
+
const adapterSearch = new URLSearchParams(rawQuery);
|
|
1271
|
+
let parsedBody;
|
|
1272
|
+
let bodyGiven = false;
|
|
1273
|
+
let bufferedBody;
|
|
1274
|
+
if (!bodylessMethod && adapterRoute.locator.in === 'json-body') {
|
|
1275
|
+
const chunks = [];
|
|
1276
|
+
let size = 0;
|
|
1277
|
+
let over = false;
|
|
1278
|
+
for await (const chunk of req) {
|
|
1279
|
+
size += chunk.length;
|
|
1280
|
+
if (size > adapterRoute.maxBody) {
|
|
1281
|
+
over = true;
|
|
1282
|
+
break;
|
|
1283
|
+
}
|
|
1284
|
+
chunks.push(Buffer.from(chunk));
|
|
1285
|
+
}
|
|
1286
|
+
if (over) {
|
|
1287
|
+
relayPluginError(res, 413, 'body-too-large');
|
|
1288
|
+
void drainUpload(req, 0, drainDeadlineMs);
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
bufferedBody = Buffer.concat(chunks);
|
|
1292
|
+
if (bufferedBody.length > 0) {
|
|
1293
|
+
try {
|
|
1294
|
+
parsedBody = JSON.parse(bufferedBody.toString('utf8'));
|
|
1295
|
+
bodyGiven = true;
|
|
1296
|
+
}
|
|
1297
|
+
catch {
|
|
1298
|
+
relayPluginError(res, 400, 'bad-request');
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
const hits = (() => {
|
|
1304
|
+
try {
|
|
1305
|
+
return locateAdapterIds(adapterRoute, routePath, adapterSearch, bodyGiven ? parsedBody : undefined);
|
|
1306
|
+
}
|
|
1307
|
+
catch (error) {
|
|
1308
|
+
if (error instanceof AdapterMultiValueQueryError) {
|
|
1309
|
+
multiValueRefusal = true;
|
|
1310
|
+
return [];
|
|
1311
|
+
}
|
|
1312
|
+
throw error;
|
|
1313
|
+
}
|
|
1314
|
+
})();
|
|
1315
|
+
if (multiValueRefusal) {
|
|
1316
|
+
relayPluginError(res, 400, 'bad-query');
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
// The sub-client only forwards a classified-remote request, so
|
|
1320
|
+
// an adapter request that locates NOTHING is malformed here.
|
|
1321
|
+
if (hits.length === 0) {
|
|
1322
|
+
relayPluginError(res, 400, 'bad-request');
|
|
1323
|
+
return;
|
|
1324
|
+
}
|
|
1325
|
+
// EVERY located id must be a shared session — one unshared id
|
|
1326
|
+
// among several refuses the whole request.
|
|
1327
|
+
for (const hit of hits) {
|
|
1328
|
+
if (!isAccessible(hit.value)) {
|
|
1329
|
+
relayPluginError(res, 403, 'not-shared');
|
|
1330
|
+
return;
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
// The server hook: refuse, or replace the parsed body (strip a
|
|
1334
|
+
// `cwd`, pin a field…). The loopback coordinates stay verbatim.
|
|
1335
|
+
let loopbackBody;
|
|
1336
|
+
if (bodyGiven)
|
|
1337
|
+
loopbackBody = Buffer.from(JSON.stringify(parsedBody), 'utf8');
|
|
1338
|
+
const serverHook = adapter.hooks.server?.request;
|
|
1339
|
+
if (serverHook !== undefined) {
|
|
1340
|
+
let verdict;
|
|
1341
|
+
try {
|
|
1342
|
+
verdict = serverHook({ method: pluginMethod, pathname: routePath, route: adapterRoute, query: adapterSearch, body: bodyGiven ? parsedBody : undefined });
|
|
1343
|
+
}
|
|
1344
|
+
catch {
|
|
1345
|
+
relayPluginError(res, 403, 'internal');
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
if (!verdict.allow) {
|
|
1349
|
+
relayPluginError(res, 403, verdict.reason);
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
if (bodyGiven && verdict.body !== undefined) {
|
|
1353
|
+
loopbackBody = Buffer.from(JSON.stringify(verdict.body), 'utf8');
|
|
1354
|
+
}
|
|
1355
|
+
// A hook may also rewrite the query (the file/upload routes'
|
|
1356
|
+
// `cwd` strip rides this) — the loopback then sends the
|
|
1357
|
+
// hook's serialization instead of the verbatim rawQuery.
|
|
1358
|
+
adapterQueryOverride = verdict.query !== undefined ? verdict.query.toString() : undefined;
|
|
1359
|
+
}
|
|
1360
|
+
// The loopback body: a body-locating route sends its (possibly
|
|
1361
|
+
// hook-rewritten) buffer; any other body rides the cooperative
|
|
1362
|
+
// counting gate.
|
|
1363
|
+
const adapterAccept = headerValue(req, 'accept');
|
|
1364
|
+
const adapterRange = headerValue(req, 'range');
|
|
1365
|
+
const adapterContentType = headerValue(req, 'content-type');
|
|
1366
|
+
let adapterBodyStream = null;
|
|
1367
|
+
let adapterBodyGateError;
|
|
1368
|
+
if (!bodylessMethod && adapterRoute.locator.in === 'json-body') {
|
|
1369
|
+
adapterBodyStream = Readable.from(loopbackBody ?? Buffer.alloc(0));
|
|
1370
|
+
}
|
|
1371
|
+
else if (!bodylessMethod) {
|
|
1372
|
+
let pumped = 0;
|
|
1373
|
+
const gate = new Transform({
|
|
1374
|
+
transform(chunk, _encoding, callback) {
|
|
1375
|
+
pumped += chunk.length;
|
|
1376
|
+
if (pumped > adapterRoute.maxBody) {
|
|
1377
|
+
callback(new Error('body-too-large'));
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
callback(null, chunk);
|
|
1381
|
+
},
|
|
1382
|
+
});
|
|
1383
|
+
gate.on('error', (error) => {
|
|
1384
|
+
adapterBodyGateError = error;
|
|
1385
|
+
adapterHangUp.abort();
|
|
1386
|
+
});
|
|
1387
|
+
req.pipe(gate);
|
|
1388
|
+
adapterBodyStream = gate;
|
|
1389
|
+
}
|
|
1390
|
+
// The loopback: the coordinates travel VERBATIM, on purpose —
|
|
1391
|
+
// the sub-client has already rewritten every located virtual id
|
|
1392
|
+
// to its real half (query re-serialized, path segment
|
|
1393
|
+
// re-encoded, JSON body re-serialized), so what the server
|
|
1394
|
+
// validated (the share check below ran over the SAME locate)
|
|
1395
|
+
// is byte-for-byte what the local plugin will see. Re-locating
|
|
1396
|
+
// here would only risk divergence. The ONE exception is an
|
|
1397
|
+
// explicit hook verdict (`query` above): the better-sidebar
|
|
1398
|
+
// adapter strips the file/upload routes' `cwd` so the backend
|
|
1399
|
+
// resolves it from the session.
|
|
1400
|
+
const outbound = await bridge.loopback.request(pluginMethod, routePath, adapterQueryOverride ?? rawQuery, {
|
|
1401
|
+
...(adapterAccept !== undefined ? { accept: adapterAccept } : {}),
|
|
1402
|
+
...(adapterRange !== undefined ? { range: adapterRange } : {}),
|
|
1403
|
+
...(adapterContentType !== undefined ? { contentType: adapterContentType } : {}),
|
|
1404
|
+
}, adapterBodyStream, adapterHangUp.signal);
|
|
1405
|
+
if (adapterBodyGateError !== undefined && !res.headersSent) {
|
|
1406
|
+
relayPluginError(res, 413, 'body-too-large');
|
|
1407
|
+
void drainUpload(req, 0, drainDeadlineMs);
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
// The adapter header allowlist (PB-12): the HTML preview's CSP
|
|
1411
|
+
// sandbox and download dispositions ride through to the phone.
|
|
1412
|
+
// The loopback collected them into `extraHeaders`.
|
|
1413
|
+
res.writeHead(outbound.status, {
|
|
1414
|
+
...(outbound.contentType !== undefined ? { 'Content-Type': outbound.contentType } : {}),
|
|
1415
|
+
...(outbound.contentLength !== undefined ? { 'Content-Length': outbound.contentLength } : {}),
|
|
1416
|
+
...(outbound.cacheControl !== undefined ? { 'Cache-Control': outbound.cacheControl } : {}),
|
|
1417
|
+
...outbound.extraHeaders,
|
|
1418
|
+
});
|
|
1419
|
+
await new Promise((resolve, reject) => {
|
|
1420
|
+
const upstream = outbound.stream;
|
|
1421
|
+
upstream.on('data', (chunk) => {
|
|
1422
|
+
if (adapterGone || res.destroyed || res.writableEnded) {
|
|
1423
|
+
upstream.destroy();
|
|
1424
|
+
return;
|
|
1425
|
+
}
|
|
1426
|
+
if (!res.write(chunk)) {
|
|
1427
|
+
upstream.pause();
|
|
1428
|
+
res.once('drain', () => upstream.resume());
|
|
1429
|
+
}
|
|
1430
|
+
});
|
|
1431
|
+
upstream.on('end', () => resolve());
|
|
1432
|
+
upstream.on('error', reject);
|
|
1433
|
+
});
|
|
1434
|
+
await new Promise((resolve) => {
|
|
1435
|
+
if (res.writableEnded || res.destroyed) {
|
|
1436
|
+
resolve();
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
const watchdog = setTimeout(() => {
|
|
1440
|
+
try {
|
|
1441
|
+
res.destroy();
|
|
1442
|
+
}
|
|
1443
|
+
catch {
|
|
1444
|
+
// Already gone.
|
|
1445
|
+
}
|
|
1446
|
+
}, endDrainTimeoutMs);
|
|
1447
|
+
if (typeof watchdog.unref === 'function')
|
|
1448
|
+
watchdog.unref();
|
|
1449
|
+
const settled = () => {
|
|
1450
|
+
clearTimeout(watchdog);
|
|
1451
|
+
resolve();
|
|
1452
|
+
};
|
|
1453
|
+
res.once('finish', settled);
|
|
1454
|
+
res.once('close', settled);
|
|
1455
|
+
res.end();
|
|
1456
|
+
});
|
|
1457
|
+
}
|
|
1458
|
+
catch (error) {
|
|
1459
|
+
if (adapterHangUp.signal.aborted) {
|
|
1460
|
+
try {
|
|
1461
|
+
res.destroy();
|
|
1462
|
+
}
|
|
1463
|
+
catch {
|
|
1464
|
+
// Already gone.
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
else if (isLoopbackAuthError(error)) {
|
|
1468
|
+
relayPluginError(res, 502, 'loopback-auth');
|
|
1469
|
+
}
|
|
1470
|
+
else if (!res.headersSent) {
|
|
1471
|
+
relayPluginError(res, 502, 'internal');
|
|
1472
|
+
}
|
|
1473
|
+
else {
|
|
1474
|
+
try {
|
|
1475
|
+
res.destroy();
|
|
1476
|
+
}
|
|
1477
|
+
catch {
|
|
1478
|
+
// Already gone.
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
finally {
|
|
1483
|
+
res.off('close', onAdapterGone);
|
|
1484
|
+
res.off('error', onAdapterGone);
|
|
1485
|
+
const remaining = (pluginsByDevice.get(adapterDevice) ?? 1) - 1;
|
|
1486
|
+
if (remaining <= 0)
|
|
1487
|
+
pluginsByDevice.delete(adapterDevice);
|
|
1488
|
+
else
|
|
1489
|
+
pluginsByDevice.set(adapterDevice, remaining);
|
|
1490
|
+
}
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
// A yielded adapter falls through: the cooperative chain's digest
|
|
1494
|
+
// check refuses these stale coordinates below.
|
|
1495
|
+
}
|
|
1496
|
+
// ---- the validation chain, in order, first failure answers --------
|
|
1497
|
+
// Steps 1/2/3 and 4-6 (registry, version+digest, exact route, the
|
|
1498
|
+
// parse-once query, the share table, the consent wall) are SHARED with
|
|
1499
|
+
// the tunnel open route — see validatePluginCall. This route's own
|
|
1500
|
+
// extras are the method check (3b) and the body gate (3c) below.
|
|
1501
|
+
const validation = validatePluginCall(bridge, { pluginName, pluginVersion, pluginDigest, routePath, rawQuery }, isAccessible);
|
|
1502
|
+
if (!validation.ok) {
|
|
1503
|
+
relayPluginError(res, validation.status, validation.code);
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
const route = validation.route;
|
|
1507
|
+
// 3b. (PB-9) the METHOD must be one the route declared — the write
|
|
1508
|
+
// surface is exactly as explicit as the read one was in 2.1: a
|
|
1509
|
+
// route that declared no `methods` forwards GET only, and an
|
|
1510
|
+
// undeclared method answers 405 rather than reaching the plugin.
|
|
1511
|
+
if (!routeMethodsOf(route).includes(pluginMethod)) {
|
|
1512
|
+
relayPluginError(res, 405, 'method-undeclared');
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
// 3c. (PB-9) the body gate: a declared Content-Length over the route's
|
|
1516
|
+
// `maxBody` refuses BEFORE a byte moves (the T51 pre-check
|
|
1517
|
+
// discipline — the tail is drained after the answer); a chunked
|
|
1518
|
+
// body carries no length header and is counted while it streams,
|
|
1519
|
+
// in the pump below.
|
|
1520
|
+
const maxBody = routeMaxBodyOf(route);
|
|
1521
|
+
const declaredLength = Number(headerValue(req, 'content-length'));
|
|
1522
|
+
if (!bodylessMethod && Number.isFinite(declaredLength) && declaredLength > maxBody) {
|
|
1523
|
+
relayPluginError(res, 413, 'body-too-large');
|
|
1524
|
+
await drainUpload(req, 0, drainDeadlineMs);
|
|
1525
|
+
return;
|
|
1526
|
+
}
|
|
1527
|
+
const params = validation.params;
|
|
1528
|
+
const normalizedQuery = validation.normalizedQuery;
|
|
1529
|
+
// 7. the per-device budget — taken only after every wall above, held
|
|
1530
|
+
// for the whole response stream, released in the finally.
|
|
1531
|
+
const device = headerValue(req, 'x-zen-remote-device') ?? '';
|
|
1532
|
+
if ((pluginsByDevice.get(device) ?? 0) >= MAX_PLUGIN_REQUESTS_PER_DEVICE) {
|
|
1533
|
+
relayPluginError(res, 429, 'busy');
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1536
|
+
pluginsByDevice.set(device, (pluginsByDevice.get(device) ?? 0) + 1);
|
|
1537
|
+
// A sub-client hang-up cancels the loopback request mid-stream, like
|
|
1538
|
+
// the invoke route's wiring; the pump below reads the same flag (and
|
|
1539
|
+
// 'error' rides along, v1/stream's discipline).
|
|
1540
|
+
let clientGone = false;
|
|
1541
|
+
// The counting gate's recorded failure (PB-9), read by the catch —
|
|
1542
|
+
// declared here because the gate itself lives inside the try.
|
|
1543
|
+
let bodyGateError;
|
|
1544
|
+
const hangUp = new AbortController();
|
|
1545
|
+
const onClientGone = () => {
|
|
1546
|
+
clientGone = true;
|
|
1547
|
+
if (!res.writableEnded)
|
|
1548
|
+
hangUp.abort();
|
|
1549
|
+
};
|
|
1550
|
+
res.on('close', onClientGone);
|
|
1551
|
+
res.on('error', onClientGone);
|
|
1552
|
+
try {
|
|
1553
|
+
const accept = headerValue(req, 'accept');
|
|
1554
|
+
const range = headerValue(req, 'range');
|
|
1555
|
+
const contentType = headerValue(req, 'content-type');
|
|
1556
|
+
// The forwarded body (PB-9): the relay request's body streamed
|
|
1557
|
+
// through a counting gate — node's pipe owns the backpressure, and
|
|
1558
|
+
// the route's `maxBody` is enforced ON THE STREAM (a chunked body
|
|
1559
|
+
// carries no length header, so the declared-length pre-check above
|
|
1560
|
+
// could not see it). Past the cap the gate errors, which tears the
|
|
1561
|
+
// loopback request down mid-write and lands as 413 below (headers
|
|
1562
|
+
// not yet sent — the plugin's response cannot have arrived while we
|
|
1563
|
+
// are still sending its request body).
|
|
1564
|
+
let bodyStream = null;
|
|
1565
|
+
// The gate's own failure must REACH the loopback request (tearing it
|
|
1566
|
+
// down mid-write) and this route's catch — an unhandled Transform
|
|
1567
|
+
// 'error' would crash the process instead. The dedicated abort
|
|
1568
|
+
// signal does both: the loopback dials hang on it, and the catch
|
|
1569
|
+
// reads the recorded error first.
|
|
1570
|
+
const bodyAbort = new AbortController();
|
|
1571
|
+
const bodySignal = AbortSignal.any([hangUp.signal, bodyAbort.signal]);
|
|
1572
|
+
if (!bodylessMethod) {
|
|
1573
|
+
let pumped = 0;
|
|
1574
|
+
const gate = new Transform({
|
|
1575
|
+
transform(chunk, _encoding, callback) {
|
|
1576
|
+
pumped += chunk.length;
|
|
1577
|
+
if (pumped > maxBody) {
|
|
1578
|
+
req.pause();
|
|
1579
|
+
callback(new Error('body-too-large'));
|
|
1580
|
+
return;
|
|
1581
|
+
}
|
|
1582
|
+
callback(null, chunk);
|
|
1583
|
+
},
|
|
1584
|
+
});
|
|
1585
|
+
gate.on('error', (error) => {
|
|
1586
|
+
if (bodyGateError === undefined)
|
|
1587
|
+
bodyGateError = error;
|
|
1588
|
+
bodyAbort.abort();
|
|
1589
|
+
});
|
|
1590
|
+
req.on('error', (error) => {
|
|
1591
|
+
if (bodyGateError === undefined)
|
|
1592
|
+
bodyGateError = error;
|
|
1593
|
+
gate.destroy(error);
|
|
1594
|
+
});
|
|
1595
|
+
gate.on('close', () => { if (!res.writableEnded)
|
|
1596
|
+
req.pause(); });
|
|
1597
|
+
bodyStream = gate;
|
|
1598
|
+
req.pipe(gate);
|
|
1599
|
+
}
|
|
1600
|
+
const outbound = await bridge.loopback.request(pluginMethod, routePath, normalizedQuery, {
|
|
1601
|
+
...(accept !== undefined ? { accept } : {}),
|
|
1602
|
+
...(range !== undefined ? { range } : {}),
|
|
1603
|
+
...(contentType !== undefined ? { contentType } : {}),
|
|
1604
|
+
}, bodyStream, bodySignal);
|
|
1605
|
+
// Success: status plus the three whitelisted headers travel, the
|
|
1606
|
+
// body streams — and NO x-zen-remote-error (the marker is refusal-
|
|
1607
|
+
// only, so the client can classify with one header read).
|
|
1608
|
+
res.writeHead(outbound.status, {
|
|
1609
|
+
...(outbound.contentType !== undefined ? { 'Content-Type': outbound.contentType } : {}),
|
|
1610
|
+
...(outbound.contentLength !== undefined ? { 'Content-Length': outbound.contentLength } : {}),
|
|
1611
|
+
...(outbound.cacheControl !== undefined ? { 'Cache-Control': outbound.cacheControl } : {}),
|
|
1612
|
+
});
|
|
1613
|
+
// The pump: chunk-for-chunk with real backpressure — a full socket
|
|
1614
|
+
// pauses the plugin's response instead of buffering it here, so a
|
|
1615
|
+
// slow phone reads a big file without this process ever holding
|
|
1616
|
+
// more than a socket buffer of it. Every chunk re-checks the
|
|
1617
|
+
// response's liveness first (the v1/stream write discipline): a
|
|
1618
|
+
// client that died mid-stream stops the pump on its next chunk
|
|
1619
|
+
// instead of racing one more write into a dead socket, and a
|
|
1620
|
+
// response 'error' (a write racing a destroyed socket surfaces
|
|
1621
|
+
// there) reacts exactly like the hang-up.
|
|
1622
|
+
let clientGoneSeen = false;
|
|
1623
|
+
await new Promise((resolve, reject) => {
|
|
1624
|
+
const upstream = outbound.stream;
|
|
1625
|
+
upstream.on('data', (chunk) => {
|
|
1626
|
+
if (clientGone || res.destroyed || res.writableEnded) {
|
|
1627
|
+
upstream.destroy();
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
if (!res.write(chunk)) {
|
|
1631
|
+
upstream.pause();
|
|
1632
|
+
res.once('drain', () => upstream.resume());
|
|
1633
|
+
}
|
|
1634
|
+
});
|
|
1635
|
+
upstream.on('end', () => resolve());
|
|
1636
|
+
upstream.on('error', reject);
|
|
1637
|
+
});
|
|
1638
|
+
// The v1/stream finish discipline (T43-A): the tail is in the
|
|
1639
|
+
// kernel's hands now, and a client that stopped reading must not
|
|
1640
|
+
// pin this device slot forever — one watchdog, disarmed by `finish`
|
|
1641
|
+
// (the honest drained signal) or `close`, destroys otherwise.
|
|
1642
|
+
await new Promise((resolve) => {
|
|
1643
|
+
if (res.writableEnded || res.destroyed) {
|
|
1644
|
+
resolve();
|
|
1645
|
+
return;
|
|
1646
|
+
}
|
|
1647
|
+
const watchdog = setTimeout(() => {
|
|
1648
|
+
try {
|
|
1649
|
+
res.destroy();
|
|
1650
|
+
}
|
|
1651
|
+
catch {
|
|
1652
|
+
// Already gone.
|
|
1653
|
+
}
|
|
1654
|
+
}, endDrainTimeoutMs);
|
|
1655
|
+
if (typeof watchdog.unref === 'function')
|
|
1656
|
+
watchdog.unref();
|
|
1657
|
+
const settled = () => {
|
|
1658
|
+
clearTimeout(watchdog);
|
|
1659
|
+
resolve();
|
|
1660
|
+
};
|
|
1661
|
+
res.once('finish', settled);
|
|
1662
|
+
res.once('close', settled);
|
|
1663
|
+
res.end();
|
|
1664
|
+
});
|
|
1665
|
+
}
|
|
1666
|
+
catch (error) {
|
|
1667
|
+
if (!res.headersSent && bodyGateError !== undefined && bodyGateError.message === 'body-too-large') {
|
|
1668
|
+
// The counting gate tripped mid-forward (PB-9): a chunked body
|
|
1669
|
+
// past the route's `maxBody`. The plugin's answer cannot have
|
|
1670
|
+
// started while we were still writing its request body, so the
|
|
1671
|
+
// honest 413 still fits on the wire; the relay body's tail is
|
|
1672
|
+
// drained after the answer, bounded and timed like every
|
|
1673
|
+
// oversized read here.
|
|
1674
|
+
relayPluginError(res, 413, 'body-too-large');
|
|
1675
|
+
void drainUpload(req, 0, drainDeadlineMs);
|
|
1676
|
+
}
|
|
1677
|
+
else if (hangUp.signal.aborted) {
|
|
1678
|
+
// The sub-client is gone mid-stream: its socket is dead, and the
|
|
1679
|
+
// abort already tore the loopback request down.
|
|
1680
|
+
try {
|
|
1681
|
+
res.destroy();
|
|
1682
|
+
}
|
|
1683
|
+
catch {
|
|
1684
|
+
// Already gone.
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
else if (isLoopbackAuthError(error)) {
|
|
1688
|
+
// The cookie could not be obtained (no token URL on this host, a
|
|
1689
|
+
// failed exchange) — the relay's own wall, not a business answer.
|
|
1690
|
+
relayPluginError(res, 502, 'loopback-auth');
|
|
1691
|
+
}
|
|
1692
|
+
else if (!res.headersSent) {
|
|
1693
|
+
// Any other loopback failure before the answer started (the web
|
|
1694
|
+
// server moved, the plugin route crashed early) is this server's
|
|
1695
|
+
// fault: the bare internal code, no message.
|
|
1696
|
+
relayPluginError(res, 502, 'internal');
|
|
1697
|
+
}
|
|
1698
|
+
else {
|
|
1699
|
+
// Mid-stream failure with headers already on the wire: the status
|
|
1700
|
+
// cannot be changed, so the response is cut — the client sees a
|
|
1701
|
+
// truncated body, the honest shape of a broken stream.
|
|
1702
|
+
try {
|
|
1703
|
+
res.destroy();
|
|
1704
|
+
}
|
|
1705
|
+
catch {
|
|
1706
|
+
// Already gone.
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
finally {
|
|
1711
|
+
res.off('close', onClientGone);
|
|
1712
|
+
res.off('error', onClientGone);
|
|
1713
|
+
const remaining = (pluginsByDevice.get(device) ?? 1) - 1;
|
|
1714
|
+
if (remaining <= 0)
|
|
1715
|
+
pluginsByDevice.delete(device);
|
|
1716
|
+
else
|
|
1717
|
+
pluginsByDevice.set(device, remaining);
|
|
1718
|
+
}
|
|
1719
|
+
return;
|
|
1720
|
+
}
|
|
1721
|
+
if (req.method === 'POST' && pathname === `${RELAY_PREFIX}/v1/plugin-tunnel/open`) {
|
|
1722
|
+
const tunnel = options.pluginBridge?.tunnel;
|
|
1723
|
+
if (tunnel === undefined) {
|
|
1724
|
+
responseJson(res, 501, { ok: false, error: { code: 'unsupported' } });
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
await tunnel.open(req, res);
|
|
1728
|
+
return;
|
|
1729
|
+
}
|
|
1730
|
+
if (req.method === 'POST' && pathname === `${RELAY_PREFIX}/v1/plugin-tunnel/send`) {
|
|
1731
|
+
const tunnel = options.pluginBridge?.tunnel;
|
|
1732
|
+
if (tunnel === undefined) {
|
|
1733
|
+
responseJson(res, 501, { ok: false, error: { code: 'unsupported' } });
|
|
1734
|
+
return;
|
|
1735
|
+
}
|
|
1736
|
+
await tunnel.send(req, res);
|
|
1737
|
+
return;
|
|
1738
|
+
}
|
|
1739
|
+
if (req.method === 'POST' && pathname === `${RELAY_PREFIX}/v1/plugin-tunnel/close`) {
|
|
1740
|
+
const tunnel = options.pluginBridge?.tunnel;
|
|
1741
|
+
if (tunnel === undefined) {
|
|
1742
|
+
responseJson(res, 501, { ok: false, error: { code: 'unsupported' } });
|
|
1743
|
+
return;
|
|
1744
|
+
}
|
|
1745
|
+
await tunnel.close(req, res);
|
|
1746
|
+
return;
|
|
1747
|
+
}
|
|
1089
1748
|
if (req.method === 'POST' && pathname === `${RELAY_PREFIX}/v1/upload`) {
|
|
1090
1749
|
// The binary upload channel (T51): the sub-client's wrapped
|
|
1091
1750
|
// `/api/session/uploadFileBinary` forwards a remote session's raw
|
|
@@ -1678,8 +2337,7 @@ export function createRelayHandler(options) {
|
|
|
1678
2337
|
closeWith(null);
|
|
1679
2338
|
}
|
|
1680
2339
|
else {
|
|
1681
|
-
|
|
1682
|
-
closeWith(message === undefined ? { type: 'error', error: { code } } : { type: 'error', error: { code, message } });
|
|
2340
|
+
closeWith({ type: 'error', error: errorEnvelopeOf(error).error });
|
|
1683
2341
|
}
|
|
1684
2342
|
}
|
|
1685
2343
|
finally {
|