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
@@ -63,12 +63,22 @@
63
63
  import type { IncomingMessage, ServerResponse } from 'node:http'
64
64
  import type { BridgeLocator, BridgeRegistry, BridgeStatus, NormalizedBridgeRoute } from './plugin-bridge.js'
65
65
  import { routeDeniesRemoteOf, routeLocatorOf, routeMaxBodyOf, routeMethodsOf, routePrefixOf, routeRewriteFieldsOf, routeRewritesIdsOf, routeUpgradesOf } from './plugin-bridge.js'
66
- import { PassThrough, Readable, Transform } from 'node:stream'
66
+ import { PassThrough, Readable, Transform, Duplex } from 'node:stream'
67
+ import { createHash } from 'node:crypto'
67
68
  import type { Socket } from 'node:net'
68
69
  import type { RelayClient } from './relay-client.js'
69
70
  import { fromVirtual, isVirtual, toVirtual } from './virtual-id.js'
71
+ import { encodeRfc6455Frame, parseRfc6455Frames, type Rfc6455Frame } from './plugin-tunnel.js'
70
72
  import type { NormalizedAdapter, NormalizedAdapterRoute, AdapterIdHit, AdapterVerdict, AdapterRemoteVerdict } from './plugin-adapter-core.js'
71
- import { adapterPathMatches, classifyAdapterRequest, rewriteAdapterRequest, adapterOwnsRegistryEntry, MAX_ADAPTER_RESPONSE_BUFFER_BYTES, ADAPTER_RESPONSE_HEADER_ALLOWLIST, AdapterMultiValueQueryError } from './plugin-adapter-core.js'
73
+ import { adapterPathMatches, classifyAdapterRequest, locateAdapterIds, rewriteAdapterRequest, adapterOwnsRegistryEntry, MAX_ADAPTER_RESPONSE_BUFFER_BYTES, ADAPTER_RESPONSE_HEADER_ALLOWLIST, AdapterMultiValueQueryError } from './plugin-adapter-core.js'
74
+
75
+ /** The RFC 6455 handshake GUID — the SHA-1 input of Sec-WebSocket-Accept. */
76
+ const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
77
+
78
+ /** The byte triplet that ends every HTTP/1.1 header block — the seam where
79
+ * an upgrade answer's HEAD stops and its WebSocket frames begin (PB-26b:
80
+ * both directions strip or buffer up to this mark). */
81
+ const HEADER_END = Buffer.from('\r\n\r\n', 'utf8')
72
82
 
