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.
Files changed (100) hide show
  1. package/AGENTS.md +15 -5
  2. package/CHANGELOG.md +71 -0
  3. package/README.md +2 -2
  4. package/README.zh-CN.md +2 -2
  5. package/docs/interface.md +12 -6
  6. package/docs/plugin-bridge.md +2 -2
  7. package/docs/remote-access.en.md +9 -3
  8. package/docs/remote-access.md +9 -3
  9. package/lan-gate.mjs +7 -0
  10. package/lib/adapters/dsh-better-sidebar/index.js +31 -2
  11. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
  12. package/lib/client-pairing.js +10 -0
  13. package/lib/client-pairing.js.map +1 -1
  14. package/lib/client-routes.js +183 -94
  15. package/lib/client-routes.js.map +1 -1
  16. package/lib/client.js +589 -97
  17. package/lib/debug-redact.js +253 -0
  18. package/lib/debug-redact.js.map +1 -0
  19. package/lib/debug-report.js +60 -0
  20. package/lib/debug-report.js.map +1 -0
  21. package/lib/fetch-route-intercept.js +23 -0
  22. package/lib/fetch-route-intercept.js.map +1 -1
  23. package/lib/index.js +306 -45
  24. package/lib/index.js.map +1 -1
  25. package/lib/intercept.js +43 -1
  26. package/lib/intercept.js.map +1 -1
  27. package/lib/log-ring.js +76 -0
  28. package/lib/log-ring.js.map +1 -0
  29. package/lib/plugin-adapter-core.js +30 -1
  30. package/lib/plugin-adapter-core.js.map +1 -1
  31. package/lib/plugin-route-intercept.js +506 -11
  32. package/lib/plugin-route-intercept.js.map +1 -1
  33. package/lib/plugin-tunnel.js +210 -18
  34. package/lib/plugin-tunnel.js.map +1 -1
  35. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +5 -2
  36. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
  37. package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
  38. package/lib/types/client/effects/remote-drop.d.ts +20 -8
  39. package/lib/types/client/effects/remote-drop.d.ts.map +1 -1
  40. package/lib/types/client/locales.d.ts +10 -1
  41. package/lib/types/client/locales.d.ts.map +1 -1
  42. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  43. package/lib/types/client/styles/content.css.d.ts +1 -1
  44. package/lib/types/client/styles/content.css.d.ts.map +1 -1
  45. package/lib/types/client/styles/layout.css.d.ts +1 -1
  46. package/lib/types/client/styles/layout.css.d.ts.map +1 -1
  47. package/lib/types/client/styles/misc.css.d.ts +1 -1
  48. package/lib/types/client/styles/misc.css.d.ts.map +1 -1
  49. package/lib/types/client-data/remote-drop.d.ts +64 -0
  50. package/lib/types/client-data/remote-drop.d.ts.map +1 -1
  51. package/lib/types/client-data/settings-form.d.ts +3 -0
  52. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  53. package/lib/types/client-pairing.d.ts +8 -0
  54. package/lib/types/client-pairing.d.ts.map +1 -1
  55. package/lib/types/client-routes.d.ts +23 -0
  56. package/lib/types/client-routes.d.ts.map +1 -1
  57. package/lib/types/debug-redact.d.ts +68 -0
  58. package/lib/types/debug-redact.d.ts.map +1 -0
  59. package/lib/types/debug-report.d.ts +58 -0
  60. package/lib/types/debug-report.d.ts.map +1 -0
  61. package/lib/types/fetch-route-intercept.d.ts +26 -0
  62. package/lib/types/fetch-route-intercept.d.ts.map +1 -1
  63. package/lib/types/index.d.ts +7 -1
  64. package/lib/types/index.d.ts.map +1 -1
  65. package/lib/types/intercept.d.ts +6 -0
  66. package/lib/types/intercept.d.ts.map +1 -1
  67. package/lib/types/log-ring.d.ts +57 -0
  68. package/lib/types/log-ring.d.ts.map +1 -0
  69. package/lib/types/plugin-adapter-core.d.ts.map +1 -1
  70. package/lib/types/plugin-route-intercept.d.ts +8 -0
  71. package/lib/types/plugin-route-intercept.d.ts.map +1 -1
  72. package/lib/types/plugin-tunnel.d.ts +37 -2
  73. package/lib/types/plugin-tunnel.d.ts.map +1 -1
  74. package/package.json +3 -2
  75. package/pwa/app.css +13 -2
  76. package/scripts/check-debug-report.mjs +277 -0
  77. package/scripts/check-info-sheet-stacking.mjs +85 -0
  78. package/scripts/check-remote-drop.mjs +130 -0
  79. package/scripts/check-tablet-safe-area.mjs +49 -0
  80. package/src/adapters/dsh-better-sidebar/index.ts +31 -2
  81. package/src/client/MobileSessionInfo.tsx +29 -2
  82. package/src/client/effects/remote-drop.ts +271 -24
  83. package/src/client/locales.ts +24 -4
  84. package/src/client/settings/SettingsSection.tsx +120 -3
  85. package/src/client/styles/content.css.ts +44 -14
  86. package/src/client/styles/layout.css.ts +9 -3
  87. package/src/client/styles/misc.css.ts +38 -0
  88. package/src/client-data/remote-drop.ts +103 -0
  89. package/src/client-data/settings-form.ts +3 -0
  90. package/src/client-pairing.ts +11 -0
  91. package/src/client-routes.ts +196 -90
  92. package/src/debug-redact.ts +290 -0
  93. package/src/debug-report.ts +93 -0
  94. package/src/fetch-route-intercept.ts +29 -0
  95. package/src/index.ts +295 -43
  96. package/src/intercept.ts +46 -0
  97. package/src/log-ring.ts +101 -0
  98. package/src/plugin-adapter-core.ts +29 -2
  99. package/src/plugin-route-intercept.ts +516 -12
  100. 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 { adapterPathMatches, classifyAdapterRequest, rewriteAdapterRequest, adapterOwnsRegistryEntry, MAX_ADAPTER_RESPONSE_BUFFER_BYTES, ADAPTER_RESPONSE_HEADER_ALLOWLIST, AdapterMultiValueQueryError } from './plugin-adapter-core.js';
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
- node[key] = toVirtual(serverId, value);
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
- async function answerRewriteIds(res, response, known, fields, serverId, knownIds) {
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) => toVirtual(serverId ?? '', 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') {