dsh-zen-remote 2.3.3 → 2.3.5
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/plugin-bridge.md +14 -7
- package/docs/remote-access.en.md +8 -8
- package/docs/remote-access.md +10 -10
- package/lib/adapters/dsh-better-sidebar/index.js +21 -8
- package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
- package/lib/adapters/dsh-better-sidebar/surface.js +79 -0
- package/lib/adapters/dsh-better-sidebar/surface.js.map +1 -0
- package/lib/client.js +102 -30
- package/lib/index.js +103 -10
- package/lib/index.js.map +1 -1
- package/lib/intercept.js +23 -1
- package/lib/intercept.js.map +1 -1
- package/lib/merge-streams.js +29 -2
- package/lib/merge-streams.js.map +1 -1
- package/lib/plugin-adapter-core.js +69 -13
- package/lib/plugin-adapter-core.js.map +1 -1
- package/lib/plugin-bridge.js +179 -12
- package/lib/plugin-bridge.js.map +1 -1
- package/lib/plugin-route-intercept.js +631 -79
- package/lib/plugin-route-intercept.js.map +1 -1
- package/lib/plugin-tunnel.js +84 -12
- package/lib/plugin-tunnel.js.map +1 -1
- package/lib/relay-server.js +65 -47
- package/lib/relay-server.js.map +1 -1
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
- package/lib/types/adapters/dsh-better-sidebar/surface.d.ts +23 -0
- package/lib/types/adapters/dsh-better-sidebar/surface.d.ts.map +1 -0
- package/lib/types/adapters/types.d.ts +14 -3
- package/lib/types/adapters/types.d.ts.map +1 -1
- package/lib/types/client/effects/turn-fold.d.ts +42 -0
- package/lib/types/client/effects/turn-fold.d.ts.map +1 -1
- package/lib/types/client/styles/turn-fold.css.d.ts.map +1 -1
- package/lib/types/index.d.ts +2 -2
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/intercept.d.ts +19 -0
- package/lib/types/intercept.d.ts.map +1 -1
- package/lib/types/merge-streams.d.ts +12 -0
- package/lib/types/merge-streams.d.ts.map +1 -1
- package/lib/types/plugin-adapter-core.d.ts +24 -11
- package/lib/types/plugin-adapter-core.d.ts.map +1 -1
- package/lib/types/plugin-bridge.d.ts +93 -5
- package/lib/types/plugin-bridge.d.ts.map +1 -1
- package/lib/types/plugin-route-intercept.d.ts +52 -3
- package/lib/types/plugin-route-intercept.d.ts.map +1 -1
- package/lib/types/plugin-tunnel.d.ts +27 -3
- package/lib/types/plugin-tunnel.d.ts.map +1 -1
- package/lib/types/relay-server.d.ts +2 -25
- package/lib/types/relay-server.d.ts.map +1 -1
- package/lib/types/upload-limits.d.ts +67 -0
- package/lib/types/upload-limits.d.ts.map +1 -0
- package/lib/types/wizard/proxy-snippets.d.ts +4 -3
- package/lib/types/wizard/proxy-snippets.d.ts.map +1 -1
- package/lib/upload-limits.js +67 -0
- package/lib/upload-limits.js.map +1 -0
- package/lib/wizard/proxy-snippets.js +5 -4
- package/lib/wizard/proxy-snippets.js.map +1 -1
- package/package.json +38 -38
- package/scripts/check-upload-limits.mjs +167 -0
- package/src/adapters/dsh-better-sidebar/index.ts +21 -9
- package/src/adapters/dsh-better-sidebar/surface.ts +90 -0
- package/src/adapters/types.ts +14 -3
- package/src/client/effects/turn-fold.ts +97 -26
- package/src/client/styles/turn-fold.css.ts +9 -0
- package/src/client-data/wizard.ts +1 -1
- package/src/index.ts +99 -12
- package/src/intercept.ts +47 -1
- package/src/merge-streams.ts +41 -1
- package/src/plugin-adapter-core.ts +69 -14
- package/src/plugin-bridge.ts +228 -14
- package/src/plugin-route-intercept.ts +677 -82
- package/src/plugin-tunnel.ts +95 -13
- package/src/relay-server.ts +60 -48
- package/src/upload-limits.ts +69 -0
- package/src/wizard/proxy-snippets.ts +5 -4
|
@@ -9,9 +9,44 @@
|
|
|
9
9
|
* cannot be served locally (the session lives on the server): the surrogate
|
|
10
10
|
* handler forwards it through `relay.pluginRequest` (PB-3's
|
|
11
11
|
* `relay/v1/plugin`) with the real id restored, and streams the answer
|
|
12
|
-
* back
|
|
12
|
+
* back (PB-21: where the id lives is the route's own declaration — the 2.1
|
|
13
|
+
* query parameter, or a declaration-v2 locator in a prefix route's path
|
|
14
|
+
* segment or the JSON body; the v2 shapes ride the SAME adapter-core
|
|
15
|
+
* locate → classify → rewrite functions the adapters run, and a
|
|
16
|
+
* `prefix: true` route forwards its full matched, rewritten path).
|
|
17
|
+
* Everything else — undeclared routes, local sessions — flows to the
|
|
13
18
|
* original route untouched.
|
|
14
19
|
*
|
|
20
|
+
* PB-22 gives the two remaining declaration-v2 flags their client meaning:
|
|
21
|
+
* a `deny: true` route answers the uniform 503 with reason `route-denied`
|
|
22
|
+
* whenever a REMOTE context touches it — the request locating a virtual id,
|
|
23
|
+
* or the browser's CURRENT session being one (T62's signal) — while a local
|
|
24
|
+
* session passes through exactly as before; a `rewriteIds: true` route's
|
|
25
|
+
* JSON answer is buffered (the 8 MB adapter cap) and deep-scanned, every
|
|
26
|
+
* string EXACTLY equal to one of the request's own real session ids
|
|
27
|
+
* rewritten to its virtual form (`zr~` carries the real id inside, so the
|
|
28
|
+
* request's own hits ARE the read-only mapping — no table is consulted or
|
|
29
|
+
* written), unknown strings passing through untouched.
|
|
30
|
+
*
|
|
31
|
+
* PB-25a adds the position form: `rewriteIds: { fields }` declares the dot
|
|
32
|
+
* paths where the response's NEWLY-CREATED session ids sit (a creation
|
|
33
|
+
* route's `childId` was never in the request, so no value scan can catch
|
|
34
|
+
* it). On top of the value scan, each declared path's non-empty, non-virtual
|
|
35
|
+
* string gains the virtual shell of THIS request's server id; missing paths
|
|
36
|
+
* are no-ops and nothing is ever guessed beyond the declared positions.
|
|
37
|
+
*
|
|
38
|
+
* PB-25b widens the value scan's lookup from the request-local mapping to
|
|
39
|
+
* the request-local mapping UNION the KNOWN-real-session inventory: the
|
|
40
|
+
* merged global streams report every real id the sub-client currently holds
|
|
41
|
+
* (the sidebar's `zr~` rows — subagents.live's object keys, workflows.list
|
|
42
|
+
* rows: ids in the browser's view but not in THIS request), and a string
|
|
43
|
+
* exactly equal to one of those is rewritten too, in the same request-sourced
|
|
44
|
+
* virtual shell. The inventory is READ-ONLY consumption — the scan never
|
|
45
|
+
* touches merge state — and it is read live per answer: a stream refresh
|
|
46
|
+
* replaces its share, and the next answer sees the fresh union (ids that
|
|
47
|
+
* appear between two refreshes wait for the next refresh — the documented
|
|
48
|
+
* ceiling). Without the option the scan is exactly the PB-22 behavior.
|
|
49
|
+
*
|
|
15
50
|
* The un-bridged refusals answer a UNIFORM 503 shape (header
|
|
16
51
|
* `x-zen-remote: unbridged`, JSON body naming the reason) — and, decisive
|
|
17
52
|
* for the security posture, they answer WITHOUT the local plugin handler
|
|
@@ -26,8 +61,8 @@
|
|
|
26
61
|
*/
|
|
27
62
|
|
|
28
63
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
29
|
-
import type { BridgeRegistry, BridgeStatus } from './plugin-bridge.js'
|
|
30
|
-
import {
|
|
64
|
+
import type { BridgeLocator, BridgeRegistry, BridgeStatus, NormalizedBridgeRoute } from './plugin-bridge.js'
|
|
65
|
+
import { routeDeniesRemoteOf, routeLocatorOf, routeMaxBodyOf, routeMethodsOf, routePrefixOf, routeRewriteFieldsOf, routeRewritesIdsOf, routeUpgradesOf } from './plugin-bridge.js'
|
|
31
66
|
import { PassThrough, Readable, Transform } from 'node:stream'
|
|
32
67
|
import type { Socket } from 'node:net'
|
|
33
68
|
import type { RelayClient } from './relay-client.js'
|
|
@@ -47,6 +82,179 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
47
82
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
48
83
|
}
|
|
49
84
|
|
|
85
|
+
/** PB-22: the rewriteIds deep scan — every string in the parsed JSON that is
|
|
86
|
+
* EXACTLY a key of `known` becomes its virtual form; everything else passes
|
|
87
|
+
* through untouched. The mapping's membership is the ONLY rewrite trigger —
|
|
88
|
+
* no shape guessing (a session-id-LOOKING string the request never carried
|
|
89
|
+
* is not ours to rewrite), and object KEYS are left alone: the scan walks
|
|
90
|
+
* object values, array items and bare strings, exactly the positions a
|
|
91
|
+
* response can hand an id back to the browser in. Containers are rebuilt
|
|
92
|
+
* unconditionally, which keeps the rule obvious at the cost of allocations
|
|
93
|
+
* bounded by the 8 MB buffer cap.
|
|
94
|
+
*
|
|
95
|
+
* PB-25b adds the second lookup: a string exactly equal to a member of
|
|
96
|
+
* `knownIds` (the merged streams' known-real-session inventory, `undefined`
|
|
97
|
+
* when the wiring passed none — the scan then stays exactly the request-local
|
|
98
|
+
* behavior) is rewritten into THIS request's virtual shell, same source as
|
|
99
|
+
* the position rewrite's serverId. Already-virtual strings are never
|
|
100
|
+
* re-wrapped, and the set is read-only: the scan consults it, never mutates
|
|
101
|
+
* it. */
|
|
102
|
+
function deepRewriteIds(
|
|
103
|
+
value: unknown,
|
|
104
|
+
known: ReadonlyMap<string, string>,
|
|
105
|
+
knownIds: ReadonlySet<string> | undefined,
|
|
106
|
+
serverId: string,
|
|
107
|
+
): unknown {
|
|
108
|
+
if (typeof value === 'string') {
|
|
109
|
+
const mapped = known.get(value)
|
|
110
|
+
if (mapped !== undefined) return mapped
|
|
111
|
+
if (knownIds !== undefined && !isVirtual(value) && knownIds.has(value)) return toVirtual(serverId, value)
|
|
112
|
+
return value
|
|
113
|
+
}
|
|
114
|
+
if (Array.isArray(value)) return value.map((item) => deepRewriteIds(item, known, knownIds, serverId))
|
|
115
|
+
if (isRecord(value)) {
|
|
116
|
+
const out: Record<string, unknown> = {}
|
|
117
|
+
for (const [key, item] of Object.entries(value)) out[key] = deepRewriteIds(item, known, knownIds, serverId)
|
|
118
|
+
return out
|
|
119
|
+
}
|
|
120
|
+
return value
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** PB-25a: the position-based half of a `rewriteIds: { fields }` route's
|
|
124
|
+
* rewrite. Runs AFTER the value scan above, on the (rebuilt, private) parsed
|
|
125
|
+
* answer: each declared dot path is walked object-by-object, and a path that
|
|
126
|
+
* exists and holds a NON-EMPTY, NON-virtual string gains its virtual shell
|
|
127
|
+
* (`zr~<serverId>~<id>`, the serverId of the request this answer belongs to
|
|
128
|
+
* — a forward always located one). A path that does not exist — or that
|
|
129
|
+
* would walk through an array or a scalar — is a no-op: the answer's shape
|
|
130
|
+
* is the plugin's business, never a rewrite trigger (no shape guessing), and
|
|
131
|
+
* array elements stay the value scan's business (the declaration refuses
|
|
132
|
+
* numeric segments outright). Already-virtual values are skipped, so a known
|
|
133
|
+
* id sitting on a declared path is rewritten exactly once, by whichever half
|
|
134
|
+
* reached it first. */
|
|
135
|
+
function rewriteIdsAtFields(root: unknown, fields: readonly string[], serverId: string): void {
|
|
136
|
+
for (const field of fields) {
|
|
137
|
+
const segments = field.split('.')
|
|
138
|
+
let node: unknown = root
|
|
139
|
+
for (const segment of segments.slice(0, -1)) {
|
|
140
|
+
node = isRecord(node) ? node[segment] : undefined
|
|
141
|
+
}
|
|
142
|
+
if (!isRecord(node)) continue
|
|
143
|
+
const key = segments[segments.length - 1]
|
|
144
|
+
const value = node[key]
|
|
145
|
+
if (typeof value === 'string' && value !== '' && !isVirtual(value)) node[key] = toVirtual(serverId, value)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Pump a response body reader into the browser response with real
|
|
150
|
+
* backpressure — the one discipline every verbatim passthrough runs. The
|
|
151
|
+
* `close` listener settles the wait when the browser vanishes mid-body (a
|
|
152
|
+
* lost drain event would otherwise hang the write loop). */
|
|
153
|
+
async function pumpResponse(
|
|
154
|
+
res: ServerResponse,
|
|
155
|
+
reader: { read(): Promise<{ done: boolean; value?: Uint8Array }> },
|
|
156
|
+
): Promise<void> {
|
|
157
|
+
while (true) {
|
|
158
|
+
const { done, value } = await reader.read()
|
|
159
|
+
if (done) break
|
|
160
|
+
if (!res.write(value)) {
|
|
161
|
+
await new Promise<void>((resolve) => {
|
|
162
|
+
const settled = (): void => {
|
|
163
|
+
res.off('drain', settled)
|
|
164
|
+
res.off('close', settled)
|
|
165
|
+
resolve()
|
|
166
|
+
}
|
|
167
|
+
res.once('drain', settled)
|
|
168
|
+
res.once('close', settled)
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** PB-22/PB-25a/PB-25b: a rewriteIds route's JSON answer — buffered whole
|
|
175
|
+
* (the 8 MB adapter cap), deep-scanned with the request's own real→virtual
|
|
176
|
+
* mapping PLUS the known-real-session inventory (read live by the caller),
|
|
177
|
+
* plus, when the route declared field paths, the position rewrite on those
|
|
178
|
+
* paths, re-serialized with a corrected content-length. Past the cap, a
|
|
179
|
+
* non-JSON content type, or a parse failure the answer passes through
|
|
180
|
+
* untouched (the adapter discipline): a rewrite is strictly
|
|
181
|
+
* better-than-nothing, never a reason to mangle or refuse an answer. The
|
|
182
|
+
* caller has already handled the relay's refusal marker — this owns the
|
|
183
|
+
* entire normal answer. */
|
|
184
|
+
async function answerRewriteIds(
|
|
185
|
+
res: ServerResponse,
|
|
186
|
+
response: Response,
|
|
187
|
+
known: ReadonlyMap<string, string>,
|
|
188
|
+
fields: readonly string[] | undefined,
|
|
189
|
+
serverId: string,
|
|
190
|
+
knownIds: ReadonlySet<string> | undefined,
|
|
191
|
+
): Promise<void> {
|
|
192
|
+
const contentType = response.headers.get('content-type')
|
|
193
|
+
const verbatimHeaders = {
|
|
194
|
+
...(contentType !== null ? { 'content-type': contentType } : {}),
|
|
195
|
+
...(response.headers.get('content-length') !== null ? { 'content-length': response.headers.get('content-length')! } : {}),
|
|
196
|
+
...(response.headers.get('cache-control') !== null ? { 'cache-control': response.headers.get('cache-control')! } : {}),
|
|
197
|
+
}
|
|
198
|
+
const reader = response.body?.getReader()
|
|
199
|
+
if (reader === undefined || contentType === null || !contentType.includes('application/json')) {
|
|
200
|
+
res.writeHead(response.status, verbatimHeaders)
|
|
201
|
+
if (reader !== undefined) await pumpResponse(res, reader)
|
|
202
|
+
res.end()
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
const chunks: Buffer[] = []
|
|
206
|
+
let size = 0
|
|
207
|
+
let overflow = false
|
|
208
|
+
while (true) {
|
|
209
|
+
const { done, value } = await reader.read()
|
|
210
|
+
if (done) break
|
|
211
|
+
size += value.length
|
|
212
|
+
// The over-cap chunk ITSELF is body content — it lands in the
|
|
213
|
+
// passthrough buffer too; only the REWRITE is cancelled.
|
|
214
|
+
chunks.push(Buffer.from(value))
|
|
215
|
+
if (size > MAX_ADAPTER_RESPONSE_BUFFER_BYTES) {
|
|
216
|
+
overflow = true
|
|
217
|
+
break
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (overflow) {
|
|
221
|
+
res.writeHead(response.status, verbatimHeaders)
|
|
222
|
+
for (const chunk of chunks) {
|
|
223
|
+
if (!res.write(chunk)) {
|
|
224
|
+
await new Promise<void>((resolve) => {
|
|
225
|
+
const settled = (): void => {
|
|
226
|
+
res.off('drain', settled)
|
|
227
|
+
res.off('close', settled)
|
|
228
|
+
resolve()
|
|
229
|
+
}
|
|
230
|
+
res.once('drain', settled)
|
|
231
|
+
res.once('close', settled)
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
await pumpResponse(res, reader)
|
|
236
|
+
res.end()
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
240
|
+
let outBody: Buffer
|
|
241
|
+
try {
|
|
242
|
+
const scanned = deepRewriteIds(JSON.parse(text), known, knownIds, serverId)
|
|
243
|
+
if (fields !== undefined) rewriteIdsAtFields(scanned, fields, serverId)
|
|
244
|
+
outBody = Buffer.from(JSON.stringify(scanned), 'utf8')
|
|
245
|
+
} catch {
|
|
246
|
+
// Not JSON after all (or the scan hit something unserializable): the
|
|
247
|
+
// original bytes pass through, length recomputed for the corrected
|
|
248
|
+
// header set.
|
|
249
|
+
outBody = Buffer.from(text, 'utf8')
|
|
250
|
+
res.writeHead(response.status, { ...verbatimHeaders, 'content-length': String(outBody.length) })
|
|
251
|
+
res.end(outBody)
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
res.writeHead(response.status, { ...verbatimHeaders, 'content-length': String(outBody.length) })
|
|
255
|
+
res.end(outBody)
|
|
256
|
+
}
|
|
257
|
+
|
|
50
258
|
/** Pure inspection of the RAW webServer instance — nothing is mutated, so a
|
|
51
259
|
* failed check leaves every route exactly as it was. */
|
|
52
260
|
export function checkWebServerShape(webServer: unknown): WebServerShapeCheck {
|
|
@@ -118,9 +326,23 @@ export interface InstallPluginRouteInterceptOptions {
|
|
|
118
326
|
* read live per call). A VIRTUAL id means a remote session's panel is
|
|
119
327
|
* the request's origin — the signal an adapter route's `deny` hook uses
|
|
120
328
|
* to refuse requests (like better-sidebar's `open.external`) that carry
|
|
121
|
-
* no session field of their own
|
|
122
|
-
*
|
|
329
|
+
* no session field of their own, and (PB-22) the same-origin wall a
|
|
330
|
+
* cooperative route's `deny: true` raises. Absent → deny judges
|
|
331
|
+
* conservatively (only the request's own ids speak). */
|
|
123
332
|
getCurrentSession?: () => string | undefined
|
|
333
|
+
/** PB-25b: the KNOWN real session ids of this sub-client — the merged
|
|
334
|
+
* global streams' session inventories, unioned by the wiring. Read LIVE
|
|
335
|
+
* per rewriteIds answer, not captured at install: the streams report after
|
|
336
|
+
* the intercept installs, and a stream refresh replaces its share
|
|
337
|
+
* replace-style, so a getter is the only form that sees the fresh union —
|
|
338
|
+
* the same live-read discipline as getServerId/getStatus/getCurrentSession
|
|
339
|
+
* above. A rewriteIds route's value scan rewrites a string exactly equal
|
|
340
|
+
* to a member even when THIS request never carried it (the id already sits
|
|
341
|
+
* in the browser's view — subagents.live's object keys, workflows.list
|
|
342
|
+
* rows), wrapped in THIS request's virtual shell. The set is READ-ONLY
|
|
343
|
+
* consumption: nothing here mutates merge state. Absent → the scan is
|
|
344
|
+
* exactly the request-local PB-22 behavior. */
|
|
345
|
+
getKnownRealIds?: () => ReadonlySet<string>
|
|
124
346
|
/** Progress logging, wired to the context logger by index.ts. */
|
|
125
347
|
log?: (format: string, ...args: unknown[]) => void
|
|
126
348
|
}
|
|
@@ -141,14 +363,59 @@ interface DeclaredHit {
|
|
|
141
363
|
plugin: string
|
|
142
364
|
version: string
|
|
143
365
|
digest: string
|
|
144
|
-
/** The declared route path —
|
|
366
|
+
/** The declared route path — what an exact route forwards verbatim (a
|
|
367
|
+
* prefix route forwards its full matched pathname instead). */
|
|
145
368
|
path: string
|
|
146
|
-
|
|
369
|
+
/** The EFFECTIVE session-id locator (PB-21, {@link routeLocatorOf}): the
|
|
370
|
+
* declared locator, or the 2.1 query-parameter convention read as one —
|
|
371
|
+
* a route never runs without a locating rule. */
|
|
372
|
+
locator: BridgeLocator
|
|
373
|
+
/** The EFFECTIVE prefix mark (PB-21, {@link routePrefixOf}): the route
|
|
374
|
+
* admits its variable path tail. */
|
|
375
|
+
prefix: boolean
|
|
147
376
|
/** The EFFECTIVE forwardable methods (PB-9): the declared list, or
|
|
148
377
|
* `['GET']` when the route declared none. */
|
|
149
378
|
methods: string[]
|
|
150
379
|
/** The EFFECTIVE body cap (PB-9), in bytes. */
|
|
151
380
|
maxBody: number
|
|
381
|
+
/** PB-22: the route refuses a REMOTE context outright (`deny: true` —
|
|
382
|
+
* local-only data): the surrogate answers `route-denied` instead of
|
|
383
|
+
* forwarding or passing through whenever the request carries a virtual id
|
|
384
|
+
* or the browser's current session is one. */
|
|
385
|
+
deny: boolean
|
|
386
|
+
/** PB-22: the route's JSON answers are deep-scanned for the request's own
|
|
387
|
+
* real session ids and re-serialized with their virtual forms. Both
|
|
388
|
+
* `rewriteIds` forms mark it true. */
|
|
389
|
+
rewriteIds: boolean
|
|
390
|
+
/** PB-25a: the declared field paths of a `{ fields }`-form `rewriteIds`
|
|
391
|
+
* route — where the response's NEWLY-CREATED session ids sit. `undefined`
|
|
392
|
+
* for a `true` route (value scan only) and for a route without the field. */
|
|
393
|
+
rewriteFields: readonly string[] | undefined
|
|
394
|
+
/** The same effective values folded into the adapter-core route shape —
|
|
395
|
+
* the object locate/classify/rewrite consume (PB-21, see
|
|
396
|
+
* {@link bridgeRouteAsAdapterRoute}). */
|
|
397
|
+
view: NormalizedAdapterRoute
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** The NormalizedBridgeRoute read through the adapter-core route shapes
|
|
401
|
+
* (PB-21): the declaration-v2 locators must ride the SAME locate →
|
|
402
|
+
* classify → rewrite functions the adapters run — never a second copy of
|
|
403
|
+
* those rules — so the cooperative route is handed to them as the
|
|
404
|
+
* NormalizedAdapterRoute those functions consume. Every field comes from a
|
|
405
|
+
* plugin-bridge effective reader; `deny` stays unset — the cooperative
|
|
406
|
+
* `deny: true` flag is a static declaration, not an adapter-style hook, so
|
|
407
|
+
* the surrogate halves judge it themselves (PB-22). */
|
|
408
|
+
function bridgeRouteAsAdapterRoute(route: NormalizedBridgeRoute): NormalizedAdapterRoute {
|
|
409
|
+
return {
|
|
410
|
+
path: route.path,
|
|
411
|
+
match: routePrefixOf(route) ? 'prefix' : 'exact',
|
|
412
|
+
methods: routeMethodsOf(route),
|
|
413
|
+
maxBody: routeMaxBodyOf(route),
|
|
414
|
+
stream: route.stream,
|
|
415
|
+
upgrade: routeUpgradesOf(route),
|
|
416
|
+
locator: routeLocatorOf(route),
|
|
417
|
+
local: false,
|
|
418
|
+
}
|
|
152
419
|
}
|
|
153
420
|
|
|
154
421
|
export function installPluginRouteIntercept(options: InstallPluginRouteInterceptOptions): PluginRouteInterceptHandle {
|
|
@@ -182,6 +449,7 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
|
|
|
182
449
|
const { webServer, registry, relay, getServerId, getStatus, log } = options
|
|
183
450
|
const adapters = options.adapters ?? []
|
|
184
451
|
const getCurrentSession = options.getCurrentSession
|
|
452
|
+
const getKnownRealIds = options.getKnownRealIds
|
|
185
453
|
const target = webServer as { match: (this: unknown, pathname: string) => unknown }
|
|
186
454
|
const savedMatch = target.match
|
|
187
455
|
|
|
@@ -244,30 +512,42 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
|
|
|
244
512
|
? (route as { handler: (req: IncomingMessage, res: ServerResponse) => Promise<void> }).handler
|
|
245
513
|
: undefined
|
|
246
514
|
|
|
247
|
-
/** The declared route this pathname hits —
|
|
248
|
-
*
|
|
249
|
-
*
|
|
515
|
+
/** The declared route this pathname hits — exact equality, or, for a
|
|
516
|
+
* route declared `prefix: true` (PB-21), equality or one path segment
|
|
517
|
+
* deeper on the RAW undecoded pathname. The rule is `adapterPathMatches`
|
|
518
|
+
* itself, run against the route's adapter-core view, so the cooperative
|
|
519
|
+
* match can never drift from the adapters'. First plugin in registry
|
|
520
|
+
* order wins, and within it the first route in declaration order — a
|
|
521
|
+
* pathname two routes of one plugin can both hit belongs to the earlier
|
|
522
|
+
* one, exactly as the exact-only lookup always did. */
|
|
250
523
|
const declaredRouteOf = (pathname: string): DeclaredHit | undefined => {
|
|
251
524
|
for (const declaration of registry.list()) {
|
|
252
525
|
for (const route of declaration.routes) {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
526
|
+
const view = bridgeRouteAsAdapterRoute(route)
|
|
527
|
+
if (!adapterPathMatches(view, pathname)) continue
|
|
528
|
+
return {
|
|
529
|
+
plugin: declaration.plugin,
|
|
530
|
+
version: declaration.version,
|
|
531
|
+
digest: declaration.digest,
|
|
532
|
+
path: route.path,
|
|
533
|
+
locator: routeLocatorOf(route),
|
|
534
|
+
prefix: routePrefixOf(route),
|
|
535
|
+
methods: routeMethodsOf(route),
|
|
536
|
+
maxBody: routeMaxBodyOf(route),
|
|
537
|
+
deny: routeDeniesRemoteOf(route),
|
|
538
|
+
rewriteIds: routeRewritesIdsOf(route),
|
|
539
|
+
rewriteFields: routeRewriteFieldsOf(route),
|
|
540
|
+
view,
|
|
263
541
|
}
|
|
264
542
|
}
|
|
265
543
|
}
|
|
266
544
|
return undefined
|
|
267
545
|
}
|
|
268
546
|
|
|
269
|
-
/** The surrogate handler
|
|
270
|
-
*
|
|
547
|
+
/** The surrogate handler, query locator's half (every 2.1 route and an
|
|
548
|
+
* explicit query declaration): ONE query parse, the virtual-id gate, the
|
|
549
|
+
* unbridged walls in contract order, then the relay round-trip. A
|
|
550
|
+
* declaration-v2 locator dispatches to the PB-21 half below. */
|
|
271
551
|
const surrogateHandler = (declared: DeclaredHit, originalHandler: ((req: IncomingMessage, res: ServerResponse) => Promise<void>) | undefined) =>
|
|
272
552
|
async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
273
553
|
const passthrough = async (): Promise<void> => {
|
|
@@ -284,16 +564,28 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
|
|
|
284
564
|
await passthrough()
|
|
285
565
|
return
|
|
286
566
|
}
|
|
567
|
+
// PB-21: a v2 locator (path-segment / json-body) runs the adapter-core
|
|
568
|
+
// pipeline in its own half — the query locator keeps THIS code path
|
|
569
|
+
// verbatim. The dispatch sits after the installed check, so the v2
|
|
570
|
+
// half never runs chained-over either.
|
|
571
|
+
if (declared.locator.in !== 'query') {
|
|
572
|
+
await surrogateV2Handler(declared, originalHandler)(req, res)
|
|
573
|
+
return
|
|
574
|
+
}
|
|
287
575
|
// ONE parse of the query (the session parameter's home — `match` only
|
|
288
|
-
// ever saw the pathname): everything below reads THIS parse.
|
|
289
|
-
|
|
576
|
+
// ever saw the pathname): everything below reads THIS parse. The
|
|
577
|
+
// parameter NAME is the effective locator's (the 2.1 sessionParam for
|
|
578
|
+
// a route that declared no locator — the same value routeLocatorOf
|
|
579
|
+
// reads back out).
|
|
580
|
+
let requestUrl: URL
|
|
290
581
|
try {
|
|
291
|
-
|
|
582
|
+
requestUrl = new URL(req.url ?? '/', 'http://local')
|
|
292
583
|
} catch {
|
|
293
584
|
answerUnbridged(res, declared.plugin, 'bad-query')
|
|
294
585
|
return
|
|
295
586
|
}
|
|
296
|
-
const
|
|
587
|
+
const searchParams = requestUrl.searchParams
|
|
588
|
+
const values = searchParams.getAll(declared.locator.name)
|
|
297
589
|
// The parameter appearing MORE than once is a shape violation the
|
|
298
590
|
// bridge refuses (bad-query) — never a passthrough, and never a
|
|
299
591
|
// forward of whichever duplicate a parser happens to keep. Its TOTAL
|
|
@@ -304,6 +596,20 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
|
|
|
304
596
|
return
|
|
305
597
|
}
|
|
306
598
|
const value = values.length === 1 ? values[0] : undefined
|
|
599
|
+
// PB-22: a `deny: true` route refuses a REMOTE context outright — the
|
|
600
|
+
// request locating a virtual id, or the browser's CURRENT session
|
|
601
|
+
// being one (T62's signal; requests like open.external carry no id of
|
|
602
|
+
// their own). The uniform 503 with the route's own reason: the local
|
|
603
|
+
// handler is never reached, and the forward path below is unreachable
|
|
604
|
+
// for this route. A local session's request without a virtual id falls
|
|
605
|
+
// through — `deny` must not touch local behavior.
|
|
606
|
+
if (declared.deny) {
|
|
607
|
+
const current = getCurrentSession?.()
|
|
608
|
+
if ((current !== undefined && isVirtual(current)) || (value !== undefined && value.startsWith('zr~'))) {
|
|
609
|
+
answerUnbridged(res, declared.plugin, 'route-denied')
|
|
610
|
+
return
|
|
611
|
+
}
|
|
612
|
+
}
|
|
307
613
|
// A LOCAL id (or none at all): the original route's business — its
|
|
308
614
|
// handler runs exactly as if we were never here.
|
|
309
615
|
if (value === undefined || value === '' || !value.startsWith('zr~')) {
|
|
@@ -354,7 +660,15 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
|
|
|
354
660
|
log?.('plugin route %s forwards for %s', declared.path, declared.plugin)
|
|
355
661
|
// The session id rides the CHECKED parse, restored to its ORIGINAL
|
|
356
662
|
// form, re-serialized once — what was judged is what is forwarded.
|
|
357
|
-
searchParams.set(declared.
|
|
663
|
+
searchParams.set(declared.locator.name, parts.id)
|
|
664
|
+
// PB-22: the request's own real→virtual pair — the READ-ONLY mapping a
|
|
665
|
+
// rewriteIds route's answer scan consults (`zr~` carries the real id
|
|
666
|
+
// inside, so the ids this very request named are exactly the ones the
|
|
667
|
+
// answer may hand back). PB-25b: the scan consults ONE more source
|
|
668
|
+
// beside this mapping — the known-real-session inventory, read live at
|
|
669
|
+
// the answer (never copied here) and consumed read-only; nothing here
|
|
670
|
+
// writes any session table.
|
|
671
|
+
const idMapping: ReadonlyMap<string, string> = new Map([[parts.id, value]])
|
|
358
672
|
const controller = new AbortController()
|
|
359
673
|
// The counting gate's failure has its OWN abort signal (PB-9-fix, the
|
|
360
674
|
// mirror of the relay route's bodyGateError): tripping the gate is a
|
|
@@ -408,7 +722,11 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
|
|
|
408
722
|
plugin: declared.plugin,
|
|
409
723
|
version: declared.version,
|
|
410
724
|
digest: declared.digest,
|
|
411
|
-
|
|
725
|
+
// A prefix route (PB-21) forwards its FULL matched pathname —
|
|
726
|
+
// the declared base alone would lose the variable tail; an
|
|
727
|
+
// exact route's pathname IS the declared path, the bytes that
|
|
728
|
+
// always travelled.
|
|
729
|
+
path: declared.prefix ? requestUrl.pathname : declared.path,
|
|
412
730
|
query: searchParams.toString(),
|
|
413
731
|
method,
|
|
414
732
|
...(hasBody ? { body: bodyStream } : {}),
|
|
@@ -428,6 +746,17 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
|
|
|
428
746
|
answerUnbridged(res, declared.plugin, refusal)
|
|
429
747
|
return
|
|
430
748
|
}
|
|
749
|
+
// PB-22: a rewriteIds route's answer is buffered and deep-scanned
|
|
750
|
+
// (the ids this request named come back virtual); everything else —
|
|
751
|
+
// non-JSON answers included — streams verbatim. PB-25a: a
|
|
752
|
+
// `{ fields }` declaration adds the position rewrite, under THIS
|
|
753
|
+
// request's own server id. PB-25b: the scan's lookup widens to the
|
|
754
|
+
// known inventory, read live right here (undefined when the wiring
|
|
755
|
+
// passed no getter — the scan stays request-local).
|
|
756
|
+
if (declared.rewriteIds) {
|
|
757
|
+
await answerRewriteIds(res, response, idMapping, declared.rewriteFields, parts.serverId, getKnownRealIds?.())
|
|
758
|
+
return
|
|
759
|
+
}
|
|
431
760
|
const contentType = response.headers.get('content-type')
|
|
432
761
|
const contentLength = response.headers.get('content-length')
|
|
433
762
|
const cacheControl = response.headers.get('cache-control')
|
|
@@ -439,23 +768,7 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
|
|
|
439
768
|
// Stream the body through with real backpressure; a mid-stream
|
|
440
769
|
// failure with headers already out can only cut the response.
|
|
441
770
|
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
|
-
}
|
|
771
|
+
if (reader !== undefined) await pumpResponse(res, reader)
|
|
459
772
|
res.end()
|
|
460
773
|
} catch (error) {
|
|
461
774
|
// The gate's local flag FIRST (PB-9-fix): the counting gate tripped
|
|
@@ -492,6 +805,304 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
|
|
|
492
805
|
}
|
|
493
806
|
}
|
|
494
807
|
|
|
808
|
+
/** The PB-21 surrogate half for the declaration-v2 locators — json-body,
|
|
809
|
+
* and path-segment on a prefix route: the adapter surrogate's pipeline
|
|
810
|
+
* (locate → classify → walls → rewrite → the relay round-trip) run
|
|
811
|
+
* through the SAME adapter-core functions against the route's
|
|
812
|
+
* adapter-core view. The response discipline stays the query half's —
|
|
813
|
+
* three headers, no hooks: `deny` and `rewriteIds` are PB-22's business.
|
|
814
|
+
* Dispatch above guarantees `installed` and a non-query locator. */
|
|
815
|
+
const surrogateV2Handler = (declared: DeclaredHit, originalHandler: ((req: IncomingMessage, res: ServerResponse) => Promise<void>) | undefined) =>
|
|
816
|
+
async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
817
|
+
const plugin = declared.plugin
|
|
818
|
+
// The local passthrough. A json-body route has ALREADY consumed the
|
|
819
|
+
// request body by the time this runs (the classify needs the parsed
|
|
820
|
+
// body), so the replay hands the buffered bytes back behind the
|
|
821
|
+
// original request's metadata — the shape a local handler actually
|
|
822
|
+
// reads (method/url/headers/pipe).
|
|
823
|
+
const passthrough = async (replayBody?: Buffer): Promise<void> => {
|
|
824
|
+
if (originalHandler === undefined) {
|
|
825
|
+
res.writeHead(404)
|
|
826
|
+
res.end()
|
|
827
|
+
return
|
|
828
|
+
}
|
|
829
|
+
if (replayBody !== undefined) {
|
|
830
|
+
const replay = new PassThrough()
|
|
831
|
+
replay.end(replayBody)
|
|
832
|
+
Object.assign(replay, { method: req.method, url: req.url, headers: req.headers })
|
|
833
|
+
await originalHandler(replay as unknown as IncomingMessage, res)
|
|
834
|
+
return
|
|
835
|
+
}
|
|
836
|
+
await originalHandler(req, res)
|
|
837
|
+
}
|
|
838
|
+
let controller: AbortController | undefined
|
|
839
|
+
const onClientGone = (): void => {
|
|
840
|
+
if (controller !== undefined && !res.writableEnded) controller.abort()
|
|
841
|
+
}
|
|
842
|
+
res.on('close', onClientGone)
|
|
843
|
+
// The counting gate's LOCAL flag (the query half's PB-9-fix shape): a
|
|
844
|
+
// tripped gate answers the local 413 in the catch below — never an
|
|
845
|
+
// offline verdict, never a half-written response.
|
|
846
|
+
let bodyGateFailed = false
|
|
847
|
+
const bodyAbort = new AbortController()
|
|
848
|
+
try {
|
|
849
|
+
let url: URL
|
|
850
|
+
try {
|
|
851
|
+
url = new URL(req.url ?? '/', 'http://local')
|
|
852
|
+
} catch {
|
|
853
|
+
answerUnbridged(res, plugin, 'bad-query')
|
|
854
|
+
return
|
|
855
|
+
}
|
|
856
|
+
const pathname = url.pathname
|
|
857
|
+
const searchParams = url.searchParams
|
|
858
|
+
const method = req.method ?? 'GET'
|
|
859
|
+
const hasBody = method !== 'GET' && method !== 'HEAD'
|
|
860
|
+
|
|
861
|
+
// PB-22: the deny wall's current-session half — a remote panel is
|
|
862
|
+
// the request's origin even when it carries no id of its own
|
|
863
|
+
// (open.external's shape). Refused before any body byte is read.
|
|
864
|
+
if (declared.deny) {
|
|
865
|
+
const current = getCurrentSession?.()
|
|
866
|
+
if (current !== undefined && isVirtual(current)) {
|
|
867
|
+
answerUnbridged(res, plugin, 'route-denied')
|
|
868
|
+
return
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// A json-body route buffers the request body up front, bounded by
|
|
873
|
+
// the route's own maxBody — the cooperative 413 shape, answered
|
|
874
|
+
// locally, the plugin never reached. A path-segment route's body is
|
|
875
|
+
// never consumed by locating; it streams through the counting gate
|
|
876
|
+
// at the forward.
|
|
877
|
+
let bufferedBody: Buffer | undefined
|
|
878
|
+
if (hasBody && declared.locator.in === 'json-body') {
|
|
879
|
+
const chunks: Buffer[] = []
|
|
880
|
+
let size = 0
|
|
881
|
+
let over = false
|
|
882
|
+
for await (const chunk of req) {
|
|
883
|
+
size += (chunk as Buffer).length
|
|
884
|
+
if (size > declared.maxBody) {
|
|
885
|
+
over = true
|
|
886
|
+
break
|
|
887
|
+
}
|
|
888
|
+
chunks.push(Buffer.from(chunk as Uint8Array))
|
|
889
|
+
}
|
|
890
|
+
if (over) {
|
|
891
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
892
|
+
res.writeHead(413)
|
|
893
|
+
res.end(JSON.stringify({ ok: false, error: { code: 'zr-plugin-body-too-large' } }))
|
|
894
|
+
recordRefusal(plugin, 'body-too-large')
|
|
895
|
+
log?.('plugin request for %s refused locally: body-too-large (%s buffered)', declared.path, size)
|
|
896
|
+
return
|
|
897
|
+
}
|
|
898
|
+
bufferedBody = Buffer.concat(chunks)
|
|
899
|
+
}
|
|
900
|
+
// A present body must parse as JSON when the route locates in it —
|
|
901
|
+
// a corrupted body refuses rather than falling through to the local
|
|
902
|
+
// plugin with a body the locate could not judge (the adapter half's
|
|
903
|
+
// shared rule).
|
|
904
|
+
let parsedBody: unknown
|
|
905
|
+
let bodyGiven = false
|
|
906
|
+
if (hasBody && declared.locator.in === 'json-body' && bufferedBody !== undefined && bufferedBody.length > 0) {
|
|
907
|
+
try {
|
|
908
|
+
parsedBody = JSON.parse(bufferedBody.toString('utf8'))
|
|
909
|
+
bodyGiven = true
|
|
910
|
+
} catch {
|
|
911
|
+
answerUnbridged(res, plugin, 'bad-query')
|
|
912
|
+
return
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
// The remote/local verdict on the ids as they arrived — the
|
|
916
|
+
// adapter-core classify (locate inside), no second copy of the
|
|
917
|
+
// rule: every id virtual → forward, none → local, a mix → the
|
|
918
|
+
// uniform refusal (docs/adr/0001: a remote session never reaches a
|
|
919
|
+
// local plugin).
|
|
920
|
+
let verdict: AdapterRemoteVerdict
|
|
921
|
+
try {
|
|
922
|
+
verdict = classifyAdapterRequest(declared.view, pathname, searchParams, bodyGiven ? parsedBody : undefined)
|
|
923
|
+
} catch (error) {
|
|
924
|
+
if (error instanceof AdapterMultiValueQueryError) {
|
|
925
|
+
answerUnbridged(res, plugin, 'bad-query')
|
|
926
|
+
return
|
|
927
|
+
}
|
|
928
|
+
throw error
|
|
929
|
+
}
|
|
930
|
+
// PB-22: the deny wall's request half — the locate found a virtual
|
|
931
|
+
// id (a remote verdict, or a mixed one: that too means a virtual id
|
|
932
|
+
// sat in the request). This route never serves a remote context, so
|
|
933
|
+
// the answer is the route's own refusal, not the mixed shape.
|
|
934
|
+
if (declared.deny && verdict.kind !== 'local') {
|
|
935
|
+
answerUnbridged(res, plugin, 'route-denied')
|
|
936
|
+
return
|
|
937
|
+
}
|
|
938
|
+
if (verdict.kind === 'local') {
|
|
939
|
+
await passthrough(bufferedBody)
|
|
940
|
+
return
|
|
941
|
+
}
|
|
942
|
+
if (verdict.kind === 'mixed') {
|
|
943
|
+
answerUnbridged(res, plugin, 'bad-query')
|
|
944
|
+
return
|
|
945
|
+
}
|
|
946
|
+
// Remote: the route's method wall, then the per-id server walls —
|
|
947
|
+
// the query half's gates, re-run per located id.
|
|
948
|
+
if (!declared.methods.includes(method)) {
|
|
949
|
+
answerUnbridged(res, plugin, 'method-not-bridged')
|
|
950
|
+
return
|
|
951
|
+
}
|
|
952
|
+
const serverId = getServerId()
|
|
953
|
+
// PB-22: the request's own real→virtual pairs, collected while the
|
|
954
|
+
// wall validates each hit — the READ-ONLY mapping a rewriteIds
|
|
955
|
+
// route's answer scan consults (`zr~` carries the real id inside, so
|
|
956
|
+
// the ids this very request named are exactly the ones the answer
|
|
957
|
+
// may hand back). PB-25a: the SAME unwrap hands the position rewrite
|
|
958
|
+
// its server id — the ids this request located live under it, so a
|
|
959
|
+
// NEWLY-created id joins them (the query half's source verbatim;
|
|
960
|
+
// `remote` always has at least one hit). PB-25b: the scan consults
|
|
961
|
+
// ONE more source beside this mapping — the known-real-session
|
|
962
|
+
// inventory, read live at the answer (never copied here) and
|
|
963
|
+
// consumed read-only; nothing here writes any session table.
|
|
964
|
+
const idMapping = new Map<string, string>()
|
|
965
|
+
let locatedServerId: string | undefined
|
|
966
|
+
for (const idHit of verdict.hits) {
|
|
967
|
+
const parts = fromVirtual(idHit.value)
|
|
968
|
+
if (parts === undefined || parts.id === '') {
|
|
969
|
+
answerUnbridged(res, plugin, 'bad-query')
|
|
970
|
+
return
|
|
971
|
+
}
|
|
972
|
+
if (parts.serverId !== serverId) {
|
|
973
|
+
answerUnbridged(res, plugin, 'server-changed')
|
|
974
|
+
return
|
|
975
|
+
}
|
|
976
|
+
idMapping.set(parts.id, idHit.value)
|
|
977
|
+
locatedServerId = parts.serverId
|
|
978
|
+
}
|
|
979
|
+
// The bridge verdicts judged from the SAME live table the query
|
|
980
|
+
// half reads.
|
|
981
|
+
const status = getStatus().find((entry) => entry.plugin === plugin)
|
|
982
|
+
if (status === undefined || !status.bridged) {
|
|
983
|
+
answerUnbridged(res, plugin, status?.reason ?? 'not-on-server')
|
|
984
|
+
return
|
|
985
|
+
}
|
|
986
|
+
// Real ids in, coordinates rewritten — the adapter-core rewrite:
|
|
987
|
+
// the query re-serialized, the path segment re-encoded, the JSON
|
|
988
|
+
// body re-serialized. The forward carries the REWRITTEN pathname —
|
|
989
|
+
// a prefix route's full matched path, never the declared base.
|
|
990
|
+
const rewrite = rewriteAdapterRequest(declared.view, pathname, searchParams, bodyGiven ? parsedBody : undefined, verdict.hits, (virtualId) => fromVirtual(virtualId)!.id)
|
|
991
|
+
forwarded += 1
|
|
992
|
+
log?.('plugin route %s forwards for %s', rewrite.pathname, plugin)
|
|
993
|
+
controller = new AbortController()
|
|
994
|
+
const forwardSignal = AbortSignal.any([controller.signal, bodyAbort.signal])
|
|
995
|
+
const accept = typeof req.headers.accept === 'string' ? req.headers.accept : undefined
|
|
996
|
+
const range = typeof req.headers.range === 'string' ? req.headers.range : undefined
|
|
997
|
+
const requestContentType = typeof req.headers['content-type'] === 'string' ? req.headers['content-type'] : undefined
|
|
998
|
+
const declaredLength = Number(req.headers['content-length'])
|
|
999
|
+
let bodyStream: ReadableStream<Uint8Array> | null = null
|
|
1000
|
+
let contentLength: number | undefined
|
|
1001
|
+
if (hasBody && declared.locator.in === 'json-body') {
|
|
1002
|
+
// The buffer forwards in full: the (possibly rewritten)
|
|
1003
|
+
// re-serialization when ids sat in it, the original bytes
|
|
1004
|
+
// otherwise. The REWRITTEN byte count is the declared length —
|
|
1005
|
+
// the budget reads what actually travels.
|
|
1006
|
+
const payload = rewrite.bodyText !== undefined ? Buffer.from(rewrite.bodyText, 'utf8') : bufferedBody ?? Buffer.alloc(0)
|
|
1007
|
+
contentLength = payload.length
|
|
1008
|
+
bodyStream = Readable.toWeb(Readable.from(payload)) as ReadableStream<Uint8Array>
|
|
1009
|
+
} else if (hasBody) {
|
|
1010
|
+
// The pre-check first — a DECLARED Content-Length past the cap
|
|
1011
|
+
// never starts a round-trip — then the counting gate for a
|
|
1012
|
+
// chunked body, exactly the query half's PB-9 discipline.
|
|
1013
|
+
if (Number.isFinite(declaredLength) && declaredLength > declared.maxBody) {
|
|
1014
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
1015
|
+
res.writeHead(413)
|
|
1016
|
+
res.end(JSON.stringify({ ok: false, error: { code: 'zr-plugin-body-too-large' } }))
|
|
1017
|
+
recordRefusal(plugin, 'body-too-large')
|
|
1018
|
+
log?.('plugin request for %s refused locally: body-too-large (%s declared)', declared.path, req.headers['content-length'])
|
|
1019
|
+
return
|
|
1020
|
+
}
|
|
1021
|
+
let pumped = 0
|
|
1022
|
+
const gate = new Transform({
|
|
1023
|
+
transform(chunk: Buffer, _encoding, callback) {
|
|
1024
|
+
pumped += chunk.length
|
|
1025
|
+
if (pumped > declared.maxBody) {
|
|
1026
|
+
callback(new Error('zr-plugin-body-too-large'))
|
|
1027
|
+
return
|
|
1028
|
+
}
|
|
1029
|
+
callback(null, chunk)
|
|
1030
|
+
},
|
|
1031
|
+
})
|
|
1032
|
+
req.on('error', (error) => gate.destroy(error))
|
|
1033
|
+
gate.on('error', () => {
|
|
1034
|
+
if (!bodyGateFailed) bodyGateFailed = true
|
|
1035
|
+
bodyAbort.abort()
|
|
1036
|
+
})
|
|
1037
|
+
bodyStream = Readable.toWeb(gate) as ReadableStream<Uint8Array>
|
|
1038
|
+
req.pipe(gate)
|
|
1039
|
+
if (Number.isFinite(declaredLength) && declaredLength > 0) contentLength = declaredLength
|
|
1040
|
+
}
|
|
1041
|
+
const response = await relay.pluginRequest(
|
|
1042
|
+
{
|
|
1043
|
+
plugin,
|
|
1044
|
+
version: declared.version,
|
|
1045
|
+
digest: declared.digest,
|
|
1046
|
+
path: rewrite.pathname,
|
|
1047
|
+
query: rewrite.query.toString(),
|
|
1048
|
+
method,
|
|
1049
|
+
...(bodyStream !== null ? { body: bodyStream } : {}),
|
|
1050
|
+
...(bodyStream !== null && requestContentType !== undefined ? { contentType: requestContentType } : {}),
|
|
1051
|
+
...(bodyStream !== null && contentLength !== undefined ? { contentLength } : {}),
|
|
1052
|
+
...(accept !== undefined ? { accept } : {}),
|
|
1053
|
+
...(range !== undefined ? { range } : {}),
|
|
1054
|
+
},
|
|
1055
|
+
forwardSignal,
|
|
1056
|
+
)
|
|
1057
|
+
// Down from here the query half's response discipline verbatim: the
|
|
1058
|
+
// relay refusal marker, the three cooperative headers, the pump
|
|
1059
|
+
// with real backpressure — plus (PB-22) the rewriteIds branch.
|
|
1060
|
+
const refusal = response.headers.get('x-zen-remote-error')
|
|
1061
|
+
if (refusal !== null) {
|
|
1062
|
+
await response.body?.cancel().catch(() => {})
|
|
1063
|
+
answerUnbridged(res, plugin, refusal)
|
|
1064
|
+
return
|
|
1065
|
+
}
|
|
1066
|
+
if (declared.rewriteIds) {
|
|
1067
|
+
await answerRewriteIds(res, response, idMapping, declared.rewriteFields, locatedServerId!, getKnownRealIds?.())
|
|
1068
|
+
return
|
|
1069
|
+
}
|
|
1070
|
+
const contentType = response.headers.get('content-type')
|
|
1071
|
+
const responseLength = response.headers.get('content-length')
|
|
1072
|
+
const cacheControl = response.headers.get('cache-control')
|
|
1073
|
+
res.writeHead(response.status, {
|
|
1074
|
+
...(contentType !== null ? { 'content-type': contentType } : {}),
|
|
1075
|
+
...(responseLength !== null ? { 'content-length': responseLength } : {}),
|
|
1076
|
+
...(cacheControl !== null ? { 'cache-control': cacheControl } : {}),
|
|
1077
|
+
})
|
|
1078
|
+
const reader = response.body?.getReader()
|
|
1079
|
+
if (reader !== undefined) await pumpResponse(res, reader)
|
|
1080
|
+
res.end()
|
|
1081
|
+
} catch (error) {
|
|
1082
|
+
// The gate's local flag first (the query half's shape): the 413
|
|
1083
|
+
// still fits while the browser's headers are not out.
|
|
1084
|
+
if (bodyGateFailed && !res.headersSent) {
|
|
1085
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
1086
|
+
res.writeHead(413)
|
|
1087
|
+
res.end(JSON.stringify({ ok: false, error: { code: 'zr-plugin-body-too-large' } }))
|
|
1088
|
+
recordRefusal(plugin, 'body-too-large')
|
|
1089
|
+
return
|
|
1090
|
+
}
|
|
1091
|
+
if (controller?.signal.aborted || res.destroyed || res.headersSent) {
|
|
1092
|
+
try {
|
|
1093
|
+
res.destroy()
|
|
1094
|
+
} catch {
|
|
1095
|
+
// Already gone.
|
|
1096
|
+
}
|
|
1097
|
+
return
|
|
1098
|
+
}
|
|
1099
|
+
const code = typeof (error as { code?: unknown } | null)?.code === 'string' ? (error as { code: string }).code : 'offline'
|
|
1100
|
+
answerUnbridged(res, plugin, code === 'aborted' ? 'offline' : code)
|
|
1101
|
+
} finally {
|
|
1102
|
+
res.off('close', onClientGone)
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
495
1106
|
// ---- PB-11: the adapter interception ---------------------------------------
|
|
496
1107
|
//
|
|
497
1108
|
// An adapter (src/adapters/, validated into a NormalizedAdapter by the
|
|
@@ -769,23 +1380,7 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
|
|
|
769
1380
|
...Object.assign({}, ...allowlistedHeaders),
|
|
770
1381
|
})
|
|
771
1382
|
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
|
-
}
|
|
1383
|
+
if (reader !== undefined) await pumpResponse(res, reader)
|
|
789
1384
|
res.end()
|
|
790
1385
|
return
|
|
791
1386
|
}
|
|
@@ -829,21 +1424,7 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
|
|
|
829
1424
|
})
|
|
830
1425
|
}
|
|
831
1426
|
}
|
|
832
|
-
|
|
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
|
-
}
|
|
1427
|
+
await pumpResponse(res, reader)
|
|
847
1428
|
res.end()
|
|
848
1429
|
return
|
|
849
1430
|
}
|
|
@@ -897,9 +1478,10 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
|
|
|
897
1478
|
/** The wrapped match: original verdict first, then the adapter routes
|
|
898
1479
|
* (PB-11 — BEFORE the declaration lookup: the adapter's registry entry
|
|
899
1480
|
* IS a declaration, and the cooperative surrogate's query-sessionParam
|
|
900
|
-
* logic is not how an adapter request is judged), then the
|
|
901
|
-
*
|
|
902
|
-
*
|
|
1481
|
+
* logic is not how an adapter request is judged), then the declaration
|
|
1482
|
+
* lookup — exact paths plus (PB-21) a declared prefix route's full
|
|
1483
|
+
* match. An undeclared pathname returns whatever the original match
|
|
1484
|
+
* answered, untouched. */
|
|
903
1485
|
const wrappedMatch = function match(this: unknown, pathname: string): unknown {
|
|
904
1486
|
const route = savedMatch.call(this, pathname)
|
|
905
1487
|
if (!installed) return route
|
|
@@ -937,7 +1519,7 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
|
|
|
937
1519
|
|
|
938
1520
|
const targetWithUpgrades = webServer as { upgrades: Map<string, unknown> }
|
|
939
1521
|
const savedUpgradesGet = targetWithUpgrades.upgrades.get
|
|
940
|
-
const upgradeRoutesOf = (pathname: string): { plugin: string; version: string; digest: string; path: string; sessionParam: string } | undefined => {
|
|
1522
|
+
const upgradeRoutesOf = (pathname: string): { plugin: string; version: string; digest: string; path: string; sessionParam: string; deny: boolean } | undefined => {
|
|
941
1523
|
if (!installed) return undefined
|
|
942
1524
|
for (const declaration of registry.list()) {
|
|
943
1525
|
for (const route of declaration.routes) {
|
|
@@ -948,6 +1530,7 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
|
|
|
948
1530
|
digest: declaration.digest,
|
|
949
1531
|
path: route.path,
|
|
950
1532
|
sessionParam: route.sessionParam,
|
|
1533
|
+
deny: routeDeniesRemoteOf(route),
|
|
951
1534
|
}
|
|
952
1535
|
}
|
|
953
1536
|
}
|
|
@@ -957,7 +1540,7 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
|
|
|
957
1540
|
|
|
958
1541
|
/** The tunnel's surrogate upgrade handler. Every outcome — refusal,
|
|
959
1542
|
* 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) =>
|
|
1543
|
+
const surrogateUpgrade = (declared: { plugin: string; version: string; digest: string; path: string; sessionParam: string; deny: boolean }, originalHandler: unknown) =>
|
|
961
1544
|
(req: IncomingMessage, socket: Socket, head: Buffer): void => {
|
|
962
1545
|
// The host never awaits an upgrade handler — the async body runs on
|
|
963
1546
|
// its own and every failure path owns the socket's teardown.
|
|
@@ -990,6 +1573,18 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
|
|
|
990
1573
|
return
|
|
991
1574
|
}
|
|
992
1575
|
const value = values.length === 1 ? values[0] : undefined
|
|
1576
|
+
// PB-22: the deny wall on the tunnel — the HTTP surrogate's rule
|
|
1577
|
+
// verbatim. A remote context (the request's own virtual id, or the
|
|
1578
|
+
// browser's reported current session) never opens a byte tunnel into
|
|
1579
|
+
// a route declared `deny: true`; a local session's upgrade passes
|
|
1580
|
+
// through untouched.
|
|
1581
|
+
if (declared.deny) {
|
|
1582
|
+
const current = getCurrentSession?.()
|
|
1583
|
+
if ((current !== undefined && isVirtual(current)) || (value !== undefined && value.startsWith('zr~'))) {
|
|
1584
|
+
answerUnbridgedSocket(socket, declared.plugin, 'route-denied')
|
|
1585
|
+
return
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
993
1588
|
// A LOCAL session (or no parameter at all): the original upgrade
|
|
994
1589
|
// handler's business, the head bytes untouched.
|
|
995
1590
|
if (value === undefined || value === '' || !value.startsWith('zr~')) {
|