dsh-zen-remote 2.3.5 → 2.3.7
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 -5
- package/CHANGELOG.md +71 -0
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/docs/interface.md +12 -6
- package/docs/plugin-bridge.md +2 -2
- package/docs/remote-access.en.md +9 -3
- package/docs/remote-access.md +9 -3
- package/lan-gate.mjs +7 -0
- package/lib/adapters/dsh-better-sidebar/index.js +31 -2
- package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
- package/lib/client-pairing.js +10 -0
- package/lib/client-pairing.js.map +1 -1
- package/lib/client-routes.js +183 -94
- package/lib/client-routes.js.map +1 -1
- package/lib/client.js +589 -97
- package/lib/debug-redact.js +253 -0
- package/lib/debug-redact.js.map +1 -0
- package/lib/debug-report.js +60 -0
- package/lib/debug-report.js.map +1 -0
- package/lib/fetch-route-intercept.js +23 -0
- package/lib/fetch-route-intercept.js.map +1 -1
- package/lib/index.js +306 -45
- package/lib/index.js.map +1 -1
- package/lib/intercept.js +43 -1
- package/lib/intercept.js.map +1 -1
- package/lib/log-ring.js +76 -0
- package/lib/log-ring.js.map +1 -0
- package/lib/plugin-adapter-core.js +30 -1
- package/lib/plugin-adapter-core.js.map +1 -1
- package/lib/plugin-route-intercept.js +506 -11
- package/lib/plugin-route-intercept.js.map +1 -1
- package/lib/plugin-tunnel.js +210 -18
- package/lib/plugin-tunnel.js.map +1 -1
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts +5 -2
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
- package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
- package/lib/types/client/effects/remote-drop.d.ts +20 -8
- package/lib/types/client/effects/remote-drop.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +10 -1
- 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/content.css.d.ts +1 -1
- package/lib/types/client/styles/content.css.d.ts.map +1 -1
- package/lib/types/client/styles/layout.css.d.ts +1 -1
- package/lib/types/client/styles/layout.css.d.ts.map +1 -1
- package/lib/types/client/styles/misc.css.d.ts +1 -1
- package/lib/types/client/styles/misc.css.d.ts.map +1 -1
- package/lib/types/client-data/remote-drop.d.ts +64 -0
- package/lib/types/client-data/remote-drop.d.ts.map +1 -1
- package/lib/types/client-data/settings-form.d.ts +3 -0
- package/lib/types/client-data/settings-form.d.ts.map +1 -1
- package/lib/types/client-pairing.d.ts +8 -0
- package/lib/types/client-pairing.d.ts.map +1 -1
- package/lib/types/client-routes.d.ts +23 -0
- package/lib/types/client-routes.d.ts.map +1 -1
- package/lib/types/debug-redact.d.ts +68 -0
- package/lib/types/debug-redact.d.ts.map +1 -0
- package/lib/types/debug-report.d.ts +58 -0
- package/lib/types/debug-report.d.ts.map +1 -0
- package/lib/types/fetch-route-intercept.d.ts +26 -0
- package/lib/types/fetch-route-intercept.d.ts.map +1 -1
- package/lib/types/index.d.ts +7 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/intercept.d.ts +6 -0
- package/lib/types/intercept.d.ts.map +1 -1
- package/lib/types/log-ring.d.ts +57 -0
- package/lib/types/log-ring.d.ts.map +1 -0
- package/lib/types/plugin-adapter-core.d.ts.map +1 -1
- package/lib/types/plugin-route-intercept.d.ts +8 -0
- package/lib/types/plugin-route-intercept.d.ts.map +1 -1
- package/lib/types/plugin-tunnel.d.ts +37 -2
- package/lib/types/plugin-tunnel.d.ts.map +1 -1
- package/package.json +3 -2
- package/pwa/app.css +13 -2
- package/scripts/check-debug-report.mjs +277 -0
- package/scripts/check-info-sheet-stacking.mjs +85 -0
- package/scripts/check-remote-drop.mjs +130 -0
- package/scripts/check-tablet-safe-area.mjs +49 -0
- package/src/adapters/dsh-better-sidebar/index.ts +31 -2
- package/src/client/MobileSessionInfo.tsx +29 -2
- package/src/client/effects/remote-drop.ts +271 -24
- package/src/client/locales.ts +24 -4
- package/src/client/settings/SettingsSection.tsx +120 -3
- package/src/client/styles/content.css.ts +44 -14
- package/src/client/styles/layout.css.ts +9 -3
- package/src/client/styles/misc.css.ts +38 -0
- package/src/client-data/remote-drop.ts +103 -0
- package/src/client-data/settings-form.ts +3 -0
- package/src/client-pairing.ts +11 -0
- package/src/client-routes.ts +196 -90
- package/src/debug-redact.ts +290 -0
- package/src/debug-report.ts +93 -0
- package/src/fetch-route-intercept.ts +29 -0
- package/src/index.ts +295 -43
- package/src/intercept.ts +46 -0
- package/src/log-ring.ts +101 -0
- package/src/plugin-adapter-core.ts +29 -2
- package/src/plugin-route-intercept.ts +516 -12
- package/src/plugin-tunnel.ts +230 -20
|
@@ -60,9 +60,17 @@
|
|
|
60
60
|
* fetch-route-intercept.ts).
|
|
61
61
|
*/
|
|
62
62
|
import { routeDeniesRemoteOf, routeLocatorOf, routeMaxBodyOf, routeMethodsOf, routePrefixOf, routeRewriteFieldsOf, routeRewritesIdsOf, routeUpgradesOf } from './plugin-bridge.js';
|
|
63
|
-
import { PassThrough, Readable, Transform } from 'node:stream';
|
|
63
|
+
import { PassThrough, Readable, Transform, Duplex } from 'node:stream';
|
|
64
|
+
import { createHash } from 'node:crypto';
|
|
64
65
|
import { fromVirtual, isVirtual, toVirtual } from './virtual-id.js';
|
|
65
|
-
import {
|
|
66
|
+
import { encodeRfc6455Frame, parseRfc6455Frames } from './plugin-tunnel.js';
|
|
67
|
+
import { adapterPathMatches, classifyAdapterRequest, locateAdapterIds, rewriteAdapterRequest, adapterOwnsRegistryEntry, MAX_ADAPTER_RESPONSE_BUFFER_BYTES, ADAPTER_RESPONSE_HEADER_ALLOWLIST, AdapterMultiValueQueryError } from './plugin-adapter-core.js';
|
|
68
|
+
/** The RFC 6455 handshake GUID — the SHA-1 input of Sec-WebSocket-Accept. */
|
|
69
|
+
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
|
70
|
+
/** The byte triplet that ends every HTTP/1.1 header block — the seam where
|
|
71
|
+
* an upgrade answer's HEAD stops and its WebSocket frames begin (PB-26b:
|
|
72
|
+
* both directions strip or buffer up to this mark). */
|
|
73
|
+
const HEADER_END = Buffer.from('\r\n\r\n', 'utf8');
|
|
66
74
|
function isRecord(value) {
|
|
67
75
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
68
76
|
}
|
|
@@ -113,8 +121,11 @@ function deepRewriteIds(value, known, knownIds, serverId) {
|
|
|
113
121
|
* array elements stay the value scan's business (the declaration refuses
|
|
114
122
|
* numeric segments outright). Already-virtual values are skipped, so a known
|
|
115
123
|
* id sitting on a declared path is rewritten exactly once, by whichever half
|
|
116
|
-
* reached it first.
|
|
124
|
+
* reached it first. Returns the virtual ids this pass MINTED (PB-26a) —
|
|
125
|
+
* exactly the positions it wrapped, deduped; a position the value scan
|
|
126
|
+
* already wrapped is not a mint (that row is in the browser's view). */
|
|
117
127
|
function rewriteIdsAtFields(root, fields, serverId) {
|
|
128
|
+
const minted = new Set();
|
|
118
129
|
for (const field of fields) {
|
|
119
130
|
const segments = field.split('.');
|
|
120
131
|
let node = root;
|
|
@@ -125,9 +136,13 @@ function rewriteIdsAtFields(root, fields, serverId) {
|
|
|
125
136
|
continue;
|
|
126
137
|
const key = segments[segments.length - 1];
|
|
127
138
|
const value = node[key];
|
|
128
|
-
if (typeof value === 'string' && value !== '' && !isVirtual(value))
|
|
129
|
-
|
|
139
|
+
if (typeof value === 'string' && value !== '' && !isVirtual(value)) {
|
|
140
|
+
const virtualId = toVirtual(serverId, value);
|
|
141
|
+
node[key] = virtualId;
|
|
142
|
+
minted.add(virtualId);
|
|
143
|
+
}
|
|
130
144
|
}
|
|
145
|
+
return [...minted];
|
|
131
146
|
}
|
|
132
147
|
/** Pump a response body reader into the browser response with real
|
|
133
148
|
* backpressure — the one discipline every verbatim passthrough runs. The
|
|
@@ -160,8 +175,11 @@ async function pumpResponse(res, reader) {
|
|
|
160
175
|
* untouched (the adapter discipline): a rewrite is strictly
|
|
161
176
|
* better-than-nothing, never a reason to mangle or refuse an answer. The
|
|
162
177
|
* caller has already handled the relay's refusal marker — this owns the
|
|
163
|
-
* entire normal answer.
|
|
164
|
-
|
|
178
|
+
* entire normal answer. PB-26a: the virtual ids the position rewrite minted
|
|
179
|
+
* are reported through `onMinted` — at most once per answer, only when there
|
|
180
|
+
* are any, and only after the rewritten body is what the browser receives
|
|
181
|
+
* (a serialization failure passes the original bytes and reports nothing). */
|
|
182
|
+
async function answerRewriteIds(res, response, known, fields, serverId, knownIds, onMinted) {
|
|
165
183
|
const contentType = response.headers.get('content-type');
|
|
166
184
|
const verbatimHeaders = {
|
|
167
185
|
...(contentType !== null ? { 'content-type': contentType } : {}),
|
|
@@ -213,10 +231,11 @@ async function answerRewriteIds(res, response, known, fields, serverId, knownIds
|
|
|
213
231
|
}
|
|
214
232
|
const text = Buffer.concat(chunks).toString('utf8');
|
|
215
233
|
let outBody;
|
|
234
|
+
let minted = [];
|
|
216
235
|
try {
|
|
217
236
|
const scanned = deepRewriteIds(JSON.parse(text), known, knownIds, serverId);
|
|
218
237
|
if (fields !== undefined)
|
|
219
|
-
rewriteIdsAtFields(scanned, fields, serverId);
|
|
238
|
+
minted = rewriteIdsAtFields(scanned, fields, serverId);
|
|
220
239
|
outBody = Buffer.from(JSON.stringify(scanned), 'utf8');
|
|
221
240
|
}
|
|
222
241
|
catch {
|
|
@@ -230,6 +249,15 @@ async function answerRewriteIds(res, response, known, fields, serverId, knownIds
|
|
|
230
249
|
}
|
|
231
250
|
res.writeHead(response.status, { ...verbatimHeaders, 'content-length': String(outBody.length) });
|
|
232
251
|
res.end(outBody);
|
|
252
|
+
if (minted.length > 0 && onMinted !== undefined) {
|
|
253
|
+
try {
|
|
254
|
+
onMinted(minted);
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
// Bookkeeping for the sidebar — never a reason to fail an answer
|
|
258
|
+
// the browser already has.
|
|
259
|
+
}
|
|
260
|
+
}
|
|
233
261
|
}
|
|
234
262
|
/** Pure inspection of the RAW webServer instance — nothing is mutated, so a
|
|
235
263
|
* failed check leaves every route exactly as it was. */
|
|
@@ -303,6 +331,7 @@ export function installPluginRouteIntercept(options) {
|
|
|
303
331
|
const adapters = options.adapters ?? [];
|
|
304
332
|
const getCurrentSession = options.getCurrentSession;
|
|
305
333
|
const getKnownRealIds = options.getKnownRealIds;
|
|
334
|
+
const onMintedVirtualIds = options.onMintedVirtualIds;
|
|
306
335
|
const target = webServer;
|
|
307
336
|
const savedMatch = target.match;
|
|
308
337
|
let installed = true;
|
|
@@ -599,7 +628,7 @@ export function installPluginRouteIntercept(options) {
|
|
|
599
628
|
// known inventory, read live right here (undefined when the wiring
|
|
600
629
|
// passed no getter — the scan stays request-local).
|
|
601
630
|
if (declared.rewriteIds) {
|
|
602
|
-
await answerRewriteIds(res, response, idMapping, declared.rewriteFields, parts.serverId, getKnownRealIds?.());
|
|
631
|
+
await answerRewriteIds(res, response, idMapping, declared.rewriteFields, parts.serverId, getKnownRealIds?.(), onMintedVirtualIds);
|
|
603
632
|
return;
|
|
604
633
|
}
|
|
605
634
|
const contentType = response.headers.get('content-type');
|
|
@@ -913,7 +942,7 @@ export function installPluginRouteIntercept(options) {
|
|
|
913
942
|
return;
|
|
914
943
|
}
|
|
915
944
|
if (declared.rewriteIds) {
|
|
916
|
-
await answerRewriteIds(res, response, idMapping, declared.rewriteFields, locatedServerId, getKnownRealIds?.());
|
|
945
|
+
await answerRewriteIds(res, response, idMapping, declared.rewriteFields, locatedServerId, getKnownRealIds?.(), onMintedVirtualIds);
|
|
917
946
|
return;
|
|
918
947
|
}
|
|
919
948
|
const contentType = response.headers.get('content-type');
|
|
@@ -1283,12 +1312,21 @@ export function installPluginRouteIntercept(options) {
|
|
|
1283
1312
|
}
|
|
1284
1313
|
const text = Buffer.concat(chunks).toString('utf8');
|
|
1285
1314
|
let rewrittenBody;
|
|
1315
|
+
// PB-26a: every id the hook virtualizes is a row the browser is
|
|
1316
|
+
// about to learn of (sidechat.start's childId) — collected here,
|
|
1317
|
+
// reported only once the hook's answer is what the browser gets.
|
|
1318
|
+
const minted = new Set();
|
|
1286
1319
|
try {
|
|
1287
1320
|
rewrittenBody = responseHook({
|
|
1288
1321
|
status: response.status,
|
|
1289
1322
|
pathname,
|
|
1290
1323
|
body: JSON.parse(text),
|
|
1291
|
-
virtualize: (id) =>
|
|
1324
|
+
virtualize: (id) => {
|
|
1325
|
+
const virtualId = toVirtual(serverId ?? '', id);
|
|
1326
|
+
if (isVirtual(virtualId))
|
|
1327
|
+
minted.add(virtualId);
|
|
1328
|
+
return virtualId;
|
|
1329
|
+
},
|
|
1292
1330
|
isVirtual: (value) => isVirtual(value),
|
|
1293
1331
|
});
|
|
1294
1332
|
}
|
|
@@ -1313,6 +1351,15 @@ export function installPluginRouteIntercept(options) {
|
|
|
1313
1351
|
'content-length': String(outBody.length),
|
|
1314
1352
|
});
|
|
1315
1353
|
res.end(outBody);
|
|
1354
|
+
if (minted.size > 0 && onMintedVirtualIds !== undefined) {
|
|
1355
|
+
try {
|
|
1356
|
+
onMintedVirtualIds([...minted]);
|
|
1357
|
+
}
|
|
1358
|
+
catch {
|
|
1359
|
+
// Sidebar bookkeeping — never a reason to fail an answer the
|
|
1360
|
+
// browser already has.
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1316
1363
|
}
|
|
1317
1364
|
catch (error) {
|
|
1318
1365
|
if (controller?.signal.aborted || res.destroyed || res.headersSent) {
|
|
@@ -1388,12 +1435,453 @@ export function installPluginRouteIntercept(options) {
|
|
|
1388
1435
|
path: route.path,
|
|
1389
1436
|
sessionParam: route.sessionParam,
|
|
1390
1437
|
deny: routeDeniesRemoteOf(route),
|
|
1438
|
+
locator: routeLocatorOf(route),
|
|
1439
|
+
rewriteIds: routeRewritesIdsOf(route),
|
|
1440
|
+
rewriteFields: routeRewriteFieldsOf(route),
|
|
1441
|
+
view: bridgeRouteAsAdapterRoute(route),
|
|
1442
|
+
adapter: adapterRouteOf(pathname)?.adapter,
|
|
1391
1443
|
};
|
|
1392
1444
|
}
|
|
1393
1445
|
}
|
|
1394
1446
|
}
|
|
1395
1447
|
return undefined;
|
|
1396
1448
|
};
|
|
1449
|
+
/** PB-26b: the json-body upgrade surrogate — the frame-level twin of the
|
|
1450
|
+
* HTTP surrogates. The exchange: the browser gets the 101 IMMEDIATELY (its
|
|
1451
|
+
* `onopen` fires and the first frame — better-sidebar's sidechat watch —
|
|
1452
|
+
* is on the wire), then every incoming text frame is located against the
|
|
1453
|
+
* route's locator and classified. LOCAL ids pipe the exchange into the
|
|
1454
|
+
* plugin's own upgrade handler over an in-memory socket (the handler's
|
|
1455
|
+
* 101 head is stripped — the browser already has one). VIRTUAL ids open a
|
|
1456
|
+
* PB-10 byte tunnel whose upstream is RE-FRAMED masked (each text frame's
|
|
1457
|
+
* ids rewritten real, per-frame — the server's send gate re-checks them
|
|
1458
|
+
* against the share wall) and whose downstream frames are rewritten
|
|
1459
|
+
* virtual (the adapter's response hook, or the cooperative rewriteIds
|
|
1460
|
+
* scan) before the browser sees them. A mixed or foreign-server frame
|
|
1461
|
+
* kills the exchange — docs/adr/0001 holds frame by frame. */
|
|
1462
|
+
const jsonBodyUpgrade = (declared, originalHandler, req, socket, head) => (async () => {
|
|
1463
|
+
const plugin = declared.plugin;
|
|
1464
|
+
const socketGone = () => socket.destroyed || !socket.writable;
|
|
1465
|
+
const destroySocket = () => {
|
|
1466
|
+
try {
|
|
1467
|
+
socket.destroy();
|
|
1468
|
+
}
|
|
1469
|
+
catch {
|
|
1470
|
+
// Already gone.
|
|
1471
|
+
}
|
|
1472
|
+
};
|
|
1473
|
+
// The post-101 refusal channel: the handshake is OUT, so no HTTP
|
|
1474
|
+
// status fits anymore — the refusal is recorded and the socket dies.
|
|
1475
|
+
const refuseAndDie = (reason) => {
|
|
1476
|
+
recordRefusal(plugin, reason);
|
|
1477
|
+
log?.('plugin upgrade refused for %s: %s', plugin, reason);
|
|
1478
|
+
destroySocket();
|
|
1479
|
+
};
|
|
1480
|
+
// The deny wall (the HTTP halves' current-session rule, pre-101 — the
|
|
1481
|
+
// answerUnbridgedSocket answer still fits here): a remote panel never
|
|
1482
|
+
// opens this route at all.
|
|
1483
|
+
if (declared.deny) {
|
|
1484
|
+
const current = getCurrentSession?.();
|
|
1485
|
+
if (current !== undefined && isVirtual(current)) {
|
|
1486
|
+
answerUnbridgedSocket(socket, plugin, 'route-denied');
|
|
1487
|
+
return;
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
// The two headers the handshake cannot exist without — we answer it
|
|
1491
|
+
// OURSELVES this time, so a missing one refuses before anything is
|
|
1492
|
+
// written.
|
|
1493
|
+
const wsKey = typeof req.headers['sec-websocket-key'] === 'string' ? req.headers['sec-websocket-key'] : undefined;
|
|
1494
|
+
const wsVersion = typeof req.headers['sec-websocket-version'] === 'string' ? req.headers['sec-websocket-version'] : undefined;
|
|
1495
|
+
if (wsKey === undefined || wsVersion === undefined) {
|
|
1496
|
+
answerUnbridgedSocket(socket, plugin, 'bad-query');
|
|
1497
|
+
return;
|
|
1498
|
+
}
|
|
1499
|
+
// The 101 goes out NOW: the browser's onopen fires and its first frame
|
|
1500
|
+
// is what decides the route below. Extensions are offered to nobody —
|
|
1501
|
+
// not echoed here, not offered upstream (the tunnel opens with the
|
|
1502
|
+
// key and version alone) — so every frame stays plain text and the
|
|
1503
|
+
// rewrites below can never land on compressed bytes.
|
|
1504
|
+
try {
|
|
1505
|
+
socket.write(`HTTP/1.1 101 Switching Protocols\r\n` +
|
|
1506
|
+
`Upgrade: websocket\r\n` +
|
|
1507
|
+
`Connection: Upgrade\r\n` +
|
|
1508
|
+
`Sec-WebSocket-Accept: ${createHash('sha1').update(wsKey + WS_GUID).digest('base64')}\r\n` +
|
|
1509
|
+
`\r\n`);
|
|
1510
|
+
}
|
|
1511
|
+
catch {
|
|
1512
|
+
return;
|
|
1513
|
+
}
|
|
1514
|
+
// ---- the local pipe: an in-memory socket behind the real handler ----
|
|
1515
|
+
let localSocket;
|
|
1516
|
+
const startLocal = () => {
|
|
1517
|
+
if (typeof originalHandler !== 'function') {
|
|
1518
|
+
// No local handler to serve the local verdict through — the
|
|
1519
|
+
// exchange is dead either way.
|
|
1520
|
+
destroySocket();
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
// The handler's writes arrive through the Duplex's writable side;
|
|
1524
|
+
// its reads come from OUR writes. The 101 head the handler produces
|
|
1525
|
+
// is stripped (the browser already has one) — every byte after the
|
|
1526
|
+
// header end is a WebSocket frame and crosses verbatim.
|
|
1527
|
+
const pipe = new Duplex({
|
|
1528
|
+
write(chunk, _encoding, callback) {
|
|
1529
|
+
forwardFromLocal(chunk);
|
|
1530
|
+
callback();
|
|
1531
|
+
},
|
|
1532
|
+
read() { },
|
|
1533
|
+
});
|
|
1534
|
+
// net.Socket methods the handler may call that a Duplex lacks.
|
|
1535
|
+
const socketShaped = pipe;
|
|
1536
|
+
socketShaped.setTimeout = () => { };
|
|
1537
|
+
socketShaped.setNoDelay = () => { };
|
|
1538
|
+
let headerSeen = false;
|
|
1539
|
+
let headerBuffer = Buffer.alloc(0);
|
|
1540
|
+
const forwardFromLocal = (chunk) => {
|
|
1541
|
+
if (socketGone())
|
|
1542
|
+
return;
|
|
1543
|
+
if (headerSeen) {
|
|
1544
|
+
socket.write(chunk);
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
headerBuffer = Buffer.concat([headerBuffer, chunk]);
|
|
1548
|
+
const end = headerBuffer.indexOf(HEADER_END);
|
|
1549
|
+
if (end === -1)
|
|
1550
|
+
return;
|
|
1551
|
+
headerSeen = true;
|
|
1552
|
+
const rest = headerBuffer.subarray(end + HEADER_END.length);
|
|
1553
|
+
headerBuffer = Buffer.alloc(0);
|
|
1554
|
+
if (rest.length > 0)
|
|
1555
|
+
socket.write(rest);
|
|
1556
|
+
};
|
|
1557
|
+
pipe.on('close', () => destroySocket());
|
|
1558
|
+
socket.on('close', () => {
|
|
1559
|
+
try {
|
|
1560
|
+
pipe.destroy();
|
|
1561
|
+
}
|
|
1562
|
+
catch {
|
|
1563
|
+
// Already gone.
|
|
1564
|
+
}
|
|
1565
|
+
});
|
|
1566
|
+
localSocket = pipe;
|
|
1567
|
+
// The handler owns the exchange from here — its async body runs on
|
|
1568
|
+
// its own, exactly as the host would run it.
|
|
1569
|
+
const handler = originalHandler;
|
|
1570
|
+
void Promise.resolve(handler(req, pipe, Buffer.alloc(0))).catch(() => destroySocket());
|
|
1571
|
+
};
|
|
1572
|
+
// ---- the remote pipe: the PB-10 tunnel, frame by frame -------------
|
|
1573
|
+
let tunnelHandle;
|
|
1574
|
+
// The connection-wide real→virtual mapping the rewriteIds deep scan
|
|
1575
|
+
// consults: EVERY validated remote frame's ids merge in (a watch list
|
|
1576
|
+
// that grows mid-connection makes the later answers rewritable too —
|
|
1577
|
+
// the deep scan only knows the ids this exchange has carried).
|
|
1578
|
+
const idMapping = new Map();
|
|
1579
|
+
let upstreamSeq = 0;
|
|
1580
|
+
let upstreamBroken = false;
|
|
1581
|
+
let teardownTunnel = () => { };
|
|
1582
|
+
const killExchange = () => {
|
|
1583
|
+
teardownTunnel();
|
|
1584
|
+
destroySocket();
|
|
1585
|
+
};
|
|
1586
|
+
/** ONE text frame onto the OPEN tunnel: the ids named by `hits`
|
|
1587
|
+
* rewritten real (an empty list — the unsubscribe-all — crosses
|
|
1588
|
+
* verbatim), re-framed MASKED (client style — the server's send gate
|
|
1589
|
+
* parses and re-checks them), sent in the chain's order. A transport
|
|
1590
|
+
* failure tears the whole exchange down (counter, close, socket) —
|
|
1591
|
+
* never a silent broken flag: that would leak `tunnelsCurrent` and
|
|
1592
|
+
* leave the browser hanging on a dead tunnel. */
|
|
1593
|
+
const sendTextUpstream = async (frame, parsedJson, hits) => {
|
|
1594
|
+
if (upstreamBroken || tunnelHandle === undefined)
|
|
1595
|
+
return;
|
|
1596
|
+
const rewrite = hits.length > 0
|
|
1597
|
+
? rewriteAdapterRequest(declared.view, declared.path, new URLSearchParams(), parsedJson, hits, (virtualId) => fromVirtual(virtualId).id)
|
|
1598
|
+
: undefined;
|
|
1599
|
+
const payload = rewrite?.bodyText !== undefined ? Buffer.from(rewrite.bodyText, 'utf8') : frame.payload;
|
|
1600
|
+
const seq = upstreamSeq;
|
|
1601
|
+
upstreamSeq += 1;
|
|
1602
|
+
try {
|
|
1603
|
+
await relay.pluginTunnelSend(tunnelHandle.tunnel, seq, encodeRfc6455Frame(0x1, payload, true, frame.fin));
|
|
1604
|
+
}
|
|
1605
|
+
catch {
|
|
1606
|
+
killExchange();
|
|
1607
|
+
}
|
|
1608
|
+
};
|
|
1609
|
+
/** The downstream river: the tunnel's byte frames → rewritten text
|
|
1610
|
+
* frames on the browser socket. The plugin's 101 head is stripped
|
|
1611
|
+
* FIRST (the browser already has ours); each text frame is rewritten
|
|
1612
|
+
* (adapter hook, else the cooperative rewriteIds scan) and re-framed
|
|
1613
|
+
* UNMASKED (server style); every other frame crosses verbatim. */
|
|
1614
|
+
const startDownstream = (opened, serverId) => {
|
|
1615
|
+
void (async () => {
|
|
1616
|
+
let downstream = Buffer.alloc(0);
|
|
1617
|
+
let headerSeen = false;
|
|
1618
|
+
try {
|
|
1619
|
+
for await (const chunk of opened.frames) {
|
|
1620
|
+
if (socketGone())
|
|
1621
|
+
break;
|
|
1622
|
+
downstream = Buffer.concat([downstream, chunk]);
|
|
1623
|
+
if (!headerSeen) {
|
|
1624
|
+
const end = downstream.indexOf(HEADER_END);
|
|
1625
|
+
if (end === -1)
|
|
1626
|
+
continue;
|
|
1627
|
+
headerSeen = true;
|
|
1628
|
+
downstream = downstream.subarray(end + HEADER_END.length);
|
|
1629
|
+
}
|
|
1630
|
+
const parsed = parseRfc6455Frames(downstream);
|
|
1631
|
+
downstream = downstream.subarray(parsed.consumed);
|
|
1632
|
+
if (parsed.violated)
|
|
1633
|
+
break;
|
|
1634
|
+
for (const frame of parsed.frames) {
|
|
1635
|
+
if (frame.opcode !== 0x1) {
|
|
1636
|
+
socket.write(frame.raw);
|
|
1637
|
+
continue;
|
|
1638
|
+
}
|
|
1639
|
+
let outPayload = frame.payload;
|
|
1640
|
+
try {
|
|
1641
|
+
const parsedJson = JSON.parse(frame.payload.toString('utf8'));
|
|
1642
|
+
const responseHook = declared.adapter?.hooks.client?.response;
|
|
1643
|
+
let hooked = false;
|
|
1644
|
+
if (responseHook !== undefined) {
|
|
1645
|
+
outPayload = Buffer.from(JSON.stringify(responseHook({
|
|
1646
|
+
status: 200,
|
|
1647
|
+
pathname: declared.path,
|
|
1648
|
+
body: parsedJson,
|
|
1649
|
+
virtualize: (id) => toVirtual(serverId, id),
|
|
1650
|
+
isVirtual: (value) => isVirtual(value),
|
|
1651
|
+
})), 'utf8');
|
|
1652
|
+
hooked = true;
|
|
1653
|
+
}
|
|
1654
|
+
else if (declared.rewriteIds) {
|
|
1655
|
+
const scanned = deepRewriteIds(parsedJson, idMapping, getKnownRealIds?.(), serverId);
|
|
1656
|
+
if (declared.rewriteFields !== undefined)
|
|
1657
|
+
rewriteIdsAtFields(scanned, declared.rewriteFields, serverId);
|
|
1658
|
+
outPayload = Buffer.from(JSON.stringify(scanned), 'utf8');
|
|
1659
|
+
hooked = true;
|
|
1660
|
+
}
|
|
1661
|
+
// Neither hook applies: the frame crosses verbatim.
|
|
1662
|
+
if (!hooked)
|
|
1663
|
+
outPayload = frame.payload;
|
|
1664
|
+
}
|
|
1665
|
+
catch {
|
|
1666
|
+
// A frame that will not parse (or a hook that threw)
|
|
1667
|
+
// crosses untouched — a rewrite is better-than-nothing,
|
|
1668
|
+
// never a reason to mangle a push.
|
|
1669
|
+
}
|
|
1670
|
+
if (socketGone())
|
|
1671
|
+
break;
|
|
1672
|
+
// The re-frame carries the ORIGINAL frame's fin bit — a
|
|
1673
|
+
// fragmented push stays fragmented, a whole frame stays
|
|
1674
|
+
// whole.
|
|
1675
|
+
if (!socket.write(encodeRfc6455Frame(0x1, outPayload, false, frame.fin))) {
|
|
1676
|
+
await new Promise((resolve) => {
|
|
1677
|
+
const settled = () => {
|
|
1678
|
+
socket.off('drain', settled);
|
|
1679
|
+
socket.off('close', settled);
|
|
1680
|
+
resolve();
|
|
1681
|
+
};
|
|
1682
|
+
socket.once('drain', settled);
|
|
1683
|
+
socket.once('close', settled);
|
|
1684
|
+
});
|
|
1685
|
+
if (socketGone())
|
|
1686
|
+
break;
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
catch {
|
|
1692
|
+
// The tunnel transport died — the browser socket follows below.
|
|
1693
|
+
}
|
|
1694
|
+
if (!socketGone())
|
|
1695
|
+
destroySocket();
|
|
1696
|
+
})();
|
|
1697
|
+
};
|
|
1698
|
+
/** ONE text frame through the walls. The FIRST one decides the
|
|
1699
|
+
* exchange's path (local pipe or remote tunnel) and the connection
|
|
1700
|
+
* never switches: a frame that would take the OTHER path is a
|
|
1701
|
+
* cross-frame mix and kills the exchange (docs/adr/0001, frame by
|
|
1702
|
+
* frame). The one exception is the id-LESS frame on a tunnel-bound
|
|
1703
|
+
* connection — better-sidebar's `{"op":"watch","threadIds":[]}`
|
|
1704
|
+
* unsubscribe-all carries no id and no local meaning, so it keeps
|
|
1705
|
+
* riding the tunnel verbatim. */
|
|
1706
|
+
const handleTextFrame = async (frame) => {
|
|
1707
|
+
let parsedJson;
|
|
1708
|
+
try {
|
|
1709
|
+
parsedJson = JSON.parse(frame.payload.toString('utf8'));
|
|
1710
|
+
}
|
|
1711
|
+
catch {
|
|
1712
|
+
refuseAndDie('bad-query');
|
|
1713
|
+
return;
|
|
1714
|
+
}
|
|
1715
|
+
let verdict;
|
|
1716
|
+
try {
|
|
1717
|
+
verdict = classifyAdapterRequest(declared.view, declared.path, new URLSearchParams(), parsedJson);
|
|
1718
|
+
}
|
|
1719
|
+
catch {
|
|
1720
|
+
refuseAndDie('bad-query');
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
if (verdict.kind === 'mixed') {
|
|
1724
|
+
refuseAndDie('bad-query');
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
const rawHits = locateAdapterIds(declared.view, declared.path, new URLSearchParams(), parsedJson);
|
|
1728
|
+
if (verdict.kind === 'local') {
|
|
1729
|
+
if (tunnelHandle !== undefined) {
|
|
1730
|
+
// Bound to the remote tunnel: the id-less frame stays on it, a
|
|
1731
|
+
// REAL local id is a cross-frame mix — never a pivot to the
|
|
1732
|
+
// local plugin.
|
|
1733
|
+
if (rawHits.length === 0) {
|
|
1734
|
+
await sendTextUpstream(frame, parsedJson, []);
|
|
1735
|
+
return;
|
|
1736
|
+
}
|
|
1737
|
+
refuseAndDie('bad-query');
|
|
1738
|
+
killExchange();
|
|
1739
|
+
return;
|
|
1740
|
+
}
|
|
1741
|
+
if (localSocket === undefined) {
|
|
1742
|
+
startLocal();
|
|
1743
|
+
if (localSocket === undefined)
|
|
1744
|
+
return;
|
|
1745
|
+
}
|
|
1746
|
+
// The browser's frame enters the handler's READ side (push) — the
|
|
1747
|
+
// Duplex's write side is the handler's own answer channel.
|
|
1748
|
+
localSocket.push(frame.raw);
|
|
1749
|
+
return;
|
|
1750
|
+
}
|
|
1751
|
+
// Remote. The FIRST remote frame opens the tunnel; every hit must
|
|
1752
|
+
// belong to THIS handshake's server. A connection already bound to
|
|
1753
|
+
// the local pipe never pivots to the tunnel.
|
|
1754
|
+
if (localSocket !== undefined) {
|
|
1755
|
+
refuseAndDie('bad-query');
|
|
1756
|
+
try {
|
|
1757
|
+
localSocket.destroy();
|
|
1758
|
+
}
|
|
1759
|
+
catch {
|
|
1760
|
+
// Already gone.
|
|
1761
|
+
}
|
|
1762
|
+
return;
|
|
1763
|
+
}
|
|
1764
|
+
const serverId = getServerId();
|
|
1765
|
+
if (serverId === undefined) {
|
|
1766
|
+
refuseAndDie('server-changed');
|
|
1767
|
+
return;
|
|
1768
|
+
}
|
|
1769
|
+
const mapping = new Map();
|
|
1770
|
+
for (const idHit of verdict.hits) {
|
|
1771
|
+
const parts = fromVirtual(idHit.value);
|
|
1772
|
+
if (parts === undefined || parts.id === '' || parts.serverId !== serverId) {
|
|
1773
|
+
refuseAndDie('server-changed');
|
|
1774
|
+
return;
|
|
1775
|
+
}
|
|
1776
|
+
mapping.set(parts.id, idHit.value);
|
|
1777
|
+
}
|
|
1778
|
+
// This frame's ids join the connection-wide mapping (see idMapping).
|
|
1779
|
+
for (const [key, value] of mapping)
|
|
1780
|
+
idMapping.set(key, value);
|
|
1781
|
+
if (tunnelHandle === undefined) {
|
|
1782
|
+
const status = getStatus().find((entry) => entry.plugin === plugin);
|
|
1783
|
+
if (status === undefined || !status.bridged) {
|
|
1784
|
+
refuseAndDie(status?.reason ?? 'not-on-server');
|
|
1785
|
+
return;
|
|
1786
|
+
}
|
|
1787
|
+
const controller = new AbortController();
|
|
1788
|
+
socket.on('close', () => controller.abort());
|
|
1789
|
+
let opened;
|
|
1790
|
+
try {
|
|
1791
|
+
log?.('opening tunnel for %s', declared.path);
|
|
1792
|
+
opened = await relay.pluginTunnelOpen({
|
|
1793
|
+
plugin,
|
|
1794
|
+
version: declared.version,
|
|
1795
|
+
digest: declared.digest,
|
|
1796
|
+
path: declared.path,
|
|
1797
|
+
query: '',
|
|
1798
|
+
wsHeaders: { 'sec-websocket-key': wsKey, 'sec-websocket-version': wsVersion },
|
|
1799
|
+
}, controller.signal);
|
|
1800
|
+
}
|
|
1801
|
+
catch (error) {
|
|
1802
|
+
const rawCode = error?.code;
|
|
1803
|
+
refuseAndDie(typeof rawCode === 'string' && rawCode !== '' ? rawCode : 'offline');
|
|
1804
|
+
return;
|
|
1805
|
+
}
|
|
1806
|
+
tunnelsCurrent += 1;
|
|
1807
|
+
tunnelsOpened += 1;
|
|
1808
|
+
tunnelHandle = opened;
|
|
1809
|
+
log?.('tunnel open for %s: %s', declared.path, opened.tunnel);
|
|
1810
|
+
teardownTunnel = () => {
|
|
1811
|
+
if (upstreamBroken)
|
|
1812
|
+
return;
|
|
1813
|
+
upstreamBroken = true;
|
|
1814
|
+
tunnelsCurrent -= 1;
|
|
1815
|
+
tunnelsClosed += 1;
|
|
1816
|
+
void relay.pluginTunnelClose(opened.tunnel).catch(() => { });
|
|
1817
|
+
};
|
|
1818
|
+
socket.on('close', teardownTunnel);
|
|
1819
|
+
socket.on('error', teardownTunnel);
|
|
1820
|
+
startDownstream(opened, serverId);
|
|
1821
|
+
}
|
|
1822
|
+
await sendTextUpstream(frame, parsedJson, verdict.hits);
|
|
1823
|
+
};
|
|
1824
|
+
const handleFrame = async (frame) => {
|
|
1825
|
+
// Control frames (close/ping/pong) carry no session ids and cross
|
|
1826
|
+
// VERBATIM on whichever path exists — the close handshake completes
|
|
1827
|
+
// the honest way (the plugin's reply close rides back downstream,
|
|
1828
|
+
// the socket-close handlers tear the tunnel down in turn). With no
|
|
1829
|
+
// path yet, a close just closes.
|
|
1830
|
+
if (frame.opcode !== 0x1 && frame.opcode !== 0x2) {
|
|
1831
|
+
if (localSocket !== undefined) {
|
|
1832
|
+
localSocket.push(frame.raw);
|
|
1833
|
+
return;
|
|
1834
|
+
}
|
|
1835
|
+
if (tunnelHandle !== undefined && !upstreamBroken) {
|
|
1836
|
+
const seq = upstreamSeq;
|
|
1837
|
+
upstreamSeq += 1;
|
|
1838
|
+
try {
|
|
1839
|
+
await relay.pluginTunnelSend(tunnelHandle.tunnel, seq, frame.raw);
|
|
1840
|
+
}
|
|
1841
|
+
catch {
|
|
1842
|
+
// The send died: tear the exchange down COMPLETELY — the
|
|
1843
|
+
// counter, the close, the browser socket (a bare broken flag
|
|
1844
|
+
// would leak `tunnelsCurrent` and hang the browser).
|
|
1845
|
+
killExchange();
|
|
1846
|
+
}
|
|
1847
|
+
return;
|
|
1848
|
+
}
|
|
1849
|
+
if (frame.opcode === 0x8)
|
|
1850
|
+
killExchange();
|
|
1851
|
+
return;
|
|
1852
|
+
}
|
|
1853
|
+
await handleTextFrame(frame);
|
|
1854
|
+
};
|
|
1855
|
+
// ---- frame intake: the early head bytes, then every socket byte ----
|
|
1856
|
+
// Complete frames dispatch through ONE serialized chain (the arrival
|
|
1857
|
+
// order is the wire order the plugin and the browser both assume); a
|
|
1858
|
+
// declared-oversize frame is a broken sender — refused, never awaited.
|
|
1859
|
+
let intake = Buffer.from(head);
|
|
1860
|
+
let chain = Promise.resolve();
|
|
1861
|
+
socket.on('data', (chunk) => {
|
|
1862
|
+
if (socketGone())
|
|
1863
|
+
return;
|
|
1864
|
+
intake = Buffer.concat([intake, chunk]);
|
|
1865
|
+
const parsedFrames = parseRfc6455Frames(intake);
|
|
1866
|
+
intake = intake.subarray(parsedFrames.consumed);
|
|
1867
|
+
if (parsedFrames.violated) {
|
|
1868
|
+
refuseAndDie('bad-query');
|
|
1869
|
+
return;
|
|
1870
|
+
}
|
|
1871
|
+
for (const frame of parsedFrames.frames) {
|
|
1872
|
+
chain = chain
|
|
1873
|
+
.then(() => handleFrame(frame))
|
|
1874
|
+
.catch(() => destroySocket());
|
|
1875
|
+
}
|
|
1876
|
+
});
|
|
1877
|
+
})().catch(() => {
|
|
1878
|
+
try {
|
|
1879
|
+
socket.destroy();
|
|
1880
|
+
}
|
|
1881
|
+
catch {
|
|
1882
|
+
// Already gone.
|
|
1883
|
+
}
|
|
1884
|
+
});
|
|
1397
1885
|
/** The tunnel's surrogate upgrade handler. Every outcome — refusal,
|
|
1398
1886
|
* teardown, the byte pumping — owns the socket's whole remaining life. */
|
|
1399
1887
|
const surrogateUpgrade = (declared, originalHandler) => (req, socket, head) => {
|
|
@@ -1401,6 +1889,13 @@ export function installPluginRouteIntercept(options) {
|
|
|
1401
1889
|
// its own and every failure path owns the socket's teardown.
|
|
1402
1890
|
void (async () => {
|
|
1403
1891
|
log?.('upgrade entered for %s', declared.path);
|
|
1892
|
+
// PB-26b: a json-body upgrade's ids ride the POST-HANDSHAKE frames —
|
|
1893
|
+
// there is no session query parameter for the byte-tunnel half below
|
|
1894
|
+
// to read, so the frame-level half owns the whole exchange.
|
|
1895
|
+
if (declared.locator.in !== 'query') {
|
|
1896
|
+
await jsonBodyUpgrade(declared, originalHandler, req, socket, head);
|
|
1897
|
+
return;
|
|
1898
|
+
}
|
|
1404
1899
|
const socketGone = () => socket.destroyed || !socket.writable;
|
|
1405
1900
|
const passthrough = () => {
|
|
1406
1901
|
if (typeof originalHandler === 'function') {
|