dsh-zen-remote 2.0.3 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +15 -1
- package/README.md +11 -5
- package/README.zh-CN.md +11 -5
- package/docs/adr/0001-plugin-bridge-forwards-plugin-routes.md +15 -0
- package/docs/adr/0002-relay-emulates-websocket.md +8 -0
- package/docs/interface.md +3 -1
- package/docs/plugin-bridge.md +140 -0
- package/docs/remote-access.en.md +57 -0
- package/docs/remote-access.md +57 -0
- package/lib/adapters/dsh-better-sidebar/index.js +144 -0
- package/lib/adapters/dsh-better-sidebar/index.js.map +1 -0
- package/lib/adapters/dsh-better-sidebar/methods.js +198 -0
- package/lib/adapters/dsh-better-sidebar/methods.js.map +1 -0
- package/lib/adapters/index.js +17 -0
- package/lib/adapters/index.js.map +1 -0
- package/lib/adapters/types.js +17 -0
- package/lib/adapters/types.js.map +1 -0
- package/lib/admin-routes.js +51 -0
- package/lib/admin-routes.js.map +1 -1
- package/lib/client-routes.js +18 -1
- package/lib/client-routes.js.map +1 -1
- package/lib/client.js +449 -45
- package/lib/config.js +27 -0
- package/lib/config.js.map +1 -1
- package/lib/http.js +10 -0
- package/lib/http.js.map +1 -1
- package/lib/index.js +246 -0
- package/lib/index.js.map +1 -1
- package/lib/plugin-adapter-core.js +385 -0
- package/lib/plugin-adapter-core.js.map +1 -0
- package/lib/plugin-bridge.js +318 -0
- package/lib/plugin-bridge.js.map +1 -0
- package/lib/plugin-loopback.js +207 -0
- package/lib/plugin-loopback.js.map +1 -0
- package/lib/plugin-route-intercept.js +1103 -0
- package/lib/plugin-route-intercept.js.map +1 -0
- package/lib/plugin-tunnel.js +416 -0
- package/lib/plugin-tunnel.js.map +1 -0
- package/lib/relay-client.js +319 -2
- package/lib/relay-client.js.map +1 -1
- package/lib/relay-server.js +633 -0
- package/lib/relay-server.js.map +1 -1
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts +27 -0
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -0
- package/lib/types/adapters/dsh-better-sidebar/methods.d.ts +51 -0
- package/lib/types/adapters/dsh-better-sidebar/methods.d.ts.map +1 -0
- package/lib/types/adapters/index.d.ts +17 -0
- package/lib/types/adapters/index.d.ts.map +1 -0
- package/lib/types/adapters/types.d.ts +42 -0
- package/lib/types/adapters/types.d.ts.map +1 -0
- package/lib/types/admin-routes.d.ts +16 -0
- package/lib/types/admin-routes.d.ts.map +1 -1
- package/lib/types/client/effects/remote-session.d.ts +0 -22
- package/lib/types/client/effects/remote-session.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +16 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
- package/lib/types/client-data/plugin-bridge.d.ts +79 -0
- package/lib/types/client-data/plugin-bridge.d.ts.map +1 -0
- package/lib/types/client-data/remote-status.d.ts +20 -0
- package/lib/types/client-data/remote-status.d.ts.map +1 -1
- package/lib/types/client-data/settings-form.d.ts +37 -1
- package/lib/types/client-data/settings-form.d.ts.map +1 -1
- package/lib/types/client-routes.d.ts +13 -0
- package/lib/types/client-routes.d.ts.map +1 -1
- package/lib/types/config.d.ts +8 -0
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/http.d.ts +7 -0
- package/lib/types/http.d.ts.map +1 -1
- package/lib/types/index.d.ts +4 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/plugin-adapter-core.d.ts +263 -0
- package/lib/types/plugin-adapter-core.d.ts.map +1 -0
- package/lib/types/plugin-bridge.d.ts +187 -0
- package/lib/types/plugin-bridge.d.ts.map +1 -0
- package/lib/types/plugin-loopback.d.ts +104 -0
- package/lib/types/plugin-loopback.d.ts.map +1 -0
- package/lib/types/plugin-route-intercept.d.ts +114 -0
- package/lib/types/plugin-route-intercept.d.ts.map +1 -0
- package/lib/types/plugin-tunnel.d.ts +96 -0
- package/lib/types/plugin-tunnel.d.ts.map +1 -0
- package/lib/types/relay-client.d.ts +76 -0
- package/lib/types/relay-client.d.ts.map +1 -1
- package/lib/types/relay-server.d.ts +32 -1
- package/lib/types/relay-server.d.ts.map +1 -1
- package/llms.txt +1 -0
- package/package.json +2 -2
- package/scripts/check-plugin-bridge-ui.mjs +168 -0
- package/scripts/check-settings-form.mjs +3 -1
- package/src/adapters/dsh-better-sidebar/index.ts +144 -0
- package/src/adapters/dsh-better-sidebar/methods.ts +215 -0
- package/src/adapters/index.ts +19 -0
- package/src/adapters/types.ts +44 -0
- package/src/admin-routes.ts +64 -0
- package/src/client/effects/remote-session.ts +82 -10
- package/src/client/locales.ts +36 -0
- package/src/client/settings/SettingsSection.tsx +107 -3
- package/src/client/styles/remote-session.css.ts +70 -31
- package/src/client-data/plugin-bridge.ts +150 -0
- package/src/client-data/remote-status.ts +30 -0
- package/src/client-data/settings-form.ts +78 -0
- package/src/client-routes.ts +29 -1
- package/src/config.ts +32 -0
- package/src/http.ts +12 -0
- package/src/index.ts +252 -0
- package/src/plugin-adapter-core.ts +530 -0
- package/src/plugin-bridge.ts +435 -0
- package/src/plugin-loopback.ts +299 -0
- package/src/plugin-route-intercept.ts +1193 -0
- package/src/plugin-tunnel.ts +470 -0
- package/src/relay-client.ts +394 -2
- package/src/relay-server.ts +656 -1
|
@@ -0,0 +1,1193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plugin bridge's route interception (PB-4): the sub-client's local
|
|
3
|
+
* DSH process owns a webServer whose `match(pathname)` every request goes
|
|
4
|
+
* through (RT dsh-host-webserver lib/index.js:350-364 — `handle` computes
|
|
5
|
+
* the RAW, undecoded pathname and calls `this.match`). Overriding that one
|
|
6
|
+
* method intercepts every route lookup — including routes registered AFTER
|
|
7
|
+
* us, because the host re-reads the instance method per request. A request
|
|
8
|
+
* aimed at a BRIDGED plugin's declared route, carrying a VIRTUAL session id,
|
|
9
|
+
* cannot be served locally (the session lives on the server): the surrogate
|
|
10
|
+
* handler forwards it through `relay.pluginRequest` (PB-3's
|
|
11
|
+
* `relay/v1/plugin`) with the real id restored, and streams the answer
|
|
12
|
+
* back. Everything else — undeclared routes, local sessions — flows to the
|
|
13
|
+
* original route untouched.
|
|
14
|
+
*
|
|
15
|
+
* The un-bridged refusals answer a UNIFORM 503 shape (header
|
|
16
|
+
* `x-zen-remote: unbridged`, JSON body naming the reason) — and, decisive
|
|
17
|
+
* for the security posture, they answer WITHOUT the local plugin handler
|
|
18
|
+
* ever running: a remote session must never execute a plugin route against
|
|
19
|
+
* local state, not even when the bridge is down (docs/adr/0001).
|
|
20
|
+
*
|
|
21
|
+
* The wrap is gated on a structural shape check like the typert and
|
|
22
|
+
* fetch-route ones; uninstall restores `match` only while the instance
|
|
23
|
+
* still holds OUR wrapper — a later wrapper chained over us keeps working,
|
|
24
|
+
* and ours goes inert instead (the same posture as intercept.ts and
|
|
25
|
+
* fetch-route-intercept.ts).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
29
|
+
import type { BridgeRegistry, BridgeStatus } from './plugin-bridge.js'
|
|
30
|
+
import { routeMethodsOf, routeMaxBodyOf, routeUpgradesOf } from './plugin-bridge.js'
|
|
31
|
+
import { PassThrough, Readable, Transform } from 'node:stream'
|
|
32
|
+
import type { Socket } from 'node:net'
|
|
33
|
+
import type { RelayClient } from './relay-client.js'
|
|
34
|
+
import { fromVirtual, isVirtual, toVirtual } from './virtual-id.js'
|
|
35
|
+
import type { NormalizedAdapter, NormalizedAdapterRoute, AdapterIdHit, AdapterVerdict, AdapterRemoteVerdict } from './plugin-adapter-core.js'
|
|
36
|
+
import { adapterPathMatches, classifyAdapterRequest, rewriteAdapterRequest, adapterOwnsRegistryEntry, MAX_ADAPTER_RESPONSE_BUFFER_BYTES, ADAPTER_RESPONSE_HEADER_ALLOWLIST, AdapterMultiValueQueryError } from './plugin-adapter-core.js'
|
|
37
|
+
|
|
38
|
+
/** Structural verdict of {@link checkWebServerShape}: the facts the wrap
|
|
39
|
+
* reads per request — the `match` function the host's handle calls, the two
|
|
40
|
+
* route tables, and (PB-10) the `upgrades` table the host's upgrade handler
|
|
41
|
+
* reads `this.upgrades.get(pathname)` from (both Maps, RT
|
|
42
|
+
* lib/index.js:467-478 and 298-308; the probe verified them on real
|
|
43
|
+
* 0.2.0-rc.2 / 0.2.1-alpha.2 instances). */
|
|
44
|
+
export type WebServerShapeCheck = { ok: true } | { ok: false; reasons: string[] }
|
|
45
|
+
|
|
46
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
47
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Pure inspection of the RAW webServer instance — nothing is mutated, so a
|
|
51
|
+
* failed check leaves every route exactly as it was. */
|
|
52
|
+
export function checkWebServerShape(webServer: unknown): WebServerShapeCheck {
|
|
53
|
+
if (!isRecord(webServer)) return { ok: false, reasons: ['webServer: not an object'] }
|
|
54
|
+
const reasons: string[] = []
|
|
55
|
+
if (typeof webServer.match !== 'function') reasons.push('match: not a function')
|
|
56
|
+
if (!(webServer.exact instanceof Map)) reasons.push('exact: not a Map')
|
|
57
|
+
if (!(webServer.prefixes instanceof Map)) reasons.push('prefixes: not a Map')
|
|
58
|
+
if (!(webServer.upgrades instanceof Map)) reasons.push('upgrades: not a Map')
|
|
59
|
+
if (reasons.length > 0) return { ok: false, reasons }
|
|
60
|
+
return { ok: true }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** One failure of the bridge answered to a browser, in the diagnostics
|
|
64
|
+
* ring. The query NEVER travels — only the plugin name and the reason. */
|
|
65
|
+
export interface PluginRouteFailureRecord {
|
|
66
|
+
time: string
|
|
67
|
+
plugin: string
|
|
68
|
+
reason: string
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** What the client status route surfaces about this interception. */
|
|
72
|
+
export interface PluginRouteInterceptDiagnostics {
|
|
73
|
+
installed: boolean
|
|
74
|
+
shape: WebServerShapeCheck
|
|
75
|
+
/** Requests forwarded through the relay (virtual ids on declared routes,
|
|
76
|
+
* everything green). */
|
|
77
|
+
forwarded: number
|
|
78
|
+
/** Requests answered with the uniform unbridged 503. */
|
|
79
|
+
refused: number
|
|
80
|
+
/** The refusals keyed by reason. */
|
|
81
|
+
refusalsByReason: Record<string, number>
|
|
82
|
+
/** The most recent refusals, oldest first, capped — time, plugin, reason
|
|
83
|
+
* only, never a query, never a session id. */
|
|
84
|
+
recentFailures: PluginRouteFailureRecord[]
|
|
85
|
+
/** PB-10: tunnels live RIGHT NOW. */
|
|
86
|
+
tunnelsCurrent: number
|
|
87
|
+
/** PB-10: tunnels opened / torn down since the install. */
|
|
88
|
+
tunnelsOpened: number
|
|
89
|
+
tunnelsClosed: number
|
|
90
|
+
/** PB-11: adapter requests forwarded through the relay. */
|
|
91
|
+
adapterForwarded: number
|
|
92
|
+
/** PB-11: adapter JSON answers rewritten by a `client.response` hook, and
|
|
93
|
+
* answers that passed through UNTOUCHED because the buffer cap (8 MB) or
|
|
94
|
+
* a parse failure made a rewrite impossible. */
|
|
95
|
+
adapterResponseRewrites: number
|
|
96
|
+
adapterResponsePassthroughs: number
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface InstallPluginRouteInterceptOptions {
|
|
100
|
+
/** The RAW webServer instance (`ctx.webServer[symbols.original]`). */
|
|
101
|
+
webServer: object
|
|
102
|
+
/** The local declaration registry (the `zenRemote` service's table). */
|
|
103
|
+
registry: BridgeRegistry
|
|
104
|
+
/** The client relay client; `pluginRequest` is the forwarding door. */
|
|
105
|
+
relay: RelayClient
|
|
106
|
+
/** The CURRENT handshake's server id, read live per call; a virtual id
|
|
107
|
+
* minted for another server (or before any handshake) refuses
|
|
108
|
+
* `server-changed`. */
|
|
109
|
+
getServerId: () => string | undefined
|
|
110
|
+
/** The PB-2 bridge verdicts, computed live per call; a plugin that is
|
|
111
|
+
* not bridged right now refuses with the status's own reason. */
|
|
112
|
+
getStatus: () => BridgeStatus[]
|
|
113
|
+
/** The validated adapters (PB-11), as the wiring registered them.
|
|
114
|
+
* Absent/empty — the pluggable default — means no adapter routes exist
|
|
115
|
+
* and this interception behaves exactly as before. */
|
|
116
|
+
adapters?: readonly NormalizedAdapter[]
|
|
117
|
+
/** The session id the browser is CURRENTLY looking at (T62's report,
|
|
118
|
+
* read live per call). A VIRTUAL id means a remote session's panel is
|
|
119
|
+
* the request's origin — the signal an adapter route's `deny` hook uses
|
|
120
|
+
* to refuse requests (like better-sidebar's `open.external`) that carry
|
|
121
|
+
* no session field of their own. Absent → deny hooks see `undefined`
|
|
122
|
+
* and judge conservatively. */
|
|
123
|
+
getCurrentSession?: () => string | undefined
|
|
124
|
+
/** Progress logging, wired to the context logger by index.ts. */
|
|
125
|
+
log?: (format: string, ...args: unknown[]) => void
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface PluginRouteInterceptHandle {
|
|
129
|
+
/** Restore `match` — ONLY while the instance still holds our wrapper
|
|
130
|
+
* (chained-over wraps go inert instead). Idempotent. */
|
|
131
|
+
uninstall(): void
|
|
132
|
+
diagnostics(): PluginRouteInterceptDiagnostics
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Longest failure ring kept for the status surface. */
|
|
136
|
+
const MAX_FAILURES = 10
|
|
137
|
+
|
|
138
|
+
/** What one declared-route hit carries from the match lookup into its
|
|
139
|
+
* surrogate handler. */
|
|
140
|
+
interface DeclaredHit {
|
|
141
|
+
plugin: string
|
|
142
|
+
version: string
|
|
143
|
+
digest: string
|
|
144
|
+
/** The declared route path — the exact string the forward uses. */
|
|
145
|
+
path: string
|
|
146
|
+
sessionParam: string
|
|
147
|
+
/** The EFFECTIVE forwardable methods (PB-9): the declared list, or
|
|
148
|
+
* `['GET']` when the route declared none. */
|
|
149
|
+
methods: string[]
|
|
150
|
+
/** The EFFECTIVE body cap (PB-9), in bytes. */
|
|
151
|
+
maxBody: number
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function installPluginRouteIntercept(options: InstallPluginRouteInterceptOptions): PluginRouteInterceptHandle {
|
|
155
|
+
// Shape FIRST, like every wrap: a caller that skipped checkWebServerShape
|
|
156
|
+
// (or a host whose shape changed between the check and this call) gets an
|
|
157
|
+
// INERT handle — nothing on the instance is touched — instead of a
|
|
158
|
+
// TypeError from a missing table midway through the install.
|
|
159
|
+
const shape = checkWebServerShape(options.webServer)
|
|
160
|
+
if (!shape.ok) {
|
|
161
|
+
options.log?.('plugin route intercept refused: %s', shape.reasons.join('; '))
|
|
162
|
+
return {
|
|
163
|
+
uninstall() {},
|
|
164
|
+
diagnostics(): PluginRouteInterceptDiagnostics {
|
|
165
|
+
return {
|
|
166
|
+
installed: false,
|
|
167
|
+
shape,
|
|
168
|
+
forwarded: 0,
|
|
169
|
+
refused: 0,
|
|
170
|
+
refusalsByReason: {},
|
|
171
|
+
recentFailures: [],
|
|
172
|
+
adapterForwarded: 0,
|
|
173
|
+
adapterResponseRewrites: 0,
|
|
174
|
+
adapterResponsePassthroughs: 0,
|
|
175
|
+
tunnelsCurrent: 0,
|
|
176
|
+
tunnelsOpened: 0,
|
|
177
|
+
tunnelsClosed: 0,
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const { webServer, registry, relay, getServerId, getStatus, log } = options
|
|
183
|
+
const adapters = options.adapters ?? []
|
|
184
|
+
const getCurrentSession = options.getCurrentSession
|
|
185
|
+
const target = webServer as { match: (this: unknown, pathname: string) => unknown }
|
|
186
|
+
const savedMatch = target.match
|
|
187
|
+
|
|
188
|
+
let installed = true
|
|
189
|
+
let forwarded = 0
|
|
190
|
+
let refused = 0
|
|
191
|
+
let adapterForwarded = 0
|
|
192
|
+
let adapterResponseRewrites = 0
|
|
193
|
+
let adapterResponsePassthroughs = 0
|
|
194
|
+
let tunnelsCurrent = 0
|
|
195
|
+
let tunnelsOpened = 0
|
|
196
|
+
let tunnelsClosed = 0
|
|
197
|
+
const refusalsByReason: Record<string, number> = {}
|
|
198
|
+
const recentFailures: PluginRouteFailureRecord[] = []
|
|
199
|
+
|
|
200
|
+
/** The unbridged refusal written straight onto an upgrading socket
|
|
201
|
+
* (PB-10): the WebSocket handshake never happened, so the "response" is
|
|
202
|
+
* hand-written HTTP — same shape, same marker, then the socket dies
|
|
203
|
+
* without the local plugin ever seeing the connection. */
|
|
204
|
+
const answerUnbridgedSocket = (socket: Socket, plugin: string, reason: string): void => {
|
|
205
|
+
recordRefusal(plugin, reason)
|
|
206
|
+
log?.('plugin upgrade refused for %s: %s', plugin, reason)
|
|
207
|
+
const body = Buffer.from(JSON.stringify({ ok: false, error: { code: 'zr-plugin-unbridged', reason, plugin } }), 'utf8')
|
|
208
|
+
try {
|
|
209
|
+
socket.write(
|
|
210
|
+
`HTTP/1.1 503 Service Unavailable\r\n` +
|
|
211
|
+
`x-zen-remote: unbridged\r\n` +
|
|
212
|
+
`content-type: application/json; charset=utf-8\r\n` +
|
|
213
|
+
`content-length: ${body.length}\r\n` +
|
|
214
|
+
`connection: close\r\n` +
|
|
215
|
+
`\r\n`,
|
|
216
|
+
)
|
|
217
|
+
socket.end(body)
|
|
218
|
+
} catch {
|
|
219
|
+
// The socket was already gone.
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const recordRefusal = (plugin: string, reason: string): void => {
|
|
224
|
+
refused += 1
|
|
225
|
+
refusalsByReason[reason] = (refusalsByReason[reason] ?? 0) + 1
|
|
226
|
+
if (recentFailures.length >= MAX_FAILURES) recentFailures.shift()
|
|
227
|
+
recentFailures.push({ time: new Date().toISOString(), plugin, reason })
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The uniform unbridged 503: one shape for every refusal, the local
|
|
231
|
+
* plugin handler never reached. */
|
|
232
|
+
const answerUnbridged = (res: ServerResponse, plugin: string, reason: string): void => {
|
|
233
|
+
recordRefusal(plugin, reason)
|
|
234
|
+
log?.('plugin route refused for %s: %s', plugin, reason)
|
|
235
|
+
res.setHeader('x-zen-remote', 'unbridged')
|
|
236
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
237
|
+
res.writeHead(503)
|
|
238
|
+
res.end(JSON.stringify({ ok: false, error: { code: 'zr-plugin-unbridged', reason, plugin } }))
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The original route handler when it is worth calling at all. */
|
|
242
|
+
const handlerOf = (route: unknown): ((req: IncomingMessage, res: ServerResponse) => Promise<void>) | undefined =>
|
|
243
|
+
route !== undefined && isRecord(route) && typeof (route as { handler?: unknown }).handler === 'function'
|
|
244
|
+
? (route as { handler: (req: IncomingMessage, res: ServerResponse) => Promise<void> }).handler
|
|
245
|
+
: undefined
|
|
246
|
+
|
|
247
|
+
/** The declared route this pathname hits — EXACT equality only this task
|
|
248
|
+
* (a declared prefix does not cover longer paths; that arrives with
|
|
249
|
+
* PB-12). First plugin in registry order wins, deterministically. */
|
|
250
|
+
const declaredRouteOf = (pathname: string): DeclaredHit | undefined => {
|
|
251
|
+
for (const declaration of registry.list()) {
|
|
252
|
+
for (const route of declaration.routes) {
|
|
253
|
+
if (route.path === pathname) {
|
|
254
|
+
return {
|
|
255
|
+
plugin: declaration.plugin,
|
|
256
|
+
version: declaration.version,
|
|
257
|
+
digest: declaration.digest,
|
|
258
|
+
path: route.path,
|
|
259
|
+
sessionParam: route.sessionParam,
|
|
260
|
+
methods: routeMethodsOf(route),
|
|
261
|
+
maxBody: routeMaxBodyOf(route),
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return undefined
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** The surrogate handler: ONE query parse, the virtual-id gate, the
|
|
270
|
+
* unbridged walls in contract order, then the relay round-trip. */
|
|
271
|
+
const surrogateHandler = (declared: DeclaredHit, originalHandler: ((req: IncomingMessage, res: ServerResponse) => Promise<void>) | undefined) =>
|
|
272
|
+
async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
273
|
+
const passthrough = async (): Promise<void> => {
|
|
274
|
+
if (originalHandler !== undefined) {
|
|
275
|
+
await originalHandler(req, res)
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
res.writeHead(404)
|
|
279
|
+
res.end()
|
|
280
|
+
}
|
|
281
|
+
// Chained-over: inert passthrough, exactly the posture of the other
|
|
282
|
+
// intercepts.
|
|
283
|
+
if (!installed) {
|
|
284
|
+
await passthrough()
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
// ONE parse of the query (the session parameter's home — `match` only
|
|
288
|
+
// ever saw the pathname): everything below reads THIS parse.
|
|
289
|
+
let searchParams: URLSearchParams
|
|
290
|
+
try {
|
|
291
|
+
searchParams = new URL(req.url ?? '/', 'http://local').searchParams
|
|
292
|
+
} catch {
|
|
293
|
+
answerUnbridged(res, declared.plugin, 'bad-query')
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
const values = searchParams.getAll(declared.sessionParam)
|
|
297
|
+
// The parameter appearing MORE than once is a shape violation the
|
|
298
|
+
// bridge refuses (bad-query) — never a passthrough, and never a
|
|
299
|
+
// forward of whichever duplicate a parser happens to keep. Its TOTAL
|
|
300
|
+
// absence, though, is the LOCAL route's business (a whole-machine
|
|
301
|
+
// route may simply not take one).
|
|
302
|
+
if (values.length > 1) {
|
|
303
|
+
answerUnbridged(res, declared.plugin, 'bad-query')
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
const value = values.length === 1 ? values[0] : undefined
|
|
307
|
+
// A LOCAL id (or none at all): the original route's business — its
|
|
308
|
+
// handler runs exactly as if we were never here.
|
|
309
|
+
if (value === undefined || value === '' || !value.startsWith('zr~')) {
|
|
310
|
+
await passthrough()
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
const parts = fromVirtual(value)
|
|
314
|
+
if (parts === undefined || parts.id === '') {
|
|
315
|
+
answerUnbridged(res, declared.plugin, 'bad-query')
|
|
316
|
+
return
|
|
317
|
+
}
|
|
318
|
+
if (parts.serverId !== getServerId()) {
|
|
319
|
+
answerUnbridged(res, declared.plugin, 'server-changed')
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
// PB-9: the method must be one the route declared — the write surface
|
|
323
|
+
// is exactly as explicit as the read one was in 2.1, and an undeclared
|
|
324
|
+
// method never reaches the local plugin OR the relay.
|
|
325
|
+
const method = req.method ?? 'GET'
|
|
326
|
+
if (!declared.methods.includes(method)) {
|
|
327
|
+
answerUnbridged(res, declared.plugin, 'method-not-bridged')
|
|
328
|
+
return
|
|
329
|
+
}
|
|
330
|
+
// PB-9: the body gate, BEFORE a byte moves — a declared
|
|
331
|
+
// Content-Length past the route's `maxBody` is refused locally (the
|
|
332
|
+
// relay would refuse it at the streaming count anyway; refusing here
|
|
333
|
+
// saves the round-trip and keeps the plugin out of it entirely).
|
|
334
|
+
const hasBody = method !== 'GET' && method !== 'HEAD'
|
|
335
|
+
const declaredLength = Number(req.headers['content-length'])
|
|
336
|
+
if (hasBody && Number.isFinite(declaredLength) && declaredLength > declared.maxBody) {
|
|
337
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
338
|
+
res.writeHead(413)
|
|
339
|
+
res.end(JSON.stringify({ ok: false, error: { code: 'zr-plugin-body-too-large' } }))
|
|
340
|
+
recordRefusal(declared.plugin, 'body-too-large')
|
|
341
|
+
log?.('plugin request for %s refused locally: body-too-large (%s declared)', declared.path, req.headers['content-length'])
|
|
342
|
+
return
|
|
343
|
+
}
|
|
344
|
+
const status = getStatus().find((entry) => entry.plugin === declared.plugin)
|
|
345
|
+
if (status === undefined || !status.bridged) {
|
|
346
|
+
answerUnbridged(res, declared.plugin, status?.reason ?? 'not-on-server')
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
forwarded += 1
|
|
350
|
+
// Logged HERE, after the virtual-id gate (PB-5-fix): a local panel
|
|
351
|
+
// polling its route every second must not log a line a second — the
|
|
352
|
+
// bridge only speaks when a request actually leaves for the relay (or
|
|
353
|
+
// is refused below, which answerUnbridged logs).
|
|
354
|
+
log?.('plugin route %s forwards for %s', declared.path, declared.plugin)
|
|
355
|
+
// The session id rides the CHECKED parse, restored to its ORIGINAL
|
|
356
|
+
// form, re-serialized once — what was judged is what is forwarded.
|
|
357
|
+
searchParams.set(declared.sessionParam, parts.id)
|
|
358
|
+
const controller = new AbortController()
|
|
359
|
+
// The counting gate's failure has its OWN abort signal (PB-9-fix, the
|
|
360
|
+
// mirror of the relay route's bodyGateError): tripping the gate is a
|
|
361
|
+
// LOCAL verdict — it must tear the relay round-trip down AND be
|
|
362
|
+
// readable in the catch below without depending on the error MESSAGE
|
|
363
|
+
// surviving undici's fetch rejection. It also tells the relay client
|
|
364
|
+
// (through the aborted signal) that this is a caller abort, never a
|
|
365
|
+
// transport death.
|
|
366
|
+
let bodyGateFailed = false
|
|
367
|
+
const bodyAbort = new AbortController()
|
|
368
|
+
const forwardSignal = AbortSignal.any([controller.signal, bodyAbort.signal])
|
|
369
|
+
const onClientGone = (): void => {
|
|
370
|
+
if (!res.writableEnded) controller.abort()
|
|
371
|
+
}
|
|
372
|
+
res.on('close', onClientGone)
|
|
373
|
+
try {
|
|
374
|
+
const accept = typeof req.headers.accept === 'string' ? req.headers.accept : undefined
|
|
375
|
+
const range = typeof req.headers.range === 'string' ? req.headers.range : undefined
|
|
376
|
+
const requestContentType = typeof req.headers['content-type'] === 'string' ? req.headers['content-type'] : undefined
|
|
377
|
+
// PB-9: the request body streams through a counting Transform —
|
|
378
|
+
// node's pipe owns the backpressure, and the route's `maxBody` is
|
|
379
|
+
// enforced ON THE STREAM for a chunked body (no Content-Length for
|
|
380
|
+
// the pre-check above to read). Past the cap the gate errors, which
|
|
381
|
+
// aborts the relay round-trip mid-flight and answers the local 413
|
|
382
|
+
// below (the browser's headers are not out — nothing ever started).
|
|
383
|
+
let bodyStream: ReadableStream<Uint8Array> | null = null
|
|
384
|
+
if (hasBody) {
|
|
385
|
+
let pumped = 0
|
|
386
|
+
const gate = new Transform({
|
|
387
|
+
transform(chunk: Buffer, _encoding, callback) {
|
|
388
|
+
pumped += chunk.length
|
|
389
|
+
if (pumped > declared.maxBody) {
|
|
390
|
+
callback(new Error('zr-plugin-body-too-large'))
|
|
391
|
+
return
|
|
392
|
+
}
|
|
393
|
+
callback(null, chunk)
|
|
394
|
+
},
|
|
395
|
+
})
|
|
396
|
+
req.on('error', (error) => gate.destroy(error))
|
|
397
|
+
gate.on('error', () => {
|
|
398
|
+
if (!bodyGateFailed) bodyGateFailed = true
|
|
399
|
+
bodyAbort.abort()
|
|
400
|
+
})
|
|
401
|
+
// The fetch body must be a WEB ReadableStream (undici's contract);
|
|
402
|
+
// Readable.toWeb adapts the counting gate, preserving backpressure.
|
|
403
|
+
bodyStream = Readable.toWeb(gate) as ReadableStream<Uint8Array>
|
|
404
|
+
req.pipe(gate)
|
|
405
|
+
}
|
|
406
|
+
const response = await relay.pluginRequest(
|
|
407
|
+
{
|
|
408
|
+
plugin: declared.plugin,
|
|
409
|
+
version: declared.version,
|
|
410
|
+
digest: declared.digest,
|
|
411
|
+
path: declared.path,
|
|
412
|
+
query: searchParams.toString(),
|
|
413
|
+
method,
|
|
414
|
+
...(hasBody ? { body: bodyStream } : {}),
|
|
415
|
+
...(hasBody && requestContentType !== undefined ? { contentType: requestContentType } : {}),
|
|
416
|
+
...(hasBody && Number.isFinite(declaredLength) && declaredLength > 0 ? { contentLength: declaredLength } : {}),
|
|
417
|
+
...(accept !== undefined ? { accept } : {}),
|
|
418
|
+
...(range !== undefined ? { range } : {}),
|
|
419
|
+
},
|
|
420
|
+
forwardSignal,
|
|
421
|
+
)
|
|
422
|
+
// A relay-side refusal travels its OWN marker header (PB-3); the
|
|
423
|
+
// browser sees the uniform unbridged shape with that code as the
|
|
424
|
+
// reason. The relay's JSON body is disposed unread.
|
|
425
|
+
const refusal = response.headers.get('x-zen-remote-error')
|
|
426
|
+
if (refusal !== null) {
|
|
427
|
+
await response.body?.cancel().catch(() => {})
|
|
428
|
+
answerUnbridged(res, declared.plugin, refusal)
|
|
429
|
+
return
|
|
430
|
+
}
|
|
431
|
+
const contentType = response.headers.get('content-type')
|
|
432
|
+
const contentLength = response.headers.get('content-length')
|
|
433
|
+
const cacheControl = response.headers.get('cache-control')
|
|
434
|
+
res.writeHead(response.status, {
|
|
435
|
+
...(contentType !== null ? { 'content-type': contentType } : {}),
|
|
436
|
+
...(contentLength !== null ? { 'content-length': contentLength } : {}),
|
|
437
|
+
...(cacheControl !== null ? { 'cache-control': cacheControl } : {}),
|
|
438
|
+
})
|
|
439
|
+
// Stream the body through with real backpressure; a mid-stream
|
|
440
|
+
// failure with headers already out can only cut the response.
|
|
441
|
+
const reader = response.body?.getReader()
|
|
442
|
+
if (reader !== undefined) {
|
|
443
|
+
while (true) {
|
|
444
|
+
const { done, value } = await reader.read()
|
|
445
|
+
if (done) break
|
|
446
|
+
if (!res.write(value)) {
|
|
447
|
+
await new Promise<void>((resolve) => {
|
|
448
|
+
const settled = (): void => {
|
|
449
|
+
res.off('drain', settled)
|
|
450
|
+
res.off('close', settled)
|
|
451
|
+
resolve()
|
|
452
|
+
}
|
|
453
|
+
res.once('drain', settled)
|
|
454
|
+
res.once('close', settled)
|
|
455
|
+
})
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
res.end()
|
|
460
|
+
} catch (error) {
|
|
461
|
+
// The gate's local flag FIRST (PB-9-fix): the counting gate tripped
|
|
462
|
+
// mid-forward — the chunked body outgrew the route's `maxBody` while
|
|
463
|
+
// the relay round-trip was still being written. The browser's
|
|
464
|
+
// headers are not out, so the local 413 still fits — same shape the
|
|
465
|
+
// pre-check above answers. The flag is local on purpose: the gate's
|
|
466
|
+
// error cannot be relied on to survive undici's fetch rejection
|
|
467
|
+
// wrapper, and the relay client has (correctly) NOT judged the link
|
|
468
|
+
// offline for a caller abort — answering from the wire's error would
|
|
469
|
+
// misreport this as 503 offline.
|
|
470
|
+
if (bodyGateFailed && !res.headersSent) {
|
|
471
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
472
|
+
res.writeHead(413)
|
|
473
|
+
res.end(JSON.stringify({ ok: false, error: { code: 'zr-plugin-body-too-large' } }))
|
|
474
|
+
recordRefusal(declared.plugin, 'body-too-large')
|
|
475
|
+
return
|
|
476
|
+
}
|
|
477
|
+
if (controller.signal.aborted || res.destroyed || res.headersSent) {
|
|
478
|
+
// The browser is gone (its abort tore the relay round-trip down)
|
|
479
|
+
// or the body already started: the status cannot change, so the
|
|
480
|
+
// response is cut.
|
|
481
|
+
try {
|
|
482
|
+
res.destroy()
|
|
483
|
+
} catch {
|
|
484
|
+
// Already gone.
|
|
485
|
+
}
|
|
486
|
+
return
|
|
487
|
+
}
|
|
488
|
+
const code = typeof (error as { code?: unknown } | null)?.code === 'string' ? (error as { code: string }).code : 'offline'
|
|
489
|
+
answerUnbridged(res, declared.plugin, code === 'aborted' ? 'offline' : code)
|
|
490
|
+
} finally {
|
|
491
|
+
res.off('close', onClientGone)
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// ---- PB-11: the adapter interception ---------------------------------------
|
|
496
|
+
//
|
|
497
|
+
// An adapter (src/adapters/, validated into a NormalizedAdapter by the
|
|
498
|
+
// wiring) claims routes of a NON-cooperative plugin: the match lookup
|
|
499
|
+
// below hands one of those requests to a surrogate that locates the
|
|
500
|
+
// session ids (query / path segment / JSON body), classifies the request
|
|
501
|
+
// — every id virtual → forward, none → local passthrough, a mix → the
|
|
502
|
+
// uniform 503 (docs/adr/0001: a remote session never reaches a local
|
|
503
|
+
// plugin) — rewrites the ids to their real halves and forwards through
|
|
504
|
+
// the SAME relay door the cooperative surrogate uses. On the way back a
|
|
505
|
+
// `client.response` hook maps real ids to virtual form inside a buffered
|
|
506
|
+
// JSON answer (8 MB cap; past it the answer passes through untouched).
|
|
507
|
+
|
|
508
|
+
/** The adapter route this pathname hits, if any: only an adapter that
|
|
509
|
+
* STILL owns its plugin's registry entry qualifies — a plugin's own
|
|
510
|
+
* cooperative declaration always wins, and the adapter yields silently
|
|
511
|
+
* here (the registration-time yield logged a line already). */
|
|
512
|
+
const adapterRouteOf = (pathname: string): { adapter: NormalizedAdapter; route: NormalizedAdapterRoute } | undefined => {
|
|
513
|
+
if (!installed || adapters.length === 0) return undefined
|
|
514
|
+
for (const adapter of adapters) {
|
|
515
|
+
if (!adapterOwnsRegistryEntry(adapter, registry.get(adapter.plugin))) continue
|
|
516
|
+
for (const route of adapter.routes) {
|
|
517
|
+
if (route.local) continue
|
|
518
|
+
if (adapterPathMatches(route, pathname)) return { adapter, route }
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
return undefined
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** The adapter surrogate: locate → classify → walls → rewrite → hooks →
|
|
525
|
+
* the relay round-trip, then the buffered JSON rewrite on the way back. */
|
|
526
|
+
const adapterSurrogateHandler = (hit: { adapter: NormalizedAdapter; route: NormalizedAdapterRoute }, originalHandler: ((req: IncomingMessage, res: ServerResponse) => Promise<void>) | undefined) =>
|
|
527
|
+
async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
528
|
+
const { adapter, route } = hit
|
|
529
|
+
const plugin = adapter.plugin
|
|
530
|
+
// The local passthrough. A body-locating route has ALREADY consumed
|
|
531
|
+
// the request body by the time this runs, so the replay hands the
|
|
532
|
+
// buffered bytes back behind the original request's metadata — the
|
|
533
|
+
// shape a local handler actually reads (method/url/headers/pipe).
|
|
534
|
+
const passthrough = async (replayBody?: Buffer): Promise<void> => {
|
|
535
|
+
if (originalHandler === undefined) {
|
|
536
|
+
res.writeHead(404)
|
|
537
|
+
res.end()
|
|
538
|
+
return
|
|
539
|
+
}
|
|
540
|
+
if (replayBody !== undefined) {
|
|
541
|
+
const replay = new PassThrough()
|
|
542
|
+
replay.end(replayBody)
|
|
543
|
+
Object.assign(replay, { method: req.method, url: req.url, headers: req.headers })
|
|
544
|
+
await originalHandler(replay as unknown as IncomingMessage, res)
|
|
545
|
+
return
|
|
546
|
+
}
|
|
547
|
+
await originalHandler(req, res)
|
|
548
|
+
}
|
|
549
|
+
// Chained-over: inert passthrough, exactly the posture of the other
|
|
550
|
+
// intercepts.
|
|
551
|
+
if (!installed) {
|
|
552
|
+
await passthrough()
|
|
553
|
+
return
|
|
554
|
+
}
|
|
555
|
+
let controller: AbortController | undefined
|
|
556
|
+
const onClientGone = (): void => {
|
|
557
|
+
if (controller !== undefined && !res.writableEnded) controller.abort()
|
|
558
|
+
}
|
|
559
|
+
res.on('close', onClientGone)
|
|
560
|
+
try {
|
|
561
|
+
let url: URL
|
|
562
|
+
try {
|
|
563
|
+
url = new URL(req.url ?? '/', 'http://local')
|
|
564
|
+
} catch {
|
|
565
|
+
answerUnbridged(res, plugin, 'bad-query')
|
|
566
|
+
return
|
|
567
|
+
}
|
|
568
|
+
const pathname = url.pathname
|
|
569
|
+
const searchParams = url.searchParams
|
|
570
|
+
const method = req.method ?? 'GET'
|
|
571
|
+
const hasBody = method !== 'GET' && method !== 'HEAD'
|
|
572
|
+
|
|
573
|
+
// The route's deny hook runs BEFORE any id locating: a request the
|
|
574
|
+
// adapter refuses on path grounds (better-sidebar's open.external
|
|
575
|
+
// from a remote panel, the side-chat methods) answers here — these
|
|
576
|
+
// requests often carry NO session field, so the classify below
|
|
577
|
+
// would otherwise read them as local and let them through.
|
|
578
|
+
if (route.deny !== undefined) {
|
|
579
|
+
const denial = route.deny({ pathname, method, currentSessionId: getCurrentSession?.() })
|
|
580
|
+
if (denial !== undefined) {
|
|
581
|
+
answerUnbridged(res, plugin, denial)
|
|
582
|
+
return
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// A body-locating route buffers the request body up front (bounded
|
|
587
|
+
// by the route's own maxBody — the cooperative 413 shape, answered
|
|
588
|
+
// locally, the plugin never reached). Any other locator's body
|
|
589
|
+
// streams through the cooperative counting gate below.
|
|
590
|
+
let bufferedBody: Buffer | undefined
|
|
591
|
+
if (hasBody && route.locator.in === 'json-body') {
|
|
592
|
+
const chunks: Buffer[] = []
|
|
593
|
+
let size = 0
|
|
594
|
+
let over = false
|
|
595
|
+
for await (const chunk of req) {
|
|
596
|
+
size += (chunk as Buffer).length
|
|
597
|
+
if (size > route.maxBody) {
|
|
598
|
+
over = true
|
|
599
|
+
break
|
|
600
|
+
}
|
|
601
|
+
chunks.push(Buffer.from(chunk as Uint8Array))
|
|
602
|
+
}
|
|
603
|
+
if (over) {
|
|
604
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
605
|
+
res.writeHead(413)
|
|
606
|
+
res.end(JSON.stringify({ ok: false, error: { code: 'zr-plugin-body-too-large' } }))
|
|
607
|
+
recordRefusal(plugin, 'body-too-large')
|
|
608
|
+
return
|
|
609
|
+
}
|
|
610
|
+
bufferedBody = Buffer.concat(chunks)
|
|
611
|
+
}
|
|
612
|
+
// A present body must be a JSON object when the route locates in
|
|
613
|
+
// the body: a remote request with a corrupted body refuses rather
|
|
614
|
+
// than falling through to the local plugin.
|
|
615
|
+
let parsedBody: unknown
|
|
616
|
+
let bodyGiven = false
|
|
617
|
+
if (hasBody && route.locator.in === 'json-body' && bufferedBody !== undefined && bufferedBody.length > 0) {
|
|
618
|
+
try {
|
|
619
|
+
parsedBody = JSON.parse(bufferedBody.toString('utf8'))
|
|
620
|
+
bodyGiven = true
|
|
621
|
+
} catch {
|
|
622
|
+
answerUnbridged(res, plugin, 'bad-query')
|
|
623
|
+
return
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// The remote/local verdict on the ids as they arrived. A duplicated
|
|
628
|
+
// query id refuses rather than silently collapsing (PB-12 review).
|
|
629
|
+
let verdict: AdapterRemoteVerdict
|
|
630
|
+
try {
|
|
631
|
+
verdict = classifyAdapterRequest(route, pathname, searchParams, bodyGiven ? parsedBody : undefined)
|
|
632
|
+
} catch (error) {
|
|
633
|
+
if (error instanceof AdapterMultiValueQueryError) {
|
|
634
|
+
answerUnbridged(res, plugin, 'bad-query')
|
|
635
|
+
return
|
|
636
|
+
}
|
|
637
|
+
throw error
|
|
638
|
+
}
|
|
639
|
+
if (verdict.kind === 'local') {
|
|
640
|
+
await passthrough(bufferedBody)
|
|
641
|
+
return
|
|
642
|
+
}
|
|
643
|
+
if (verdict.kind === 'mixed') {
|
|
644
|
+
answerUnbridged(res, plugin, 'bad-query')
|
|
645
|
+
return
|
|
646
|
+
}
|
|
647
|
+
// Remote: the route's method wall, then per-id server walls.
|
|
648
|
+
if (!route.methods.includes(method)) {
|
|
649
|
+
answerUnbridged(res, plugin, 'method-not-bridged')
|
|
650
|
+
return
|
|
651
|
+
}
|
|
652
|
+
const serverId = getServerId()
|
|
653
|
+
for (const idHit of verdict.hits) {
|
|
654
|
+
const parts = fromVirtual(idHit.value)
|
|
655
|
+
if (parts === undefined || parts.id === '') {
|
|
656
|
+
answerUnbridged(res, plugin, 'bad-query')
|
|
657
|
+
return
|
|
658
|
+
}
|
|
659
|
+
if (parts.serverId !== serverId) {
|
|
660
|
+
answerUnbridged(res, plugin, 'server-changed')
|
|
661
|
+
return
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
// The bridge verdicts (version skew, consent, offline…) judged from
|
|
665
|
+
// the SAME live table the cooperative surrogate reads — the
|
|
666
|
+
// adapter's registry entry makes the plugin indistinguishable from
|
|
667
|
+
// a cooperative one here.
|
|
668
|
+
const status = getStatus().find((entry) => entry.plugin === plugin)
|
|
669
|
+
if (status === undefined || !status.bridged) {
|
|
670
|
+
answerUnbridged(res, plugin, status?.reason ?? 'not-on-server')
|
|
671
|
+
return
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Real ids in, coordinates rewritten.
|
|
675
|
+
const rewrite = rewriteAdapterRequest(route, pathname, searchParams, bodyGiven ? parsedBody : undefined, verdict.hits, (virtualId) => fromVirtual(virtualId)!.id)
|
|
676
|
+
// The client hook: refuse, or replace the parsed body wholesale.
|
|
677
|
+
// The hook sees the REWRITTEN body — the ids are already real (the
|
|
678
|
+
// design's order: locate → rewrite → hook → forward).
|
|
679
|
+
const hook = adapter.hooks.client?.request
|
|
680
|
+
if (hook !== undefined) {
|
|
681
|
+
const hookedBody = bodyGiven && rewrite.bodyText !== undefined ? JSON.parse(rewrite.bodyText) : bodyGiven ? parsedBody : undefined
|
|
682
|
+
let hookVerdict: AdapterVerdict
|
|
683
|
+
try {
|
|
684
|
+
hookVerdict = hook({ method, pathname, route, query: rewrite.query, body: hookedBody })
|
|
685
|
+
} catch {
|
|
686
|
+
answerUnbridged(res, plugin, 'internal')
|
|
687
|
+
return
|
|
688
|
+
}
|
|
689
|
+
if (!hookVerdict.allow) {
|
|
690
|
+
answerUnbridged(res, plugin, hookVerdict.reason)
|
|
691
|
+
return
|
|
692
|
+
}
|
|
693
|
+
if (hookedBody !== undefined && hookVerdict.body !== undefined) {
|
|
694
|
+
rewrite.bodyText = JSON.stringify(hookVerdict.body)
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
controller = new AbortController()
|
|
699
|
+
adapterForwarded += 1
|
|
700
|
+
// The forward. A body-locating route sends its (possibly rewritten)
|
|
701
|
+
// buffer; any other body rides the cooperative counting gate.
|
|
702
|
+
const accept = typeof req.headers.accept === 'string' ? req.headers.accept : undefined
|
|
703
|
+
const range = typeof req.headers.range === 'string' ? req.headers.range : undefined
|
|
704
|
+
const requestContentType = typeof req.headers['content-type'] === 'string' ? req.headers['content-type'] : undefined
|
|
705
|
+
let bodyStream: ReadableStream<Uint8Array> | null = null
|
|
706
|
+
let contentLength: number | undefined
|
|
707
|
+
if (hasBody && route.locator.in === 'json-body') {
|
|
708
|
+
const payload = rewrite.bodyText !== undefined ? Buffer.from(rewrite.bodyText, 'utf8') : bufferedBody ?? Buffer.alloc(0)
|
|
709
|
+
contentLength = payload.length
|
|
710
|
+
bodyStream = Readable.toWeb(Readable.from(payload)) as ReadableStream<Uint8Array>
|
|
711
|
+
} else if (hasBody) {
|
|
712
|
+
let pumped = 0
|
|
713
|
+
const gate = new Transform({
|
|
714
|
+
transform(chunk: Buffer, _encoding, callback) {
|
|
715
|
+
pumped += chunk.length
|
|
716
|
+
if (pumped > route.maxBody) {
|
|
717
|
+
callback(new Error('zr-plugin-body-too-large'))
|
|
718
|
+
return
|
|
719
|
+
}
|
|
720
|
+
callback(null, chunk)
|
|
721
|
+
},
|
|
722
|
+
})
|
|
723
|
+
gate.on('error', () => controller?.abort())
|
|
724
|
+
bodyStream = Readable.toWeb(gate) as ReadableStream<Uint8Array>
|
|
725
|
+
req.pipe(gate)
|
|
726
|
+
}
|
|
727
|
+
const response = await relay.pluginRequest(
|
|
728
|
+
{
|
|
729
|
+
plugin,
|
|
730
|
+
version: adapter.declaration.version,
|
|
731
|
+
digest: adapter.digest,
|
|
732
|
+
path: rewrite.pathname,
|
|
733
|
+
query: rewrite.query.toString(),
|
|
734
|
+
method,
|
|
735
|
+
...(bodyStream !== null ? { body: bodyStream } : {}),
|
|
736
|
+
...(bodyStream !== null && requestContentType !== undefined ? { contentType: requestContentType } : {}),
|
|
737
|
+
...(bodyStream !== null && contentLength !== undefined ? { contentLength } : {}),
|
|
738
|
+
...(accept !== undefined ? { accept } : {}),
|
|
739
|
+
...(range !== undefined ? { range } : {}),
|
|
740
|
+
},
|
|
741
|
+
controller.signal,
|
|
742
|
+
)
|
|
743
|
+
const refusal = response.headers.get('x-zen-remote-error')
|
|
744
|
+
if (refusal !== null) {
|
|
745
|
+
await response.body?.cancel().catch(() => {})
|
|
746
|
+
answerUnbridged(res, plugin, refusal)
|
|
747
|
+
return
|
|
748
|
+
}
|
|
749
|
+
const contentType = response.headers.get('content-type')
|
|
750
|
+
const responseLength = response.headers.get('content-length')
|
|
751
|
+
const cacheControl = response.headers.get('cache-control')
|
|
752
|
+
// The adapter header allowlist (PB-12): beyond the three cooperative
|
|
753
|
+
// headers, the answers adapters carry may need their own — the HTML
|
|
754
|
+
// preview's CSP sandbox, download dispositions, range headers. The
|
|
755
|
+
// plugin wrote them; the browser needs them.
|
|
756
|
+
const allowlistedHeaders = ADAPTER_RESPONSE_HEADER_ALLOWLIST.flatMap((name) => {
|
|
757
|
+
const value = response.headers.get(name)
|
|
758
|
+
return value !== null ? [{ [name]: value }] : []
|
|
759
|
+
})
|
|
760
|
+
const responseHook = adapter.hooks.client?.response
|
|
761
|
+
const rewriteCandidate = responseHook !== undefined && !route.stream && contentType !== null && contentType.includes('application/json')
|
|
762
|
+
if (!rewriteCandidate) {
|
|
763
|
+
// The verbatim pump: chunk-for-chunk with real backpressure, the
|
|
764
|
+
// cooperative surrogate's discipline.
|
|
765
|
+
res.writeHead(response.status, {
|
|
766
|
+
...(contentType !== null ? { 'content-type': contentType } : {}),
|
|
767
|
+
...(responseLength !== null ? { 'content-length': responseLength } : {}),
|
|
768
|
+
...(cacheControl !== null ? { 'cache-control': cacheControl } : {}),
|
|
769
|
+
...Object.assign({}, ...allowlistedHeaders),
|
|
770
|
+
})
|
|
771
|
+
const reader = response.body?.getReader()
|
|
772
|
+
if (reader !== undefined) {
|
|
773
|
+
while (true) {
|
|
774
|
+
const { done, value } = await reader.read()
|
|
775
|
+
if (done) break
|
|
776
|
+
if (!res.write(value)) {
|
|
777
|
+
await new Promise<void>((resolve) => {
|
|
778
|
+
const settled = (): void => {
|
|
779
|
+
res.off('drain', settled)
|
|
780
|
+
res.off('close', settled)
|
|
781
|
+
resolve()
|
|
782
|
+
}
|
|
783
|
+
res.once('drain', settled)
|
|
784
|
+
res.once('close', settled)
|
|
785
|
+
})
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
res.end()
|
|
790
|
+
return
|
|
791
|
+
}
|
|
792
|
+
// The buffered rewrite: the whole answer (≤ 8 MB), parsed, hooked,
|
|
793
|
+
// re-serialized with a corrected content-length. Past the cap — or
|
|
794
|
+
// on a parse/hook failure — the answer passes through untouched.
|
|
795
|
+
const reader = response.body!.getReader()
|
|
796
|
+
const chunks: Buffer[] = []
|
|
797
|
+
let size = 0
|
|
798
|
+
let overflow = false
|
|
799
|
+
while (true) {
|
|
800
|
+
const { done, value } = await reader.read()
|
|
801
|
+
if (done) break
|
|
802
|
+
size += value.length
|
|
803
|
+
// The over-cap chunk ITSELF is body content — it lands in the
|
|
804
|
+
// passthrough buffer too; only the REWRITE is cancelled.
|
|
805
|
+
chunks.push(Buffer.from(value))
|
|
806
|
+
if (size > MAX_ADAPTER_RESPONSE_BUFFER_BYTES) {
|
|
807
|
+
overflow = true
|
|
808
|
+
break
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
if (overflow) {
|
|
812
|
+
adapterResponsePassthroughs += 1
|
|
813
|
+
res.writeHead(response.status, {
|
|
814
|
+
...(contentType !== null ? { 'content-type': contentType } : {}),
|
|
815
|
+
...(responseLength !== null ? { 'content-length': responseLength } : {}),
|
|
816
|
+
...(cacheControl !== null ? { 'cache-control': cacheControl } : {}),
|
|
817
|
+
...Object.assign({}, ...allowlistedHeaders),
|
|
818
|
+
})
|
|
819
|
+
for (const chunk of chunks) {
|
|
820
|
+
if (!res.write(chunk)) {
|
|
821
|
+
await new Promise<void>((resolve) => {
|
|
822
|
+
const settled = (): void => {
|
|
823
|
+
res.off('drain', settled)
|
|
824
|
+
res.off('close', settled)
|
|
825
|
+
resolve()
|
|
826
|
+
}
|
|
827
|
+
res.once('drain', settled)
|
|
828
|
+
res.once('close', settled)
|
|
829
|
+
})
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
while (true) {
|
|
833
|
+
const { done, value } = await reader.read()
|
|
834
|
+
if (done) break
|
|
835
|
+
if (!res.write(value)) {
|
|
836
|
+
await new Promise<void>((resolve) => {
|
|
837
|
+
const settled = (): void => {
|
|
838
|
+
res.off('drain', settled)
|
|
839
|
+
res.off('close', settled)
|
|
840
|
+
resolve()
|
|
841
|
+
}
|
|
842
|
+
res.once('drain', settled)
|
|
843
|
+
res.once('close', settled)
|
|
844
|
+
})
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
res.end()
|
|
848
|
+
return
|
|
849
|
+
}
|
|
850
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
851
|
+
let rewrittenBody: unknown
|
|
852
|
+
try {
|
|
853
|
+
rewrittenBody = responseHook({
|
|
854
|
+
status: response.status,
|
|
855
|
+
pathname,
|
|
856
|
+
body: JSON.parse(text),
|
|
857
|
+
virtualize: (id: string) => toVirtual(serverId ?? '', id),
|
|
858
|
+
isVirtual: (value: unknown) => isVirtual(value),
|
|
859
|
+
})
|
|
860
|
+
} catch {
|
|
861
|
+
adapterResponsePassthroughs += 1
|
|
862
|
+
const passthroughBody = Buffer.from(text, 'utf8')
|
|
863
|
+
res.writeHead(response.status, {
|
|
864
|
+
...(contentType !== null ? { 'content-type': contentType } : {}),
|
|
865
|
+
...(cacheControl !== null ? { 'cache-control': cacheControl } : {}),
|
|
866
|
+
...Object.assign({}, ...allowlistedHeaders),
|
|
867
|
+
'content-length': String(passthroughBody.length),
|
|
868
|
+
})
|
|
869
|
+
res.end(passthroughBody)
|
|
870
|
+
return
|
|
871
|
+
}
|
|
872
|
+
adapterResponseRewrites += 1
|
|
873
|
+
const outBody = Buffer.from(JSON.stringify(rewrittenBody), 'utf8')
|
|
874
|
+
res.writeHead(response.status, {
|
|
875
|
+
...(contentType !== null ? { 'content-type': contentType } : {}),
|
|
876
|
+
...(cacheControl !== null ? { 'cache-control': cacheControl } : {}),
|
|
877
|
+
...Object.assign({}, ...allowlistedHeaders),
|
|
878
|
+
'content-length': String(outBody.length),
|
|
879
|
+
})
|
|
880
|
+
res.end(outBody)
|
|
881
|
+
} catch (error) {
|
|
882
|
+
if (controller?.signal.aborted || res.destroyed || res.headersSent) {
|
|
883
|
+
try {
|
|
884
|
+
res.destroy()
|
|
885
|
+
} catch {
|
|
886
|
+
// Already gone.
|
|
887
|
+
}
|
|
888
|
+
return
|
|
889
|
+
}
|
|
890
|
+
const code = typeof (error as { code?: unknown } | null)?.code === 'string' ? (error as { code: string }).code : 'offline'
|
|
891
|
+
answerUnbridged(res, plugin, code === 'aborted' ? 'offline' : code)
|
|
892
|
+
} finally {
|
|
893
|
+
res.off('close', onClientGone)
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/** The wrapped match: original verdict first, then the adapter routes
|
|
898
|
+
* (PB-11 — BEFORE the declaration lookup: the adapter's registry entry
|
|
899
|
+
* IS a declaration, and the cooperative surrogate's query-sessionParam
|
|
900
|
+
* logic is not how an adapter request is judged), then the exact
|
|
901
|
+
* declaration lookup — an undeclared pathname returns whatever the
|
|
902
|
+
* original match answered, untouched. */
|
|
903
|
+
const wrappedMatch = function match(this: unknown, pathname: string): unknown {
|
|
904
|
+
const route = savedMatch.call(this, pathname)
|
|
905
|
+
if (!installed) return route
|
|
906
|
+
if (typeof pathname !== 'string' || !pathname.startsWith('/')) return route
|
|
907
|
+
const adapterHit = adapterRouteOf(pathname)
|
|
908
|
+
if (adapterHit !== undefined) {
|
|
909
|
+
return {
|
|
910
|
+
kind: 'exact',
|
|
911
|
+
path: pathname,
|
|
912
|
+
handler: adapterSurrogateHandler(adapterHit, handlerOf(route)),
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
const declared = declaredRouteOf(pathname)
|
|
916
|
+
if (declared !== undefined) {
|
|
917
|
+
return {
|
|
918
|
+
kind: 'exact',
|
|
919
|
+
path: pathname,
|
|
920
|
+
handler: surrogateHandler(declared, handlerOf(route)),
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
return route
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// ---- PB-10: the WebSocket upgrade interception -----------------------------
|
|
927
|
+
//
|
|
928
|
+
// The host's upgrade handler reads `this.upgrades.get(pathname)` (RT
|
|
929
|
+
// dsh-host-webserver — the probe verified the shape on real instances).
|
|
930
|
+
// Shadowing that ONE method on the raw instance intercepts every upgrade
|
|
931
|
+
// lookup, including upgrades registered after us. Declared `upgrade`
|
|
932
|
+
// routes with a virtual session id become a byte tunnel to the server:
|
|
933
|
+
// the browser's WebSocket speaks to THIS process, the bytes travel the
|
|
934
|
+
// tunnel, and the REAL handshake (101, frames, close) is produced by the
|
|
935
|
+
// plugin route on the server machine — neither end parses WebSocket
|
|
936
|
+
// frames (docs/adr/0002).
|
|
937
|
+
|
|
938
|
+
const targetWithUpgrades = webServer as { upgrades: Map<string, unknown> }
|
|
939
|
+
const savedUpgradesGet = targetWithUpgrades.upgrades.get
|
|
940
|
+
const upgradeRoutesOf = (pathname: string): { plugin: string; version: string; digest: string; path: string; sessionParam: string } | undefined => {
|
|
941
|
+
if (!installed) return undefined
|
|
942
|
+
for (const declaration of registry.list()) {
|
|
943
|
+
for (const route of declaration.routes) {
|
|
944
|
+
if (route.path === pathname && routeUpgradesOf(route)) {
|
|
945
|
+
return {
|
|
946
|
+
plugin: declaration.plugin,
|
|
947
|
+
version: declaration.version,
|
|
948
|
+
digest: declaration.digest,
|
|
949
|
+
path: route.path,
|
|
950
|
+
sessionParam: route.sessionParam,
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
return undefined
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/** The tunnel's surrogate upgrade handler. Every outcome — refusal,
|
|
959
|
+
* teardown, the byte pumping — owns the socket's whole remaining life. */
|
|
960
|
+
const surrogateUpgrade = (declared: { plugin: string; version: string; digest: string; path: string; sessionParam: string }, originalHandler: unknown) =>
|
|
961
|
+
(req: IncomingMessage, socket: Socket, head: Buffer): void => {
|
|
962
|
+
// The host never awaits an upgrade handler — the async body runs on
|
|
963
|
+
// its own and every failure path owns the socket's teardown.
|
|
964
|
+
void (async () => {
|
|
965
|
+
log?.('upgrade entered for %s', declared.path)
|
|
966
|
+
const socketGone = (): boolean => socket.destroyed || !socket.writable
|
|
967
|
+
const passthrough = (): void => {
|
|
968
|
+
if (typeof originalHandler === 'function') {
|
|
969
|
+
originalHandler(req, socket, head)
|
|
970
|
+
return
|
|
971
|
+
}
|
|
972
|
+
try {
|
|
973
|
+
socket.destroy()
|
|
974
|
+
} catch {
|
|
975
|
+
// Already gone.
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
try {
|
|
979
|
+
// ONE parse of the query — the session parameter's home.
|
|
980
|
+
let searchParams: URLSearchParams
|
|
981
|
+
try {
|
|
982
|
+
searchParams = new URL(req.url ?? '/', 'http://local').searchParams
|
|
983
|
+
} catch {
|
|
984
|
+
answerUnbridgedSocket(socket, declared.plugin, 'bad-query')
|
|
985
|
+
return
|
|
986
|
+
}
|
|
987
|
+
const values = searchParams.getAll(declared.sessionParam)
|
|
988
|
+
if (values.length > 1) {
|
|
989
|
+
answerUnbridgedSocket(socket, declared.plugin, 'bad-query')
|
|
990
|
+
return
|
|
991
|
+
}
|
|
992
|
+
const value = values.length === 1 ? values[0] : undefined
|
|
993
|
+
// A LOCAL session (or no parameter at all): the original upgrade
|
|
994
|
+
// handler's business, the head bytes untouched.
|
|
995
|
+
if (value === undefined || value === '' || !value.startsWith('zr~')) {
|
|
996
|
+
passthrough()
|
|
997
|
+
return
|
|
998
|
+
}
|
|
999
|
+
const parts = fromVirtual(value)
|
|
1000
|
+
if (parts === undefined || parts.id === '') {
|
|
1001
|
+
answerUnbridgedSocket(socket, declared.plugin, 'bad-query')
|
|
1002
|
+
return
|
|
1003
|
+
}
|
|
1004
|
+
if (parts.serverId !== getServerId()) {
|
|
1005
|
+
answerUnbridgedSocket(socket, declared.plugin, 'server-changed')
|
|
1006
|
+
return
|
|
1007
|
+
}
|
|
1008
|
+
// The PB-2 bridge verdict, the SAME wall the HTTP surrogate raises:
|
|
1009
|
+
// the tunnel opens only for a plugin bridged RIGHT NOW — an absent
|
|
1010
|
+
// or unbridged status refuses with its own reason, and the local
|
|
1011
|
+
// plugin handler is never reached.
|
|
1012
|
+
const bridgeStatus = getStatus().find((entry) => entry.plugin === declared.plugin)
|
|
1013
|
+
if (bridgeStatus === undefined || !bridgeStatus.bridged) {
|
|
1014
|
+
answerUnbridgedSocket(socket, declared.plugin, bridgeStatus?.reason ?? 'not-on-server')
|
|
1015
|
+
return
|
|
1016
|
+
}
|
|
1017
|
+
// ---- bridged: open the byte tunnel (PB-10) ---------------------
|
|
1018
|
+
const controller = new AbortController()
|
|
1019
|
+
socket.on('close', () => controller.abort())
|
|
1020
|
+
const wsKey = typeof req.headers['sec-websocket-key'] === 'string' ? req.headers['sec-websocket-key'] : undefined
|
|
1021
|
+
const wsVersion = typeof req.headers['sec-websocket-version'] === 'string' ? req.headers['sec-websocket-version'] : undefined
|
|
1022
|
+
if (wsKey === undefined || wsVersion === undefined) {
|
|
1023
|
+
answerUnbridgedSocket(socket, declared.plugin, 'bad-query')
|
|
1024
|
+
return
|
|
1025
|
+
}
|
|
1026
|
+
const protocol = typeof req.headers['sec-websocket-protocol'] === 'string' ? req.headers['sec-websocket-protocol'] : undefined
|
|
1027
|
+
const extensions = typeof req.headers['sec-websocket-extensions'] === 'string' ? req.headers['sec-websocket-extensions'] : undefined
|
|
1028
|
+
const query = new URLSearchParams(searchParams)
|
|
1029
|
+
query.set(declared.sessionParam, parts.id)
|
|
1030
|
+
let tunnel: { tunnel: string; frames: AsyncIterable<Uint8Array> }
|
|
1031
|
+
try {
|
|
1032
|
+
log?.('opening tunnel for %s', declared.path)
|
|
1033
|
+
tunnel = await relay.pluginTunnelOpen(
|
|
1034
|
+
{
|
|
1035
|
+
plugin: declared.plugin,
|
|
1036
|
+
version: declared.version,
|
|
1037
|
+
digest: declared.digest,
|
|
1038
|
+
path: declared.path,
|
|
1039
|
+
query: query.toString(),
|
|
1040
|
+
wsHeaders: {
|
|
1041
|
+
'sec-websocket-key': wsKey,
|
|
1042
|
+
'sec-websocket-version': wsVersion,
|
|
1043
|
+
...(protocol !== undefined ? { 'sec-websocket-protocol': protocol } : {}),
|
|
1044
|
+
...(extensions !== undefined ? { 'sec-websocket-extensions': extensions } : {}),
|
|
1045
|
+
},
|
|
1046
|
+
...(head.length > 0 ? { head: head.toString('base64') } : {}),
|
|
1047
|
+
},
|
|
1048
|
+
controller.signal,
|
|
1049
|
+
)
|
|
1050
|
+
} catch (error) {
|
|
1051
|
+
// The relay refused the open (unbridged walls arrive with their
|
|
1052
|
+
// code in the error). The browser's handshake never completes.
|
|
1053
|
+
const rawCode = (error as { code?: unknown } | null)?.code
|
|
1054
|
+
const code = typeof rawCode === 'string' && rawCode !== '' ? rawCode : 'offline'
|
|
1055
|
+
answerUnbridgedSocket(socket, declared.plugin, code)
|
|
1056
|
+
return
|
|
1057
|
+
}
|
|
1058
|
+
tunnelsCurrent += 1
|
|
1059
|
+
tunnelsOpened += 1
|
|
1060
|
+
log?.('tunnel open for %s: %s', declared.path, tunnel.tunnel)
|
|
1061
|
+
// ---- downstream: tunnel frames → the browser socket -------------
|
|
1062
|
+
void (async () => {
|
|
1063
|
+
try {
|
|
1064
|
+
for await (const chunk of tunnel.frames) {
|
|
1065
|
+
if (socketGone()) break
|
|
1066
|
+
if (!socket.write(chunk)) {
|
|
1067
|
+
// Real backpressure: the NEXT frame is not pulled until the
|
|
1068
|
+
// browser socket drained — the tunnel's server half pauses
|
|
1069
|
+
// its upstream read in turn (the same discipline the NDJSON
|
|
1070
|
+
// streams run).
|
|
1071
|
+
await new Promise<void>((resolve) => {
|
|
1072
|
+
const settled = (): void => {
|
|
1073
|
+
socket.off('drain', settled)
|
|
1074
|
+
socket.off('close', settled)
|
|
1075
|
+
resolve()
|
|
1076
|
+
}
|
|
1077
|
+
socket.once('drain', settled)
|
|
1078
|
+
socket.once('close', settled)
|
|
1079
|
+
})
|
|
1080
|
+
if (socketGone()) break
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
} catch {
|
|
1084
|
+
// The tunnel died (transport or server-side failure) — the
|
|
1085
|
+
// browser socket follows, below.
|
|
1086
|
+
}
|
|
1087
|
+
// Upstream is over: the browser's WebSocket ends here. A WebSocket
|
|
1088
|
+
// close frame cannot be synthesized (this is a byte tunnel — the
|
|
1089
|
+
// upstream's own close bytes, when the plugin sent any, already
|
|
1090
|
+
// travelled as data), so the socket is cut the honest way.
|
|
1091
|
+
if (!socketGone()) socket.destroy()
|
|
1092
|
+
})()
|
|
1093
|
+
// ---- upstream: browser bytes → ordered sends --------------------
|
|
1094
|
+
let sendSeq = 0
|
|
1095
|
+
let upstreamBroken = false
|
|
1096
|
+
let pending = Promise.resolve()
|
|
1097
|
+
socket.on('data', (chunk: Buffer) => {
|
|
1098
|
+
let offset = 0
|
|
1099
|
+
while (offset < chunk.length) {
|
|
1100
|
+
const part = chunk.subarray(offset, Math.min(offset + 65536, chunk.length))
|
|
1101
|
+
offset += part.length
|
|
1102
|
+
const bytes = new Uint8Array(part)
|
|
1103
|
+
const seq = sendSeq
|
|
1104
|
+
sendSeq += 1
|
|
1105
|
+
// ONE in-flight send at a time: each chunk chains behind the
|
|
1106
|
+
// previous answer, which is what keeps the server-side sequence
|
|
1107
|
+
// gate satisfied and the byte order intact.
|
|
1108
|
+
pending = pending.then(async () => {
|
|
1109
|
+
if (upstreamBroken) return
|
|
1110
|
+
try {
|
|
1111
|
+
await relay.pluginTunnelSend(tunnel.tunnel, seq, bytes, controller.signal)
|
|
1112
|
+
} catch {
|
|
1113
|
+
upstreamBroken = true
|
|
1114
|
+
}
|
|
1115
|
+
})
|
|
1116
|
+
}
|
|
1117
|
+
})
|
|
1118
|
+
const teardownTunnel = (): void => {
|
|
1119
|
+
upstreamBroken = true
|
|
1120
|
+
tunnelsCurrent -= 1
|
|
1121
|
+
tunnelsClosed += 1
|
|
1122
|
+
void relay.pluginTunnelClose(tunnel.tunnel).catch(() => {})
|
|
1123
|
+
}
|
|
1124
|
+
socket.on('close', teardownTunnel)
|
|
1125
|
+
socket.on('error', teardownTunnel)
|
|
1126
|
+
} catch {
|
|
1127
|
+
// Any unexpected failure in the bridged path: the socket goes.
|
|
1128
|
+
try {
|
|
1129
|
+
socket.destroy()
|
|
1130
|
+
} catch {
|
|
1131
|
+
// Already gone.
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
})().catch(() => {
|
|
1135
|
+
try {
|
|
1136
|
+
socket.destroy()
|
|
1137
|
+
} catch {
|
|
1138
|
+
// Already gone.
|
|
1139
|
+
}
|
|
1140
|
+
})
|
|
1141
|
+
void originalHandler
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
const wrappedUpgradesGet = function get(this: Map<string, unknown>, pathname: string): unknown {
|
|
1145
|
+
const entry = savedUpgradesGet.call(this, pathname)
|
|
1146
|
+
if (!installed) return entry
|
|
1147
|
+
if (typeof pathname !== 'string' || !pathname.startsWith('/')) return entry
|
|
1148
|
+
const declared = upgradeRoutesOf(pathname)
|
|
1149
|
+
if (declared === undefined) return entry
|
|
1150
|
+
const originalHandler = entry !== undefined && isRecord(entry) && typeof (entry as { handler?: unknown }).handler === 'function'
|
|
1151
|
+
? (entry as { handler: unknown }).handler
|
|
1152
|
+
: undefined
|
|
1153
|
+
return {
|
|
1154
|
+
path: pathname,
|
|
1155
|
+
handler: surrogateUpgrade(declared, originalHandler),
|
|
1156
|
+
}
|
|
1157
|
+
} as unknown as typeof targetWithUpgrades.upgrades.get
|
|
1158
|
+
|
|
1159
|
+
target.match = wrappedMatch as typeof target.match
|
|
1160
|
+
// Instance-owning property shadows Map.prototype.get — exactly the seam
|
|
1161
|
+
// the probe verified (the host calls `this.upgrades.get(pathname)`).
|
|
1162
|
+
targetWithUpgrades.upgrades.get = wrappedUpgradesGet
|
|
1163
|
+
|
|
1164
|
+
return {
|
|
1165
|
+
uninstall() {
|
|
1166
|
+
if (!installed) return
|
|
1167
|
+
installed = false
|
|
1168
|
+
// Only restore while the instance still holds OUR wrapper — a later
|
|
1169
|
+
// wrapper chained over us captured this function, and restoring the
|
|
1170
|
+
// original would silently drop ITS wrap. Ours goes inert instead.
|
|
1171
|
+
if (target.match === wrappedMatch) target.match = savedMatch
|
|
1172
|
+
if (targetWithUpgrades.upgrades.get === wrappedUpgradesGet) {
|
|
1173
|
+
targetWithUpgrades.upgrades.get = savedUpgradesGet
|
|
1174
|
+
}
|
|
1175
|
+
},
|
|
1176
|
+
diagnostics(): PluginRouteInterceptDiagnostics {
|
|
1177
|
+
return {
|
|
1178
|
+
installed,
|
|
1179
|
+
shape: checkWebServerShape(webServer),
|
|
1180
|
+
forwarded,
|
|
1181
|
+
refused,
|
|
1182
|
+
refusalsByReason: { ...refusalsByReason },
|
|
1183
|
+
recentFailures: [...recentFailures],
|
|
1184
|
+
adapterForwarded,
|
|
1185
|
+
adapterResponseRewrites,
|
|
1186
|
+
adapterResponsePassthroughs,
|
|
1187
|
+
tunnelsCurrent,
|
|
1188
|
+
tunnelsOpened,
|
|
1189
|
+
tunnelsClosed,
|
|
1190
|
+
}
|
|
1191
|
+
},
|
|
1192
|
+
}
|
|
1193
|
+
}
|