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/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];
|
|
@@ -493,6 +511,16 @@ export function createRelayHandler(options) {
|
|
|
493
511
|
const invokesByDevice = new Map();
|
|
494
512
|
/** Uploads currently in flight per device id, for the upload budget (T51-fix). */
|
|
495
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();
|
|
496
524
|
/**
|
|
497
525
|
* The device NAME this process last saw per device id (T59): every relay
|
|
498
526
|
* request rides the gateway's fresh `x-zen-remote-device-name` marking
|
|
@@ -768,6 +796,20 @@ export function createRelayHandler(options) {
|
|
|
768
796
|
catch {
|
|
769
797
|
fingerprints = {};
|
|
770
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
|
+
}
|
|
771
813
|
responseJson(res, 200, {
|
|
772
814
|
ok: true,
|
|
773
815
|
relayProtocol: RELAY_PROTOCOL,
|
|
@@ -777,6 +819,9 @@ export function createRelayHandler(options) {
|
|
|
777
819
|
// T42: the server's own interface fingerprints (empty when the wiring
|
|
778
820
|
// had nothing to compute — the client judges group by group).
|
|
779
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,
|
|
780
825
|
// T59: the CALLING device's name, as the gateway's device table holds
|
|
781
826
|
// it at this moment (the header is written fresh per forward, so a
|
|
782
827
|
// just-renamed device reads its new name here). Undefined without the
|
|
@@ -1112,6 +1157,594 @@ export function createRelayHandler(options) {
|
|
|
1112
1157
|
}
|
|
1113
1158
|
return;
|
|
1114
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
|
+
}
|
|
1115
1748
|
if (req.method === 'POST' && pathname === `${RELAY_PREFIX}/v1/upload`) {
|
|
1116
1749
|
// The binary upload channel (T51): the sub-client's wrapped
|
|
1117
1750
|
// `/api/session/uploadFileBinary` forwards a remote session's raw
|