dsh-zen-remote 2.3.3 → 2.3.4
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 +1 -1
- 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/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 +2 -2
- 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-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
|
|
@@ -24,13 +59,178 @@
|
|
|
24
59
|
* and ours goes inert instead (the same posture as intercept.ts and
|
|
25
60
|
* fetch-route-intercept.ts).
|
|
26
61
|
*/
|
|
27
|
-
import {
|
|
62
|
+
import { routeDeniesRemoteOf, routeLocatorOf, routeMaxBodyOf, routeMethodsOf, routePrefixOf, routeRewriteFieldsOf, routeRewritesIdsOf, routeUpgradesOf } from './plugin-bridge.js';
|
|
28
63
|
import { PassThrough, Readable, Transform } from 'node:stream';
|
|
29
64
|
import { fromVirtual, isVirtual, toVirtual } from './virtual-id.js';
|
|
30
65
|
import { adapterPathMatches, classifyAdapterRequest, rewriteAdapterRequest, adapterOwnsRegistryEntry, MAX_ADAPTER_RESPONSE_BUFFER_BYTES, ADAPTER_RESPONSE_HEADER_ALLOWLIST, AdapterMultiValueQueryError } from './plugin-adapter-core.js';
|
|
31
66
|
function isRecord(value) {
|
|
32
67
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
33
68
|
}
|
|
69
|
+
/** PB-22: the rewriteIds deep scan — every string in the parsed JSON that is
|
|
70
|
+
* EXACTLY a key of `known` becomes its virtual form; everything else passes
|
|
71
|
+
* through untouched. The mapping's membership is the ONLY rewrite trigger —
|
|
72
|
+
* no shape guessing (a session-id-LOOKING string the request never carried
|
|
73
|
+
* is not ours to rewrite), and object KEYS are left alone: the scan walks
|
|
74
|
+
* object values, array items and bare strings, exactly the positions a
|
|
75
|
+
* response can hand an id back to the browser in. Containers are rebuilt
|
|
76
|
+
* unconditionally, which keeps the rule obvious at the cost of allocations
|
|
77
|
+
* bounded by the 8 MB buffer cap.
|
|
78
|
+
*
|
|
79
|
+
* PB-25b adds the second lookup: a string exactly equal to a member of
|
|
80
|
+
* `knownIds` (the merged streams' known-real-session inventory, `undefined`
|
|
81
|
+
* when the wiring passed none — the scan then stays exactly the request-local
|
|
82
|
+
* behavior) is rewritten into THIS request's virtual shell, same source as
|
|
83
|
+
* the position rewrite's serverId. Already-virtual strings are never
|
|
84
|
+
* re-wrapped, and the set is read-only: the scan consults it, never mutates
|
|
85
|
+
* it. */
|
|
86
|
+
function deepRewriteIds(value, known, knownIds, serverId) {
|
|
87
|
+
if (typeof value === 'string') {
|
|
88
|
+
const mapped = known.get(value);
|
|
89
|
+
if (mapped !== undefined)
|
|
90
|
+
return mapped;
|
|
91
|
+
if (knownIds !== undefined && !isVirtual(value) && knownIds.has(value))
|
|
92
|
+
return toVirtual(serverId, value);
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
95
|
+
if (Array.isArray(value))
|
|
96
|
+
return value.map((item) => deepRewriteIds(item, known, knownIds, serverId));
|
|
97
|
+
if (isRecord(value)) {
|
|
98
|
+
const out = {};
|
|
99
|
+
for (const [key, item] of Object.entries(value))
|
|
100
|
+
out[key] = deepRewriteIds(item, known, knownIds, serverId);
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
return value;
|
|
104
|
+
}
|
|
105
|
+
/** PB-25a: the position-based half of a `rewriteIds: { fields }` route's
|
|
106
|
+
* rewrite. Runs AFTER the value scan above, on the (rebuilt, private) parsed
|
|
107
|
+
* answer: each declared dot path is walked object-by-object, and a path that
|
|
108
|
+
* exists and holds a NON-EMPTY, NON-virtual string gains its virtual shell
|
|
109
|
+
* (`zr~<serverId>~<id>`, the serverId of the request this answer belongs to
|
|
110
|
+
* — a forward always located one). A path that does not exist — or that
|
|
111
|
+
* would walk through an array or a scalar — is a no-op: the answer's shape
|
|
112
|
+
* is the plugin's business, never a rewrite trigger (no shape guessing), and
|
|
113
|
+
* array elements stay the value scan's business (the declaration refuses
|
|
114
|
+
* numeric segments outright). Already-virtual values are skipped, so a known
|
|
115
|
+
* id sitting on a declared path is rewritten exactly once, by whichever half
|
|
116
|
+
* reached it first. */
|
|
117
|
+
function rewriteIdsAtFields(root, fields, serverId) {
|
|
118
|
+
for (const field of fields) {
|
|
119
|
+
const segments = field.split('.');
|
|
120
|
+
let node = root;
|
|
121
|
+
for (const segment of segments.slice(0, -1)) {
|
|
122
|
+
node = isRecord(node) ? node[segment] : undefined;
|
|
123
|
+
}
|
|
124
|
+
if (!isRecord(node))
|
|
125
|
+
continue;
|
|
126
|
+
const key = segments[segments.length - 1];
|
|
127
|
+
const value = node[key];
|
|
128
|
+
if (typeof value === 'string' && value !== '' && !isVirtual(value))
|
|
129
|
+
node[key] = toVirtual(serverId, value);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** Pump a response body reader into the browser response with real
|
|
133
|
+
* backpressure — the one discipline every verbatim passthrough runs. The
|
|
134
|
+
* `close` listener settles the wait when the browser vanishes mid-body (a
|
|
135
|
+
* lost drain event would otherwise hang the write loop). */
|
|
136
|
+
async function pumpResponse(res, reader) {
|
|
137
|
+
while (true) {
|
|
138
|
+
const { done, value } = await reader.read();
|
|
139
|
+
if (done)
|
|
140
|
+
break;
|
|
141
|
+
if (!res.write(value)) {
|
|
142
|
+
await new Promise((resolve) => {
|
|
143
|
+
const settled = () => {
|
|
144
|
+
res.off('drain', settled);
|
|
145
|
+
res.off('close', settled);
|
|
146
|
+
resolve();
|
|
147
|
+
};
|
|
148
|
+
res.once('drain', settled);
|
|
149
|
+
res.once('close', settled);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/** PB-22/PB-25a/PB-25b: a rewriteIds route's JSON answer — buffered whole
|
|
155
|
+
* (the 8 MB adapter cap), deep-scanned with the request's own real→virtual
|
|
156
|
+
* mapping PLUS the known-real-session inventory (read live by the caller),
|
|
157
|
+
* plus, when the route declared field paths, the position rewrite on those
|
|
158
|
+
* paths, re-serialized with a corrected content-length. Past the cap, a
|
|
159
|
+
* non-JSON content type, or a parse failure the answer passes through
|
|
160
|
+
* untouched (the adapter discipline): a rewrite is strictly
|
|
161
|
+
* better-than-nothing, never a reason to mangle or refuse an answer. The
|
|
162
|
+
* caller has already handled the relay's refusal marker — this owns the
|
|
163
|
+
* entire normal answer. */
|
|
164
|
+
async function answerRewriteIds(res, response, known, fields, serverId, knownIds) {
|
|
165
|
+
const contentType = response.headers.get('content-type');
|
|
166
|
+
const verbatimHeaders = {
|
|
167
|
+
...(contentType !== null ? { 'content-type': contentType } : {}),
|
|
168
|
+
...(response.headers.get('content-length') !== null ? { 'content-length': response.headers.get('content-length') } : {}),
|
|
169
|
+
...(response.headers.get('cache-control') !== null ? { 'cache-control': response.headers.get('cache-control') } : {}),
|
|
170
|
+
};
|
|
171
|
+
const reader = response.body?.getReader();
|
|
172
|
+
if (reader === undefined || contentType === null || !contentType.includes('application/json')) {
|
|
173
|
+
res.writeHead(response.status, verbatimHeaders);
|
|
174
|
+
if (reader !== undefined)
|
|
175
|
+
await pumpResponse(res, reader);
|
|
176
|
+
res.end();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const chunks = [];
|
|
180
|
+
let size = 0;
|
|
181
|
+
let overflow = false;
|
|
182
|
+
while (true) {
|
|
183
|
+
const { done, value } = await reader.read();
|
|
184
|
+
if (done)
|
|
185
|
+
break;
|
|
186
|
+
size += value.length;
|
|
187
|
+
// The over-cap chunk ITSELF is body content — it lands in the
|
|
188
|
+
// passthrough buffer too; only the REWRITE is cancelled.
|
|
189
|
+
chunks.push(Buffer.from(value));
|
|
190
|
+
if (size > MAX_ADAPTER_RESPONSE_BUFFER_BYTES) {
|
|
191
|
+
overflow = true;
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (overflow) {
|
|
196
|
+
res.writeHead(response.status, verbatimHeaders);
|
|
197
|
+
for (const chunk of chunks) {
|
|
198
|
+
if (!res.write(chunk)) {
|
|
199
|
+
await new Promise((resolve) => {
|
|
200
|
+
const settled = () => {
|
|
201
|
+
res.off('drain', settled);
|
|
202
|
+
res.off('close', settled);
|
|
203
|
+
resolve();
|
|
204
|
+
};
|
|
205
|
+
res.once('drain', settled);
|
|
206
|
+
res.once('close', settled);
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
await pumpResponse(res, reader);
|
|
211
|
+
res.end();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
215
|
+
let outBody;
|
|
216
|
+
try {
|
|
217
|
+
const scanned = deepRewriteIds(JSON.parse(text), known, knownIds, serverId);
|
|
218
|
+
if (fields !== undefined)
|
|
219
|
+
rewriteIdsAtFields(scanned, fields, serverId);
|
|
220
|
+
outBody = Buffer.from(JSON.stringify(scanned), 'utf8');
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
// Not JSON after all (or the scan hit something unserializable): the
|
|
224
|
+
// original bytes pass through, length recomputed for the corrected
|
|
225
|
+
// header set.
|
|
226
|
+
outBody = Buffer.from(text, 'utf8');
|
|
227
|
+
res.writeHead(response.status, { ...verbatimHeaders, 'content-length': String(outBody.length) });
|
|
228
|
+
res.end(outBody);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
res.writeHead(response.status, { ...verbatimHeaders, 'content-length': String(outBody.length) });
|
|
232
|
+
res.end(outBody);
|
|
233
|
+
}
|
|
34
234
|
/** Pure inspection of the RAW webServer instance — nothing is mutated, so a
|
|
35
235
|
* failed check leaves every route exactly as it was. */
|
|
36
236
|
export function checkWebServerShape(webServer) {
|
|
@@ -51,6 +251,26 @@ export function checkWebServerShape(webServer) {
|
|
|
51
251
|
}
|
|
52
252
|
/** Longest failure ring kept for the status surface. */
|
|
53
253
|
const MAX_FAILURES = 10;
|
|
254
|
+
/** The NormalizedBridgeRoute read through the adapter-core route shapes
|
|
255
|
+
* (PB-21): the declaration-v2 locators must ride the SAME locate →
|
|
256
|
+
* classify → rewrite functions the adapters run — never a second copy of
|
|
257
|
+
* those rules — so the cooperative route is handed to them as the
|
|
258
|
+
* NormalizedAdapterRoute those functions consume. Every field comes from a
|
|
259
|
+
* plugin-bridge effective reader; `deny` stays unset — the cooperative
|
|
260
|
+
* `deny: true` flag is a static declaration, not an adapter-style hook, so
|
|
261
|
+
* the surrogate halves judge it themselves (PB-22). */
|
|
262
|
+
function bridgeRouteAsAdapterRoute(route) {
|
|
263
|
+
return {
|
|
264
|
+
path: route.path,
|
|
265
|
+
match: routePrefixOf(route) ? 'prefix' : 'exact',
|
|
266
|
+
methods: routeMethodsOf(route),
|
|
267
|
+
maxBody: routeMaxBodyOf(route),
|
|
268
|
+
stream: route.stream,
|
|
269
|
+
upgrade: routeUpgradesOf(route),
|
|
270
|
+
locator: routeLocatorOf(route),
|
|
271
|
+
local: false,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
54
274
|
export function installPluginRouteIntercept(options) {
|
|
55
275
|
// Shape FIRST, like every wrap: a caller that skipped checkWebServerShape
|
|
56
276
|
// (or a host whose shape changed between the check and this call) gets an
|
|
@@ -82,6 +302,7 @@ export function installPluginRouteIntercept(options) {
|
|
|
82
302
|
const { webServer, registry, relay, getServerId, getStatus, log } = options;
|
|
83
303
|
const adapters = options.adapters ?? [];
|
|
84
304
|
const getCurrentSession = options.getCurrentSession;
|
|
305
|
+
const getKnownRealIds = options.getKnownRealIds;
|
|
85
306
|
const target = webServer;
|
|
86
307
|
const savedMatch = target.match;
|
|
87
308
|
let installed = true;
|
|
@@ -137,29 +358,42 @@ export function installPluginRouteIntercept(options) {
|
|
|
137
358
|
const handlerOf = (route) => route !== undefined && isRecord(route) && typeof route.handler === 'function'
|
|
138
359
|
? route.handler
|
|
139
360
|
: undefined;
|
|
140
|
-
/** The declared route this pathname hits —
|
|
141
|
-
*
|
|
142
|
-
*
|
|
361
|
+
/** The declared route this pathname hits — exact equality, or, for a
|
|
362
|
+
* route declared `prefix: true` (PB-21), equality or one path segment
|
|
363
|
+
* deeper on the RAW undecoded pathname. The rule is `adapterPathMatches`
|
|
364
|
+
* itself, run against the route's adapter-core view, so the cooperative
|
|
365
|
+
* match can never drift from the adapters'. First plugin in registry
|
|
366
|
+
* order wins, and within it the first route in declaration order — a
|
|
367
|
+
* pathname two routes of one plugin can both hit belongs to the earlier
|
|
368
|
+
* one, exactly as the exact-only lookup always did. */
|
|
143
369
|
const declaredRouteOf = (pathname) => {
|
|
144
370
|
for (const declaration of registry.list()) {
|
|
145
371
|
for (const route of declaration.routes) {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
372
|
+
const view = bridgeRouteAsAdapterRoute(route);
|
|
373
|
+
if (!adapterPathMatches(view, pathname))
|
|
374
|
+
continue;
|
|
375
|
+
return {
|
|
376
|
+
plugin: declaration.plugin,
|
|
377
|
+
version: declaration.version,
|
|
378
|
+
digest: declaration.digest,
|
|
379
|
+
path: route.path,
|
|
380
|
+
locator: routeLocatorOf(route),
|
|
381
|
+
prefix: routePrefixOf(route),
|
|
382
|
+
methods: routeMethodsOf(route),
|
|
383
|
+
maxBody: routeMaxBodyOf(route),
|
|
384
|
+
deny: routeDeniesRemoteOf(route),
|
|
385
|
+
rewriteIds: routeRewritesIdsOf(route),
|
|
386
|
+
rewriteFields: routeRewriteFieldsOf(route),
|
|
387
|
+
view,
|
|
388
|
+
};
|
|
157
389
|
}
|
|
158
390
|
}
|
|
159
391
|
return undefined;
|
|
160
392
|
};
|
|
161
|
-
/** The surrogate handler
|
|
162
|
-
*
|
|
393
|
+
/** The surrogate handler, query locator's half (every 2.1 route and an
|
|
394
|
+
* explicit query declaration): ONE query parse, the virtual-id gate, the
|
|
395
|
+
* unbridged walls in contract order, then the relay round-trip. A
|
|
396
|
+
* declaration-v2 locator dispatches to the PB-21 half below. */
|
|
163
397
|
const surrogateHandler = (declared, originalHandler) => async (req, res) => {
|
|
164
398
|
const passthrough = async () => {
|
|
165
399
|
if (originalHandler !== undefined) {
|
|
@@ -175,17 +409,29 @@ export function installPluginRouteIntercept(options) {
|
|
|
175
409
|
await passthrough();
|
|
176
410
|
return;
|
|
177
411
|
}
|
|
412
|
+
// PB-21: a v2 locator (path-segment / json-body) runs the adapter-core
|
|
413
|
+
// pipeline in its own half — the query locator keeps THIS code path
|
|
414
|
+
// verbatim. The dispatch sits after the installed check, so the v2
|
|
415
|
+
// half never runs chained-over either.
|
|
416
|
+
if (declared.locator.in !== 'query') {
|
|
417
|
+
await surrogateV2Handler(declared, originalHandler)(req, res);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
178
420
|
// ONE parse of the query (the session parameter's home — `match` only
|
|
179
|
-
// ever saw the pathname): everything below reads THIS parse.
|
|
180
|
-
|
|
421
|
+
// ever saw the pathname): everything below reads THIS parse. The
|
|
422
|
+
// parameter NAME is the effective locator's (the 2.1 sessionParam for
|
|
423
|
+
// a route that declared no locator — the same value routeLocatorOf
|
|
424
|
+
// reads back out).
|
|
425
|
+
let requestUrl;
|
|
181
426
|
try {
|
|
182
|
-
|
|
427
|
+
requestUrl = new URL(req.url ?? '/', 'http://local');
|
|
183
428
|
}
|
|
184
429
|
catch {
|
|
185
430
|
answerUnbridged(res, declared.plugin, 'bad-query');
|
|
186
431
|
return;
|
|
187
432
|
}
|
|
188
|
-
const
|
|
433
|
+
const searchParams = requestUrl.searchParams;
|
|
434
|
+
const values = searchParams.getAll(declared.locator.name);
|
|
189
435
|
// The parameter appearing MORE than once is a shape violation the
|
|
190
436
|
// bridge refuses (bad-query) — never a passthrough, and never a
|
|
191
437
|
// forward of whichever duplicate a parser happens to keep. Its TOTAL
|
|
@@ -196,6 +442,20 @@ export function installPluginRouteIntercept(options) {
|
|
|
196
442
|
return;
|
|
197
443
|
}
|
|
198
444
|
const value = values.length === 1 ? values[0] : undefined;
|
|
445
|
+
// PB-22: a `deny: true` route refuses a REMOTE context outright — the
|
|
446
|
+
// request locating a virtual id, or the browser's CURRENT session
|
|
447
|
+
// being one (T62's signal; requests like open.external carry no id of
|
|
448
|
+
// their own). The uniform 503 with the route's own reason: the local
|
|
449
|
+
// handler is never reached, and the forward path below is unreachable
|
|
450
|
+
// for this route. A local session's request without a virtual id falls
|
|
451
|
+
// through — `deny` must not touch local behavior.
|
|
452
|
+
if (declared.deny) {
|
|
453
|
+
const current = getCurrentSession?.();
|
|
454
|
+
if ((current !== undefined && isVirtual(current)) || (value !== undefined && value.startsWith('zr~'))) {
|
|
455
|
+
answerUnbridged(res, declared.plugin, 'route-denied');
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
199
459
|
// A LOCAL id (or none at all): the original route's business — its
|
|
200
460
|
// handler runs exactly as if we were never here.
|
|
201
461
|
if (value === undefined || value === '' || !value.startsWith('zr~')) {
|
|
@@ -246,7 +506,15 @@ export function installPluginRouteIntercept(options) {
|
|
|
246
506
|
log?.('plugin route %s forwards for %s', declared.path, declared.plugin);
|
|
247
507
|
// The session id rides the CHECKED parse, restored to its ORIGINAL
|
|
248
508
|
// form, re-serialized once — what was judged is what is forwarded.
|
|
249
|
-
searchParams.set(declared.
|
|
509
|
+
searchParams.set(declared.locator.name, parts.id);
|
|
510
|
+
// PB-22: the request's own real→virtual pair — the READ-ONLY mapping a
|
|
511
|
+
// rewriteIds route's answer scan consults (`zr~` carries the real id
|
|
512
|
+
// inside, so the ids this very request named are exactly the ones the
|
|
513
|
+
// answer may hand back). PB-25b: the scan consults ONE more source
|
|
514
|
+
// beside this mapping — the known-real-session inventory, read live at
|
|
515
|
+
// the answer (never copied here) and consumed read-only; nothing here
|
|
516
|
+
// writes any session table.
|
|
517
|
+
const idMapping = new Map([[parts.id, value]]);
|
|
250
518
|
const controller = new AbortController();
|
|
251
519
|
// The counting gate's failure has its OWN abort signal (PB-9-fix, the
|
|
252
520
|
// mirror of the relay route's bodyGateError): tripping the gate is a
|
|
@@ -301,7 +569,11 @@ export function installPluginRouteIntercept(options) {
|
|
|
301
569
|
plugin: declared.plugin,
|
|
302
570
|
version: declared.version,
|
|
303
571
|
digest: declared.digest,
|
|
304
|
-
|
|
572
|
+
// A prefix route (PB-21) forwards its FULL matched pathname —
|
|
573
|
+
// the declared base alone would lose the variable tail; an
|
|
574
|
+
// exact route's pathname IS the declared path, the bytes that
|
|
575
|
+
// always travelled.
|
|
576
|
+
path: declared.prefix ? requestUrl.pathname : declared.path,
|
|
305
577
|
query: searchParams.toString(),
|
|
306
578
|
method,
|
|
307
579
|
...(hasBody ? { body: bodyStream } : {}),
|
|
@@ -319,6 +591,17 @@ export function installPluginRouteIntercept(options) {
|
|
|
319
591
|
answerUnbridged(res, declared.plugin, refusal);
|
|
320
592
|
return;
|
|
321
593
|
}
|
|
594
|
+
// PB-22: a rewriteIds route's answer is buffered and deep-scanned
|
|
595
|
+
// (the ids this request named come back virtual); everything else —
|
|
596
|
+
// non-JSON answers included — streams verbatim. PB-25a: a
|
|
597
|
+
// `{ fields }` declaration adds the position rewrite, under THIS
|
|
598
|
+
// request's own server id. PB-25b: the scan's lookup widens to the
|
|
599
|
+
// known inventory, read live right here (undefined when the wiring
|
|
600
|
+
// passed no getter — the scan stays request-local).
|
|
601
|
+
if (declared.rewriteIds) {
|
|
602
|
+
await answerRewriteIds(res, response, idMapping, declared.rewriteFields, parts.serverId, getKnownRealIds?.());
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
322
605
|
const contentType = response.headers.get('content-type');
|
|
323
606
|
const contentLength = response.headers.get('content-length');
|
|
324
607
|
const cacheControl = response.headers.get('cache-control');
|
|
@@ -330,24 +613,8 @@ export function installPluginRouteIntercept(options) {
|
|
|
330
613
|
// Stream the body through with real backpressure; a mid-stream
|
|
331
614
|
// failure with headers already out can only cut the response.
|
|
332
615
|
const reader = response.body?.getReader();
|
|
333
|
-
if (reader !== undefined)
|
|
334
|
-
|
|
335
|
-
const { done, value } = await reader.read();
|
|
336
|
-
if (done)
|
|
337
|
-
break;
|
|
338
|
-
if (!res.write(value)) {
|
|
339
|
-
await new Promise((resolve) => {
|
|
340
|
-
const settled = () => {
|
|
341
|
-
res.off('drain', settled);
|
|
342
|
-
res.off('close', settled);
|
|
343
|
-
resolve();
|
|
344
|
-
};
|
|
345
|
-
res.once('drain', settled);
|
|
346
|
-
res.once('close', settled);
|
|
347
|
-
});
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
}
|
|
616
|
+
if (reader !== undefined)
|
|
617
|
+
await pumpResponse(res, reader);
|
|
351
618
|
res.end();
|
|
352
619
|
}
|
|
353
620
|
catch (error) {
|
|
@@ -386,6 +653,308 @@ export function installPluginRouteIntercept(options) {
|
|
|
386
653
|
res.off('close', onClientGone);
|
|
387
654
|
}
|
|
388
655
|
};
|
|
656
|
+
/** The PB-21 surrogate half for the declaration-v2 locators — json-body,
|
|
657
|
+
* and path-segment on a prefix route: the adapter surrogate's pipeline
|
|
658
|
+
* (locate → classify → walls → rewrite → the relay round-trip) run
|
|
659
|
+
* through the SAME adapter-core functions against the route's
|
|
660
|
+
* adapter-core view. The response discipline stays the query half's —
|
|
661
|
+
* three headers, no hooks: `deny` and `rewriteIds` are PB-22's business.
|
|
662
|
+
* Dispatch above guarantees `installed` and a non-query locator. */
|
|
663
|
+
const surrogateV2Handler = (declared, originalHandler) => async (req, res) => {
|
|
664
|
+
const plugin = declared.plugin;
|
|
665
|
+
// The local passthrough. A json-body route has ALREADY consumed the
|
|
666
|
+
// request body by the time this runs (the classify needs the parsed
|
|
667
|
+
// body), so the replay hands the buffered bytes back behind the
|
|
668
|
+
// original request's metadata — the shape a local handler actually
|
|
669
|
+
// reads (method/url/headers/pipe).
|
|
670
|
+
const passthrough = async (replayBody) => {
|
|
671
|
+
if (originalHandler === undefined) {
|
|
672
|
+
res.writeHead(404);
|
|
673
|
+
res.end();
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
if (replayBody !== undefined) {
|
|
677
|
+
const replay = new PassThrough();
|
|
678
|
+
replay.end(replayBody);
|
|
679
|
+
Object.assign(replay, { method: req.method, url: req.url, headers: req.headers });
|
|
680
|
+
await originalHandler(replay, res);
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
await originalHandler(req, res);
|
|
684
|
+
};
|
|
685
|
+
let controller;
|
|
686
|
+
const onClientGone = () => {
|
|
687
|
+
if (controller !== undefined && !res.writableEnded)
|
|
688
|
+
controller.abort();
|
|
689
|
+
};
|
|
690
|
+
res.on('close', onClientGone);
|
|
691
|
+
// The counting gate's LOCAL flag (the query half's PB-9-fix shape): a
|
|
692
|
+
// tripped gate answers the local 413 in the catch below — never an
|
|
693
|
+
// offline verdict, never a half-written response.
|
|
694
|
+
let bodyGateFailed = false;
|
|
695
|
+
const bodyAbort = new AbortController();
|
|
696
|
+
try {
|
|
697
|
+
let url;
|
|
698
|
+
try {
|
|
699
|
+
url = new URL(req.url ?? '/', 'http://local');
|
|
700
|
+
}
|
|
701
|
+
catch {
|
|
702
|
+
answerUnbridged(res, plugin, 'bad-query');
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
const pathname = url.pathname;
|
|
706
|
+
const searchParams = url.searchParams;
|
|
707
|
+
const method = req.method ?? 'GET';
|
|
708
|
+
const hasBody = method !== 'GET' && method !== 'HEAD';
|
|
709
|
+
// PB-22: the deny wall's current-session half — a remote panel is
|
|
710
|
+
// the request's origin even when it carries no id of its own
|
|
711
|
+
// (open.external's shape). Refused before any body byte is read.
|
|
712
|
+
if (declared.deny) {
|
|
713
|
+
const current = getCurrentSession?.();
|
|
714
|
+
if (current !== undefined && isVirtual(current)) {
|
|
715
|
+
answerUnbridged(res, plugin, 'route-denied');
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
// A json-body route buffers the request body up front, bounded by
|
|
720
|
+
// the route's own maxBody — the cooperative 413 shape, answered
|
|
721
|
+
// locally, the plugin never reached. A path-segment route's body is
|
|
722
|
+
// never consumed by locating; it streams through the counting gate
|
|
723
|
+
// at the forward.
|
|
724
|
+
let bufferedBody;
|
|
725
|
+
if (hasBody && declared.locator.in === 'json-body') {
|
|
726
|
+
const chunks = [];
|
|
727
|
+
let size = 0;
|
|
728
|
+
let over = false;
|
|
729
|
+
for await (const chunk of req) {
|
|
730
|
+
size += chunk.length;
|
|
731
|
+
if (size > declared.maxBody) {
|
|
732
|
+
over = true;
|
|
733
|
+
break;
|
|
734
|
+
}
|
|
735
|
+
chunks.push(Buffer.from(chunk));
|
|
736
|
+
}
|
|
737
|
+
if (over) {
|
|
738
|
+
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
739
|
+
res.writeHead(413);
|
|
740
|
+
res.end(JSON.stringify({ ok: false, error: { code: 'zr-plugin-body-too-large' } }));
|
|
741
|
+
recordRefusal(plugin, 'body-too-large');
|
|
742
|
+
log?.('plugin request for %s refused locally: body-too-large (%s buffered)', declared.path, size);
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
bufferedBody = Buffer.concat(chunks);
|
|
746
|
+
}
|
|
747
|
+
// A present body must parse as JSON when the route locates in it —
|
|
748
|
+
// a corrupted body refuses rather than falling through to the local
|
|
749
|
+
// plugin with a body the locate could not judge (the adapter half's
|
|
750
|
+
// shared rule).
|
|
751
|
+
let parsedBody;
|
|
752
|
+
let bodyGiven = false;
|
|
753
|
+
if (hasBody && declared.locator.in === 'json-body' && bufferedBody !== undefined && bufferedBody.length > 0) {
|
|
754
|
+
try {
|
|
755
|
+
parsedBody = JSON.parse(bufferedBody.toString('utf8'));
|
|
756
|
+
bodyGiven = true;
|
|
757
|
+
}
|
|
758
|
+
catch {
|
|
759
|
+
answerUnbridged(res, plugin, 'bad-query');
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
// The remote/local verdict on the ids as they arrived — the
|
|
764
|
+
// adapter-core classify (locate inside), no second copy of the
|
|
765
|
+
// rule: every id virtual → forward, none → local, a mix → the
|
|
766
|
+
// uniform refusal (docs/adr/0001: a remote session never reaches a
|
|
767
|
+
// local plugin).
|
|
768
|
+
let verdict;
|
|
769
|
+
try {
|
|
770
|
+
verdict = classifyAdapterRequest(declared.view, pathname, searchParams, bodyGiven ? parsedBody : undefined);
|
|
771
|
+
}
|
|
772
|
+
catch (error) {
|
|
773
|
+
if (error instanceof AdapterMultiValueQueryError) {
|
|
774
|
+
answerUnbridged(res, plugin, 'bad-query');
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
throw error;
|
|
778
|
+
}
|
|
779
|
+
// PB-22: the deny wall's request half — the locate found a virtual
|
|
780
|
+
// id (a remote verdict, or a mixed one: that too means a virtual id
|
|
781
|
+
// sat in the request). This route never serves a remote context, so
|
|
782
|
+
// the answer is the route's own refusal, not the mixed shape.
|
|
783
|
+
if (declared.deny && verdict.kind !== 'local') {
|
|
784
|
+
answerUnbridged(res, plugin, 'route-denied');
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
if (verdict.kind === 'local') {
|
|
788
|
+
await passthrough(bufferedBody);
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
if (verdict.kind === 'mixed') {
|
|
792
|
+
answerUnbridged(res, plugin, 'bad-query');
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
// Remote: the route's method wall, then the per-id server walls —
|
|
796
|
+
// the query half's gates, re-run per located id.
|
|
797
|
+
if (!declared.methods.includes(method)) {
|
|
798
|
+
answerUnbridged(res, plugin, 'method-not-bridged');
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
const serverId = getServerId();
|
|
802
|
+
// PB-22: the request's own real→virtual pairs, collected while the
|
|
803
|
+
// wall validates each hit — the READ-ONLY mapping a rewriteIds
|
|
804
|
+
// route's answer scan consults (`zr~` carries the real id inside, so
|
|
805
|
+
// the ids this very request named are exactly the ones the answer
|
|
806
|
+
// may hand back). PB-25a: the SAME unwrap hands the position rewrite
|
|
807
|
+
// its server id — the ids this request located live under it, so a
|
|
808
|
+
// NEWLY-created id joins them (the query half's source verbatim;
|
|
809
|
+
// `remote` always has at least one hit). PB-25b: the scan consults
|
|
810
|
+
// ONE more source beside this mapping — the known-real-session
|
|
811
|
+
// inventory, read live at the answer (never copied here) and
|
|
812
|
+
// consumed read-only; nothing here writes any session table.
|
|
813
|
+
const idMapping = new Map();
|
|
814
|
+
let locatedServerId;
|
|
815
|
+
for (const idHit of verdict.hits) {
|
|
816
|
+
const parts = fromVirtual(idHit.value);
|
|
817
|
+
if (parts === undefined || parts.id === '') {
|
|
818
|
+
answerUnbridged(res, plugin, 'bad-query');
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
if (parts.serverId !== serverId) {
|
|
822
|
+
answerUnbridged(res, plugin, 'server-changed');
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
idMapping.set(parts.id, idHit.value);
|
|
826
|
+
locatedServerId = parts.serverId;
|
|
827
|
+
}
|
|
828
|
+
// The bridge verdicts judged from the SAME live table the query
|
|
829
|
+
// half reads.
|
|
830
|
+
const status = getStatus().find((entry) => entry.plugin === plugin);
|
|
831
|
+
if (status === undefined || !status.bridged) {
|
|
832
|
+
answerUnbridged(res, plugin, status?.reason ?? 'not-on-server');
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
// Real ids in, coordinates rewritten — the adapter-core rewrite:
|
|
836
|
+
// the query re-serialized, the path segment re-encoded, the JSON
|
|
837
|
+
// body re-serialized. The forward carries the REWRITTEN pathname —
|
|
838
|
+
// a prefix route's full matched path, never the declared base.
|
|
839
|
+
const rewrite = rewriteAdapterRequest(declared.view, pathname, searchParams, bodyGiven ? parsedBody : undefined, verdict.hits, (virtualId) => fromVirtual(virtualId).id);
|
|
840
|
+
forwarded += 1;
|
|
841
|
+
log?.('plugin route %s forwards for %s', rewrite.pathname, plugin);
|
|
842
|
+
controller = new AbortController();
|
|
843
|
+
const forwardSignal = AbortSignal.any([controller.signal, bodyAbort.signal]);
|
|
844
|
+
const accept = typeof req.headers.accept === 'string' ? req.headers.accept : undefined;
|
|
845
|
+
const range = typeof req.headers.range === 'string' ? req.headers.range : undefined;
|
|
846
|
+
const requestContentType = typeof req.headers['content-type'] === 'string' ? req.headers['content-type'] : undefined;
|
|
847
|
+
const declaredLength = Number(req.headers['content-length']);
|
|
848
|
+
let bodyStream = null;
|
|
849
|
+
let contentLength;
|
|
850
|
+
if (hasBody && declared.locator.in === 'json-body') {
|
|
851
|
+
// The buffer forwards in full: the (possibly rewritten)
|
|
852
|
+
// re-serialization when ids sat in it, the original bytes
|
|
853
|
+
// otherwise. The REWRITTEN byte count is the declared length —
|
|
854
|
+
// the budget reads what actually travels.
|
|
855
|
+
const payload = rewrite.bodyText !== undefined ? Buffer.from(rewrite.bodyText, 'utf8') : bufferedBody ?? Buffer.alloc(0);
|
|
856
|
+
contentLength = payload.length;
|
|
857
|
+
bodyStream = Readable.toWeb(Readable.from(payload));
|
|
858
|
+
}
|
|
859
|
+
else if (hasBody) {
|
|
860
|
+
// The pre-check first — a DECLARED Content-Length past the cap
|
|
861
|
+
// never starts a round-trip — then the counting gate for a
|
|
862
|
+
// chunked body, exactly the query half's PB-9 discipline.
|
|
863
|
+
if (Number.isFinite(declaredLength) && declaredLength > declared.maxBody) {
|
|
864
|
+
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
865
|
+
res.writeHead(413);
|
|
866
|
+
res.end(JSON.stringify({ ok: false, error: { code: 'zr-plugin-body-too-large' } }));
|
|
867
|
+
recordRefusal(plugin, 'body-too-large');
|
|
868
|
+
log?.('plugin request for %s refused locally: body-too-large (%s declared)', declared.path, req.headers['content-length']);
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
let pumped = 0;
|
|
872
|
+
const gate = new Transform({
|
|
873
|
+
transform(chunk, _encoding, callback) {
|
|
874
|
+
pumped += chunk.length;
|
|
875
|
+
if (pumped > declared.maxBody) {
|
|
876
|
+
callback(new Error('zr-plugin-body-too-large'));
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
callback(null, chunk);
|
|
880
|
+
},
|
|
881
|
+
});
|
|
882
|
+
req.on('error', (error) => gate.destroy(error));
|
|
883
|
+
gate.on('error', () => {
|
|
884
|
+
if (!bodyGateFailed)
|
|
885
|
+
bodyGateFailed = true;
|
|
886
|
+
bodyAbort.abort();
|
|
887
|
+
});
|
|
888
|
+
bodyStream = Readable.toWeb(gate);
|
|
889
|
+
req.pipe(gate);
|
|
890
|
+
if (Number.isFinite(declaredLength) && declaredLength > 0)
|
|
891
|
+
contentLength = declaredLength;
|
|
892
|
+
}
|
|
893
|
+
const response = await relay.pluginRequest({
|
|
894
|
+
plugin,
|
|
895
|
+
version: declared.version,
|
|
896
|
+
digest: declared.digest,
|
|
897
|
+
path: rewrite.pathname,
|
|
898
|
+
query: rewrite.query.toString(),
|
|
899
|
+
method,
|
|
900
|
+
...(bodyStream !== null ? { body: bodyStream } : {}),
|
|
901
|
+
...(bodyStream !== null && requestContentType !== undefined ? { contentType: requestContentType } : {}),
|
|
902
|
+
...(bodyStream !== null && contentLength !== undefined ? { contentLength } : {}),
|
|
903
|
+
...(accept !== undefined ? { accept } : {}),
|
|
904
|
+
...(range !== undefined ? { range } : {}),
|
|
905
|
+
}, forwardSignal);
|
|
906
|
+
// Down from here the query half's response discipline verbatim: the
|
|
907
|
+
// relay refusal marker, the three cooperative headers, the pump
|
|
908
|
+
// with real backpressure — plus (PB-22) the rewriteIds branch.
|
|
909
|
+
const refusal = response.headers.get('x-zen-remote-error');
|
|
910
|
+
if (refusal !== null) {
|
|
911
|
+
await response.body?.cancel().catch(() => { });
|
|
912
|
+
answerUnbridged(res, plugin, refusal);
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
if (declared.rewriteIds) {
|
|
916
|
+
await answerRewriteIds(res, response, idMapping, declared.rewriteFields, locatedServerId, getKnownRealIds?.());
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
const contentType = response.headers.get('content-type');
|
|
920
|
+
const responseLength = response.headers.get('content-length');
|
|
921
|
+
const cacheControl = response.headers.get('cache-control');
|
|
922
|
+
res.writeHead(response.status, {
|
|
923
|
+
...(contentType !== null ? { 'content-type': contentType } : {}),
|
|
924
|
+
...(responseLength !== null ? { 'content-length': responseLength } : {}),
|
|
925
|
+
...(cacheControl !== null ? { 'cache-control': cacheControl } : {}),
|
|
926
|
+
});
|
|
927
|
+
const reader = response.body?.getReader();
|
|
928
|
+
if (reader !== undefined)
|
|
929
|
+
await pumpResponse(res, reader);
|
|
930
|
+
res.end();
|
|
931
|
+
}
|
|
932
|
+
catch (error) {
|
|
933
|
+
// The gate's local flag first (the query half's shape): the 413
|
|
934
|
+
// still fits while the browser's headers are not out.
|
|
935
|
+
if (bodyGateFailed && !res.headersSent) {
|
|
936
|
+
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
937
|
+
res.writeHead(413);
|
|
938
|
+
res.end(JSON.stringify({ ok: false, error: { code: 'zr-plugin-body-too-large' } }));
|
|
939
|
+
recordRefusal(plugin, 'body-too-large');
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
if (controller?.signal.aborted || res.destroyed || res.headersSent) {
|
|
943
|
+
try {
|
|
944
|
+
res.destroy();
|
|
945
|
+
}
|
|
946
|
+
catch {
|
|
947
|
+
// Already gone.
|
|
948
|
+
}
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
const code = typeof error?.code === 'string' ? error.code : 'offline';
|
|
952
|
+
answerUnbridged(res, plugin, code === 'aborted' ? 'offline' : code);
|
|
953
|
+
}
|
|
954
|
+
finally {
|
|
955
|
+
res.off('close', onClientGone);
|
|
956
|
+
}
|
|
957
|
+
};
|
|
389
958
|
// ---- PB-11: the adapter interception ---------------------------------------
|
|
390
959
|
//
|
|
391
960
|
// An adapter (src/adapters/, validated into a NormalizedAdapter by the
|
|
@@ -662,24 +1231,8 @@ export function installPluginRouteIntercept(options) {
|
|
|
662
1231
|
...Object.assign({}, ...allowlistedHeaders),
|
|
663
1232
|
});
|
|
664
1233
|
const reader = response.body?.getReader();
|
|
665
|
-
if (reader !== undefined)
|
|
666
|
-
|
|
667
|
-
const { done, value } = await reader.read();
|
|
668
|
-
if (done)
|
|
669
|
-
break;
|
|
670
|
-
if (!res.write(value)) {
|
|
671
|
-
await new Promise((resolve) => {
|
|
672
|
-
const settled = () => {
|
|
673
|
-
res.off('drain', settled);
|
|
674
|
-
res.off('close', settled);
|
|
675
|
-
resolve();
|
|
676
|
-
};
|
|
677
|
-
res.once('drain', settled);
|
|
678
|
-
res.once('close', settled);
|
|
679
|
-
});
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
|
-
}
|
|
1234
|
+
if (reader !== undefined)
|
|
1235
|
+
await pumpResponse(res, reader);
|
|
683
1236
|
res.end();
|
|
684
1237
|
return;
|
|
685
1238
|
}
|
|
@@ -724,22 +1277,7 @@ export function installPluginRouteIntercept(options) {
|
|
|
724
1277
|
});
|
|
725
1278
|
}
|
|
726
1279
|
}
|
|
727
|
-
|
|
728
|
-
const { done, value } = await reader.read();
|
|
729
|
-
if (done)
|
|
730
|
-
break;
|
|
731
|
-
if (!res.write(value)) {
|
|
732
|
-
await new Promise((resolve) => {
|
|
733
|
-
const settled = () => {
|
|
734
|
-
res.off('drain', settled);
|
|
735
|
-
res.off('close', settled);
|
|
736
|
-
resolve();
|
|
737
|
-
};
|
|
738
|
-
res.once('drain', settled);
|
|
739
|
-
res.once('close', settled);
|
|
740
|
-
});
|
|
741
|
-
}
|
|
742
|
-
}
|
|
1280
|
+
await pumpResponse(res, reader);
|
|
743
1281
|
res.end();
|
|
744
1282
|
return;
|
|
745
1283
|
}
|
|
@@ -796,9 +1334,10 @@ export function installPluginRouteIntercept(options) {
|
|
|
796
1334
|
/** The wrapped match: original verdict first, then the adapter routes
|
|
797
1335
|
* (PB-11 — BEFORE the declaration lookup: the adapter's registry entry
|
|
798
1336
|
* IS a declaration, and the cooperative surrogate's query-sessionParam
|
|
799
|
-
* logic is not how an adapter request is judged), then the
|
|
800
|
-
*
|
|
801
|
-
*
|
|
1337
|
+
* logic is not how an adapter request is judged), then the declaration
|
|
1338
|
+
* lookup — exact paths plus (PB-21) a declared prefix route's full
|
|
1339
|
+
* match. An undeclared pathname returns whatever the original match
|
|
1340
|
+
* answered, untouched. */
|
|
802
1341
|
const wrappedMatch = function match(pathname) {
|
|
803
1342
|
const route = savedMatch.call(this, pathname);
|
|
804
1343
|
if (!installed)
|
|
@@ -848,6 +1387,7 @@ export function installPluginRouteIntercept(options) {
|
|
|
848
1387
|
digest: declaration.digest,
|
|
849
1388
|
path: route.path,
|
|
850
1389
|
sessionParam: route.sessionParam,
|
|
1390
|
+
deny: routeDeniesRemoteOf(route),
|
|
851
1391
|
};
|
|
852
1392
|
}
|
|
853
1393
|
}
|
|
@@ -890,6 +1430,18 @@ export function installPluginRouteIntercept(options) {
|
|
|
890
1430
|
return;
|
|
891
1431
|
}
|
|
892
1432
|
const value = values.length === 1 ? values[0] : undefined;
|
|
1433
|
+
// PB-22: the deny wall on the tunnel — the HTTP surrogate's rule
|
|
1434
|
+
// verbatim. A remote context (the request's own virtual id, or the
|
|
1435
|
+
// browser's reported current session) never opens a byte tunnel into
|
|
1436
|
+
// a route declared `deny: true`; a local session's upgrade passes
|
|
1437
|
+
// through untouched.
|
|
1438
|
+
if (declared.deny) {
|
|
1439
|
+
const current = getCurrentSession?.();
|
|
1440
|
+
if ((current !== undefined && isVirtual(current)) || (value !== undefined && value.startsWith('zr~'))) {
|
|
1441
|
+
answerUnbridgedSocket(socket, declared.plugin, 'route-denied');
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
893
1445
|
// A LOCAL session (or no parameter at all): the original upgrade
|
|
894
1446
|
// handler's business, the head bytes untouched.
|
|
895
1447
|
if (value === undefined || value === '' || !value.startsWith('zr~')) {
|