dsh-zen-remote 2.3.5 → 2.3.6
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/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/docs/interface.md +2 -2
- package/docs/plugin-bridge.md +2 -2
- package/docs/remote-access.en.md +3 -3
- package/docs/remote-access.md +3 -3
- package/lib/adapters/dsh-better-sidebar/index.js +31 -2
- package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
- package/lib/client.js +84 -19
- package/lib/index.js +13 -0
- package/lib/index.js.map +1 -1
- package/lib/intercept.js +43 -1
- package/lib/intercept.js.map +1 -1
- 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/locales.d.ts +2 -0
- package/lib/types/client/locales.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/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/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 +1 -1
- package/scripts/check-info-sheet-stacking.mjs +85 -0
- package/src/adapters/dsh-better-sidebar/index.ts +31 -2
- package/src/client/MobileSessionInfo.tsx +29 -2
- package/src/client/locales.ts +4 -0
- package/src/client/styles/content.css.ts +44 -14
- package/src/client/styles/layout.css.ts +9 -3
- package/src/index.ts +13 -0
- package/src/intercept.ts +46 -0
- package/src/plugin-adapter-core.ts +29 -2
- package/src/plugin-route-intercept.ts +516 -12
- 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
|
-
|
|
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))
|
|
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) =>
|
|
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
|
-
|
|
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:
|
|
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') {
|