73
83
  /** Structural verdict of {@link checkWebServerShape}: the facts the wrap
74
84
  * reads per request — the `match` function the host's handle calls, the two
@@ -131,8 +141,11 @@ function deepRewriteIds(
131
141
  * array elements stay the value scan's business (the declaration refuses
132
142
  * numeric segments outright). Already-virtual values are skipped, so a known
133
143
  * id sitting on a declared path is rewritten exactly once, by whichever half
134
- * reached it first. */
135
- function rewriteIdsAtFields(root: unknown, fields: readonly string[], serverId: string): void {
144
+ * reached it first. Returns the virtual ids this pass MINTED (PB-26a) —
145
+ * exactly the positions it wrapped, deduped; a position the value scan
146
+ * already wrapped is not a mint (that row is in the browser's view). */
147
+ function rewriteIdsAtFields(root: unknown, fields: readonly string[], serverId: string): string[] {
148
+ const minted = new Set<string>()
136
149
  for (const field of fields) {
137
150
  const segments = field.split('.')
138
151
  let node: unknown = root
@@ -142,8 +155,13 @@ function rewriteIdsAtFields(root: unknown, fields: readonly string[], serverId:
142
155
  if (!isRecord(node)) continue
143
156
  const key = segments[segments.length - 1]
144
157
  const value = node[key]
145
- if (typeof value === 'string' && value !== '' && !isVirtual(value)) node[key] = toVirtual(serverId, value)
158
+ if (typeof value === 'string' && value !== '' && !isVirtual(value)) {
159
+ const virtualId = toVirtual(serverId, value)
160
+ node[key] = virtualId
161
+ minted.add(virtualId)
162
+ }
146
163
  }
164
+ return [...minted]
147
165
  }
148
166
 
149
167
  /** Pump a response body reader into the browser response with real
@@ -180,7 +198,10 @@ async function pumpResponse(
180
198
  * untouched (the adapter discipline): a rewrite is strictly
181
199
  * better-than-nothing, never a reason to mangle or refuse an answer. The
182
200
  * caller has already handled the relay's refusal marker — this owns the
183
- * entire normal answer. */
201
+ * entire normal answer. PB-26a: the virtual ids the position rewrite minted
202
+ * are reported through `onMinted` — at most once per answer, only when there
203
+ * are any, and only after the rewritten body is what the browser receives
204
+ * (a serialization failure passes the original bytes and reports nothing). */
184
205
  async function answerRewriteIds(
185
206
  res: ServerResponse,
186
207
  response: Response,
@@ -188,6 +209,7 @@ async function answerRewriteIds(
188
209
  fields: readonly string[] | undefined,
189
210
  serverId: string,
190
211
  knownIds: ReadonlySet<string> | undefined,
212
+ onMinted: ((virtualIds: readonly string[]) => void) | undefined,
191
213
  ): Promise<void> {
192
214
  const contentType = response.headers.get('content-type')
193
215
  const verbatimHeaders = {
@@ -238,9 +260,10 @@ async function answerRewriteIds(
238
260
  }
239
261
  const text = Buffer.concat(chunks).toString('utf8')
240
262
  let outBody: Buffer
263
+ let minted: string[] = []
241
264
  try {
242
265
  const scanned = deepRewriteIds(JSON.parse(text), known, knownIds, serverId)
243
- if (fields !== undefined) rewriteIdsAtFields(scanned, fields, serverId)
266
+ if (fields !== undefined) minted = rewriteIdsAtFields(scanned, fields, serverId)
244
267
  outBody = Buffer.from(JSON.stringify(scanned), 'utf8')
245
268
  } catch {
246
269
  // Not JSON after all (or the scan hit something unserializable): the
@@ -253,6 +276,14 @@ async function answerRewriteIds(
253
276
  }
254
277
  res.writeHead(response.status, { ...verbatimHeaders, 'content-length': String(outBody.length) })
255
278
  res.end(outBody)
279
+ if (minted.length > 0 && onMinted !== undefined) {
280
+ try {
281
+ onMinted(minted)
282
+ } catch {
283
+ // Bookkeeping for the sidebar — never a reason to fail an answer
284
+ // the browser already has.
285
+ }
286
+ }
256
287
  }
257
288
 
258
289
  /** Pure inspection of the RAW webServer instance — nothing is mutated, so a
@@ -343,6 +374,14 @@ export interface InstallPluginRouteInterceptOptions {
343
374
  * consumption: nothing here mutates merge state. Absent → the scan is
344
375
  * exactly the request-local PB-22 behavior. */
345
376
  getKnownRealIds?: () => ReadonlySet<string>
377
+ /** PB-26a: called with the virtual session ids a response rewrite
378
+ * PRODUCED — a `rewriteIds.fields` route's wrapped positions, or the ids
379
+ * an adapter's `client.response` hook passed through `virtualize` (e.g.
380
+ * better-sidebar's `sidechat.start` childId) — so the session-summary
381
+ * sync can announce subagent/sidechat rows that never appear in
382
+ * workspace/follow. At most once per answer, deduped, only when there is
383
+ * something to report; a throwing callback never fails the answer. */
384
+ onMintedVirtualIds?: (virtualIds: readonly string[]) => void
346
385
  /** Progress logging, wired to the context logger by index.ts. */
347
386
  log?: (format: string, ...args: unknown[]) => void
348
387
  }
@@ -450,6 +489,7 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
450
489
  const adapters = options.adapters ?? []
451
490
  const getCurrentSession = options.getCurrentSession
452
491
  const getKnownRealIds = options.getKnownRealIds
492
+ const onMintedVirtualIds = options.onMintedVirtualIds
453
493
  const target = webServer as { match: (this: unknown, pathname: string) => unknown }
454
494
  const savedMatch = target.match
455
495
 
@@ -754,7 +794,7 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
754
794
  // known inventory, read live right here (undefined when the wiring
755
795
  // passed no getter — the scan stays request-local).
756
796
  if (declared.rewriteIds) {
757
- await answerRewriteIds(res, response, idMapping, declared.rewriteFields, parts.serverId, getKnownRealIds?.())
797
+ await answerRewriteIds(res, response, idMapping, declared.rewriteFields, parts.serverId, getKnownRealIds?.(), onMintedVirtualIds)
758
798
  return
759
799
  }
760
800
  const contentType = response.headers.get('content-type')
@@ -1064,7 +1104,7 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
1064
1104
  return
1065
1105
  }
1066
1106
  if (declared.rewriteIds) {
1067
- await answerRewriteIds(res, response, idMapping, declared.rewriteFields, locatedServerId!, getKnownRealIds?.())
1107
+ await answerRewriteIds(res, response, idMapping, declared.rewriteFields, locatedServerId!, getKnownRealIds?.(), onMintedVirtualIds)
1068
1108
  return
1069
1109
  }
1070
1110
  const contentType = response.headers.get('content-type')
@@ -1430,12 +1470,20 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
1430
1470
  }
1431
1471
  const text = Buffer.concat(chunks).toString('utf8')
1432
1472
  let rewrittenBody: unknown
1473
+ // PB-26a: every id the hook virtualizes is a row the browser is
1474
+ // about to learn of (sidechat.start's childId) — collected here,
1475
+ // reported only once the hook's answer is what the browser gets.
1476
+ const minted = new Set<string>()
1433
1477
  try {
1434
1478
  rewrittenBody = responseHook({
1435
1479
  status: response.status,
1436
1480
  pathname,
1437
1481
  body: JSON.parse(text),
1438
- virtualize: (id: string) => toVirtual(serverId ?? '', id),
1482
+ virtualize: (id: string) => {
1483
+ const virtualId = toVirtual(serverId ?? '', id)
1484
+ if (isVirtual(virtualId)) minted.add(virtualId)
1485
+ return virtualId
1486
+ },
1439
1487
  isVirtual: (value: unknown) => isVirtual(value),
1440
1488
  })
1441
1489
  } catch {
@@ -1459,6 +1507,14 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
1459
1507
  'content-length': String(outBody.length),
1460
1508
  })
1461
1509
  res.end(outBody)
1510
+ if (minted.size > 0 && onMintedVirtualIds !== undefined) {
1511
+ try {
1512
+ onMintedVirtualIds([...minted])
1513
+ } catch {
1514
+ // Sidebar bookkeeping — never a reason to fail an answer the
1515
+ // browser already has.
1516
+ }
1517
+ }
1462
1518
  } catch (error) {
1463
1519
  if (controller?.signal.aborted || res.destroyed || res.headersSent) {
1464
1520
  try {
@@ -1519,7 +1575,28 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
1519
1575
 
1520
1576
  const targetWithUpgrades = webServer as { upgrades: Map<string, unknown> }
1521
1577
  const savedUpgradesGet = targetWithUpgrades.upgrades.get
1522
- const upgradeRoutesOf = (pathname: string): { plugin: string; version: string; digest: string; path: string; sessionParam: string; deny: boolean } | undefined => {
1578
+ /** One declared upgrade-route hit, as the surrogate consumes it. The
1579
+ * PB-26b fields: the EFFECTIVE locator (a json-body upgrade's ids ride
1580
+ * the post-handshake frames, never the query), the rewriteIds flags the
1581
+ * downstream frames honor, the route's adapter-core view (the classify/
1582
+ * rewrite runs on it), and the OWNING adapter when the route is an
1583
+ * adapter's — its `client.response` hook rewrites the downstream frames
1584
+ * (`undefined` for a cooperative declaration, and for an adapter that
1585
+ * yielded its registry entry). */
1586
+ interface DeclaredUpgrade {
1587
+ plugin: string
1588
+ version: string
1589
+ digest: string
1590
+ path: string
1591
+ sessionParam: string
1592
+ deny: boolean
1593
+ locator: BridgeLocator
1594
+ rewriteIds: boolean
1595
+ rewriteFields: readonly string[] | undefined
1596
+ view: NormalizedAdapterRoute
1597
+ adapter: NormalizedAdapter | undefined
1598
+ }
1599
+ const upgradeRoutesOf = (pathname: string): DeclaredUpgrade | undefined => {
1523
1600
  if (!installed) return undefined
1524
1601
  for (const declaration of registry.list()) {
1525
1602
  for (const route of declaration.routes) {
@@ -1531,6 +1608,11 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
1531
1608
  path: route.path,
1532
1609
  sessionParam: route.sessionParam,
1533
1610
  deny: routeDeniesRemoteOf(route),
1611
+ locator: routeLocatorOf(route),
1612
+ rewriteIds: routeRewritesIdsOf(route),
1613
+ rewriteFields: routeRewriteFieldsOf(route),
1614
+ view: bridgeRouteAsAdapterRoute(route),
1615
+ adapter: adapterRouteOf(pathname)?.adapter,
1534
1616
  }
1535
1617
  }
1536
1618
  }
@@ -1538,14 +1620,436 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
1538
1620
  return undefined
1539
1621
  }
1540
1622
 
1623
+ /** PB-26b: the json-body upgrade surrogate — the frame-level twin of the
1624
+ * HTTP surrogates. The exchange: the browser gets the 101 IMMEDIATELY (its
1625
+ * `onopen` fires and the first frame — better-sidebar's sidechat watch —
1626
+ * is on the wire), then every incoming text frame is located against the
1627
+ * route's locator and classified. LOCAL ids pipe the exchange into the
1628
+ * plugin's own upgrade handler over an in-memory socket (the handler's
1629
+ * 101 head is stripped — the browser already has one). VIRTUAL ids open a
1630
+ * PB-10 byte tunnel whose upstream is RE-FRAMED masked (each text frame's
1631
+ * ids rewritten real, per-frame — the server's send gate re-checks them
1632
+ * against the share wall) and whose downstream frames are rewritten
1633
+ * virtual (the adapter's response hook, or the cooperative rewriteIds
1634
+ * scan) before the browser sees them. A mixed or foreign-server frame
1635
+ * kills the exchange — docs/adr/0001 holds frame by frame. */
1636
+ const jsonBodyUpgrade = (declared: DeclaredUpgrade, originalHandler: unknown, req: IncomingMessage, socket: Socket, head: Buffer): Promise<void> =>
1637
+ (async () => {
1638
+ const plugin = declared.plugin
1639
+ const socketGone = (): boolean => socket.destroyed || !socket.writable
1640
+ const destroySocket = (): void => {
1641
+ try {
1642
+ socket.destroy()
1643
+ } catch {
1644
+ // Already gone.
1645
+ }
1646
+ }
1647
+ // The post-101 refusal channel: the handshake is OUT, so no HTTP
1648
+ // status fits anymore — the refusal is recorded and the socket dies.
1649
+ const refuseAndDie = (reason: string): void => {
1650
+ recordRefusal(plugin, reason)
1651
+ log?.('plugin upgrade refused for %s: %s', plugin, reason)
1652
+ destroySocket()
1653
+ }
1654
+ // The deny wall (the HTTP halves' current-session rule, pre-101 — the
1655
+ // answerUnbridgedSocket answer still fits here): a remote panel never
1656
+ // opens this route at all.
1657
+ if (declared.deny) {
1658
+ const current = getCurrentSession?.()
1659
+ if (current !== undefined && isVirtual(current)) {
1660
+ answerUnbridgedSocket(socket, plugin, 'route-denied')
1661
+ return
1662
+ }
1663
+ }
1664
+ // The two headers the handshake cannot exist without — we answer it
1665
+ // OURSELVES this time, so a missing one refuses before anything is
1666
+ // written.
1667
+ const wsKey = typeof req.headers['sec-websocket-key'] === 'string' ? req.headers['sec-websocket-key'] : undefined
1668
+ const wsVersion = typeof req.headers['sec-websocket-version'] === 'string' ? req.headers['sec-websocket-version'] : undefined
1669
+ if (wsKey === undefined || wsVersion === undefined) {
1670
+ answerUnbridgedSocket(socket, plugin, 'bad-query')
1671
+ return
1672
+ }
1673
+ // The 101 goes out NOW: the browser's onopen fires and its first frame
1674
+ // is what decides the route below. Extensions are offered to nobody —
1675
+ // not echoed here, not offered upstream (the tunnel opens with the
1676
+ // key and version alone) — so every frame stays plain text and the
1677
+ // rewrites below can never land on compressed bytes.
1678
+ try {
1679
+ socket.write(
1680
+ `HTTP/1.1 101 Switching Protocols\r\n` +
1681
+ `Upgrade: websocket\r\n` +
1682
+ `Connection: Upgrade\r\n` +
1683
+ `Sec-WebSocket-Accept: ${createHash('sha1').update(wsKey + WS_GUID).digest('base64')}\r\n` +
1684
+ `\r\n`,
1685
+ )
1686
+ } catch {
1687
+ return
1688
+ }
1689
+
1690
+ // ---- the local pipe: an in-memory socket behind the real handler ----
1691
+ let localSocket: Duplex | undefined
1692
+ const startLocal = (): void => {
1693
+ if (typeof originalHandler !== 'function') {
1694
+ // No local handler to serve the local verdict through — the
1695
+ // exchange is dead either way.
1696
+ destroySocket()
1697
+ return
1698
+ }
1699
+ // The handler's writes arrive through the Duplex's writable side;
1700
+ // its reads come from OUR writes. The 101 head the handler produces
1701
+ // is stripped (the browser already has one) — every byte after the
1702
+ // header end is a WebSocket frame and crosses verbatim.
1703
+ const pipe = new Duplex({
1704
+ write(chunk: Buffer, _encoding, callback) {
1705
+ forwardFromLocal(chunk)
1706
+ callback()
1707
+ },
1708
+ read() {},
1709
+ })
1710
+ // net.Socket methods the handler may call that a Duplex lacks.
1711
+ const socketShaped = pipe as unknown as { setTimeout(): unknown; setNoDelay(): unknown }
1712
+ socketShaped.setTimeout = () => {}
1713
+ socketShaped.setNoDelay = () => {}
1714
+ let headerSeen = false
1715
+ let headerBuffer = Buffer.alloc(0)
1716
+ const forwardFromLocal = (chunk: Buffer): void => {
1717
+ if (socketGone()) return
1718
+ if (headerSeen) {
1719
+ socket.write(chunk)
1720
+ return
1721
+ }
1722
+ headerBuffer = Buffer.concat([headerBuffer, chunk])
1723
+ const end = headerBuffer.indexOf(HEADER_END)
1724
+ if (end === -1) return
1725
+ headerSeen = true
1726
+ const rest = headerBuffer.subarray(end + HEADER_END.length)
1727
+ headerBuffer = Buffer.alloc(0)
1728
+ if (rest.length > 0) socket.write(rest)
1729
+ }
1730
+ pipe.on('close', () => destroySocket())
1731
+ socket.on('close', () => {
1732
+ try {
1733
+ pipe.destroy()
1734
+ } catch {
1735
+ // Already gone.
1736
+ }
1737
+ })
1738
+ localSocket = pipe
1739
+ // The handler owns the exchange from here — its async body runs on
1740
+ // its own, exactly as the host would run it.
1741
+ const handler = originalHandler as (request: IncomingMessage, sock: Socket, earlyHead: Buffer) => unknown
1742
+ void Promise.resolve(handler(req, pipe as unknown as Socket, Buffer.alloc(0))).catch(() => destroySocket())
1743
+ }
1744
+
1745
+ // ---- the remote pipe: the PB-10 tunnel, frame by frame -------------
1746
+ let tunnelHandle: { tunnel: string; frames: AsyncIterable<Uint8Array> } | undefined
1747
+ // The connection-wide real→virtual mapping the rewriteIds deep scan
1748
+ // consults: EVERY validated remote frame's ids merge in (a watch list
1749
+ // that grows mid-connection makes the later answers rewritable too —
1750
+ // the deep scan only knows the ids this exchange has carried).
1751
+ const idMapping = new Map<string, string>()
1752
+ let upstreamSeq = 0
1753
+ let upstreamBroken = false
1754
+ let teardownTunnel: () => void = () => {}
1755
+ const killExchange = (): void => {
1756
+ teardownTunnel()
1757
+ destroySocket()
1758
+ }
1759
+ /** ONE text frame onto the OPEN tunnel: the ids named by `hits`
1760
+ * rewritten real (an empty list — the unsubscribe-all — crosses
1761
+ * verbatim), re-framed MASKED (client style — the server's send gate
1762
+ * parses and re-checks them), sent in the chain's order. A transport
1763
+ * failure tears the whole exchange down (counter, close, socket) —
1764
+ * never a silent broken flag: that would leak `tunnelsCurrent` and
1765
+ * leave the browser hanging on a dead tunnel. */
1766
+ const sendTextUpstream = async (frame: Rfc6455Frame, parsedJson: unknown, hits: AdapterIdHit[]): Promise<void> => {
1767
+ if (upstreamBroken || tunnelHandle === undefined) return
1768
+ const rewrite = hits.length > 0
1769
+ ? rewriteAdapterRequest(declared.view, declared.path, new URLSearchParams(), parsedJson, hits, (virtualId) => fromVirtual(virtualId)!.id)
1770
+ : undefined
1771
+ const payload = rewrite?.bodyText !== undefined ? Buffer.from(rewrite.bodyText, 'utf8') : frame.payload
1772
+ const seq = upstreamSeq
1773
+ upstreamSeq += 1
1774
+ try {
1775
+ await relay.pluginTunnelSend(tunnelHandle.tunnel, seq, encodeRfc6455Frame(0x1, payload, true, frame.fin))
1776
+ } catch {
1777
+ killExchange()
1778
+ }
1779
+ }
1780
+ /** The downstream river: the tunnel's byte frames → rewritten text
1781
+ * frames on the browser socket. The plugin's 101 head is stripped
1782
+ * FIRST (the browser already has ours); each text frame is rewritten
1783
+ * (adapter hook, else the cooperative rewriteIds scan) and re-framed
1784
+ * UNMASKED (server style); every other frame crosses verbatim. */
1785
+ const startDownstream = (opened: { tunnel: string; frames: AsyncIterable<Uint8Array> }, serverId: string): void => {
1786
+ void (async () => {
1787
+ let downstream = Buffer.alloc(0)
1788
+ let headerSeen = false
1789
+ try {
1790
+ for await (const chunk of opened.frames) {
1791
+ if (socketGone()) break
1792
+ downstream = Buffer.concat([downstream, chunk])
1793
+ if (!headerSeen) {
1794
+ const end = downstream.indexOf(HEADER_END)
1795
+ if (end === -1) continue
1796
+ headerSeen = true
1797
+ downstream = downstream.subarray(end + HEADER_END.length)
1798
+ }
1799
+ const parsed = parseRfc6455Frames(downstream)
1800
+ downstream = downstream.subarray(parsed.consumed)
1801
+ if (parsed.violated) break
1802
+ for (const frame of parsed.frames) {
1803
+ if (frame.opcode !== 0x1) {
1804
+ socket.write(frame.raw)
1805
+ continue
1806
+ }
1807
+ let outPayload = frame.payload
1808
+ try {
1809
+ const parsedJson = JSON.parse(frame.payload.toString('utf8'))
1810
+ const responseHook = declared.adapter?.hooks.client?.response
1811
+ let hooked = false
1812
+ if (responseHook !== undefined) {
1813
+ outPayload = Buffer.from(JSON.stringify(responseHook({
1814
+ status: 200,
1815
+ pathname: declared.path,
1816
+ body: parsedJson,
1817
+ virtualize: (id: string) => toVirtual(serverId, id),
1818
+ isVirtual: (value: unknown) => isVirtual(value),
1819
+ })), 'utf8')
1820
+ hooked = true
1821
+ } else if (declared.rewriteIds) {
1822
+ const scanned = deepRewriteIds(parsedJson, idMapping, getKnownRealIds?.(), serverId)
1823
+ if (declared.rewriteFields !== undefined) rewriteIdsAtFields(scanned, declared.rewriteFields, serverId)
1824
+ outPayload = Buffer.from(JSON.stringify(scanned), 'utf8')
1825
+ hooked = true
1826
+ }
1827
+ // Neither hook applies: the frame crosses verbatim.
1828
+ if (!hooked) outPayload = frame.payload
1829
+ } catch {
1830
+ // A frame that will not parse (or a hook that threw)
1831
+ // crosses untouched — a rewrite is better-than-nothing,
1832
+ // never a reason to mangle a push.
1833
+ }
1834
+ if (socketGone()) break
1835
+ // The re-frame carries the ORIGINAL frame's fin bit — a
1836
+ // fragmented push stays fragmented, a whole frame stays
1837
+ // whole.
1838
+ if (!socket.write(encodeRfc6455Frame(0x1, outPayload, false, frame.fin))) {
1839
+ await new Promise<void>((resolve) => {
1840
+ const settled = (): void => {
1841
+ socket.off('drain', settled)
1842
+ socket.off('close', settled)
1843
+ resolve()
1844
+ }
1845
+ socket.once('drain', settled)
1846
+ socket.once('close', settled)
1847
+ })
1848
+ if (socketGone()) break
1849
+ }
1850
+ }
1851
+ }
1852
+ } catch {
1853
+ // The tunnel transport died — the browser socket follows below.
1854
+ }
1855
+ if (!socketGone()) destroySocket()
1856
+ })()
1857
+ }
1858
+ /** ONE text frame through the walls. The FIRST one decides the
1859
+ * exchange's path (local pipe or remote tunnel) and the connection
1860
+ * never switches: a frame that would take the OTHER path is a
1861
+ * cross-frame mix and kills the exchange (docs/adr/0001, frame by
1862
+ * frame). The one exception is the id-LESS frame on a tunnel-bound
1863
+ * connection — better-sidebar's `{"op":"watch","threadIds":[]}`
1864
+ * unsubscribe-all carries no id and no local meaning, so it keeps
1865
+ * riding the tunnel verbatim. */
1866
+ const handleTextFrame = async (frame: Rfc6455Frame): Promise<void> => {
1867
+ let parsedJson: unknown
1868
+ try {
1869
+ parsedJson = JSON.parse(frame.payload.toString('utf8'))
1870
+ } catch {
1871
+ refuseAndDie('bad-query')
1872
+ return
1873
+ }
1874
+ let verdict: AdapterRemoteVerdict
1875
+ try {
1876
+ verdict = classifyAdapterRequest(declared.view, declared.path, new URLSearchParams(), parsedJson)
1877
+ } catch {
1878
+ refuseAndDie('bad-query')
1879
+ return
1880
+ }
1881
+ if (verdict.kind === 'mixed') {
1882
+ refuseAndDie('bad-query')
1883
+ return
1884
+ }
1885
+ const rawHits = locateAdapterIds(declared.view, declared.path, new URLSearchParams(), parsedJson)
1886
+ if (verdict.kind === 'local') {
1887
+ if (tunnelHandle !== undefined) {
1888
+ // Bound to the remote tunnel: the id-less frame stays on it, a
1889
+ // REAL local id is a cross-frame mix — never a pivot to the
1890
+ // local plugin.
1891
+ if (rawHits.length === 0) {
1892
+ await sendTextUpstream(frame, parsedJson, [])
1893
+ return
1894
+ }
1895
+ refuseAndDie('bad-query')
1896
+ killExchange()
1897
+ return
1898
+ }
1899
+ if (localSocket === undefined) {
1900
+ startLocal()
1901
+ if (localSocket === undefined) return
1902
+ }
1903
+ // The browser's frame enters the handler's READ side (push) — the
1904
+ // Duplex's write side is the handler's own answer channel.
1905
+ localSocket.push(frame.raw)
1906
+ return
1907
+ }
1908
+ // Remote. The FIRST remote frame opens the tunnel; every hit must
1909
+ // belong to THIS handshake's server. A connection already bound to
1910
+ // the local pipe never pivots to the tunnel.
1911
+ if (localSocket !== undefined) {
1912
+ refuseAndDie('bad-query')
1913
+ try {
1914
+ localSocket.destroy()
1915
+ } catch {
1916
+ // Already gone.
1917
+ }
1918
+ return
1919
+ }
1920
+ const serverId = getServerId()
1921
+ if (serverId === undefined) {
1922
+ refuseAndDie('server-changed')
1923
+ return
1924
+ }
1925
+ const mapping = new Map<string, string>()
1926
+ for (const idHit of verdict.hits) {
1927
+ const parts = fromVirtual(idHit.value)
1928
+ if (parts === undefined || parts.id === '' || parts.serverId !== serverId) {
1929
+ refuseAndDie('server-changed')
1930
+ return
1931
+ }
1932
+ mapping.set(parts.id, idHit.value)
1933
+ }
1934
+ // This frame's ids join the connection-wide mapping (see idMapping).
1935
+ for (const [key, value] of mapping) idMapping.set(key, value)
1936
+ if (tunnelHandle === undefined) {
1937
+ const status = getStatus().find((entry) => entry.plugin === plugin)
1938
+ if (status === undefined || !status.bridged) {
1939
+ refuseAndDie(status?.reason ?? 'not-on-server')
1940
+ return
1941
+ }
1942
+ const controller = new AbortController()
1943
+ socket.on('close', () => controller.abort())
1944
+ let opened: { tunnel: string; frames: AsyncIterable<Uint8Array> }
1945
+ try {
1946
+ log?.('opening tunnel for %s', declared.path)
1947
+ opened = await relay.pluginTunnelOpen(
1948
+ {
1949
+ plugin,
1950
+ version: declared.version,
1951
+ digest: declared.digest,
1952
+ path: declared.path,
1953
+ query: '',
1954
+ wsHeaders: { 'sec-websocket-key': wsKey, 'sec-websocket-version': wsVersion },
1955
+ },
1956
+ controller.signal,
1957
+ )
1958
+ } catch (error) {
1959
+ const rawCode = (error as { code?: unknown } | null)?.code
1960
+ refuseAndDie(typeof rawCode === 'string' && rawCode !== '' ? rawCode : 'offline')
1961
+ return
1962
+ }
1963
+ tunnelsCurrent += 1
1964
+ tunnelsOpened += 1
1965
+ tunnelHandle = opened
1966
+ log?.('tunnel open for %s: %s', declared.path, opened.tunnel)
1967
+ teardownTunnel = (): void => {
1968
+ if (upstreamBroken) return
1969
+ upstreamBroken = true
1970
+ tunnelsCurrent -= 1
1971
+ tunnelsClosed += 1
1972
+ void relay.pluginTunnelClose(opened.tunnel).catch(() => {})
1973
+ }
1974
+ socket.on('close', teardownTunnel)
1975
+ socket.on('error', teardownTunnel)
1976
+ startDownstream(opened, serverId)
1977
+ }
1978
+ await sendTextUpstream(frame, parsedJson, verdict.hits)
1979
+ }
1980
+ const handleFrame = async (frame: Rfc6455Frame): Promise<void> => {
1981
+ // Control frames (close/ping/pong) carry no session ids and cross
1982
+ // VERBATIM on whichever path exists — the close handshake completes
1983
+ // the honest way (the plugin's reply close rides back downstream,
1984
+ // the socket-close handlers tear the tunnel down in turn). With no
1985
+ // path yet, a close just closes.
1986
+ if (frame.opcode !== 0x1 && frame.opcode !== 0x2) {
1987
+ if (localSocket !== undefined) {
1988
+ localSocket.push(frame.raw)
1989
+ return
1990
+ }
1991
+ if (tunnelHandle !== undefined && !upstreamBroken) {
1992
+ const seq = upstreamSeq
1993
+ upstreamSeq += 1
1994
+ try {
1995
+ await relay.pluginTunnelSend(tunnelHandle.tunnel, seq, frame.raw)
1996
+ } catch {
1997
+ // The send died: tear the exchange down COMPLETELY — the
1998
+ // counter, the close, the browser socket (a bare broken flag
1999
+ // would leak `tunnelsCurrent` and hang the browser).
2000
+ killExchange()
2001
+ }
2002
+ return
2003
+ }
2004
+ if (frame.opcode === 0x8) killExchange()
2005
+ return
2006
+ }
2007
+ await handleTextFrame(frame)
2008
+ }
2009
+ // ---- frame intake: the early head bytes, then every socket byte ----
2010
+ // Complete frames dispatch through ONE serialized chain (the arrival
2011
+ // order is the wire order the plugin and the browser both assume); a
2012
+ // declared-oversize frame is a broken sender — refused, never awaited.
2013
+ let intake = Buffer.from(head)
2014
+ let chain: Promise<void> = Promise.resolve()
2015
+ socket.on('data', (chunk: Buffer) => {
2016
+ if (socketGone()) return
2017
+ intake = Buffer.concat([intake, chunk])
2018
+ const parsedFrames = parseRfc6455Frames(intake)
2019
+ intake = intake.subarray(parsedFrames.consumed)
2020
+ if (parsedFrames.violated) {
2021
+ refuseAndDie('bad-query')
2022
+ return
2023
+ }
2024
+ for (const frame of parsedFrames.frames) {
2025
+ chain = chain
2026
+ .then(() => handleFrame(frame))
2027
+ .catch(() => destroySocket())
2028
+ }
2029
+ })
2030
+ })().catch(() => {
2031
+ try {
2032
+ socket.destroy()
2033
+ } catch {
2034
+ // Already gone.
2035
+ }
2036
+ })
2037
+
1541
2038
  /** The tunnel's surrogate upgrade handler. Every outcome — refusal,
1542
2039
  * teardown, the byte pumping — owns the socket's whole remaining life. */
1543
- const surrogateUpgrade = (declared: { plugin: string; version: string; digest: string; path: string; sessionParam: string; deny: boolean }, originalHandler: unknown) =>
2040
+ const surrogateUpgrade = (declared: DeclaredUpgrade, originalHandler: unknown) =>
1544
2041
  (req: IncomingMessage, socket: Socket, head: Buffer): void => {
1545
2042
  // The host never awaits an upgrade handler — the async body runs on
1546
2043
  // its own and every failure path owns the socket's teardown.
1547
2044
  void (async () => {
1548
2045
  log?.('upgrade entered for %s', declared.path)
2046
+ // PB-26b: a json-body upgrade's ids ride the POST-HANDSHAKE frames —
2047
+ // there is no session query parameter for the byte-tunnel half below
2048
+ // to read, so the frame-level half owns the whole exchange.
2049
+ if (declared.locator.in !== 'query') {
2050
+ await jsonBodyUpgrade(declared, originalHandler, req, socket, head)
2051
+ return
2052
+ }
1549
2053
  const socketGone = (): boolean => socket.destroyed || !socket.writable
1550
2054
  const passthrough = (): void => {
1551
2055
  if (typeof originalHandler === 'function') {