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
package/src/plugin-bridge.ts
CHANGED
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
* ({@link createBridgeRegistry}), and the local-vs-server comparison that a
|
|
12
12
|
* later task feeds handshake data into ({@link compareBridges}).
|
|
13
13
|
*
|
|
14
|
-
* Pure logic by design: no DSH imports
|
|
14
|
+
* Pure logic by design: no DSH imports (the one exception is the
|
|
15
|
+
* pure-constants src/upload-limits.ts, itself zero-import), no I/O, no
|
|
16
|
+
* clocks — the digest is
|
|
15
17
|
* the only place node:crypto appears, which is fine because this module only
|
|
16
18
|
* ever runs in the backend process. Nothing here talks to the relay or the
|
|
17
19
|
* network; the handshake side and the request rewrite side are later tasks
|
|
@@ -19,6 +21,7 @@
|
|
|
19
21
|
*/
|
|
20
22
|
|
|
21
23
|
import { createHash } from 'node:crypto'
|
|
24
|
+
import { MAX_UPLOAD_BYTES } from './upload-limits.js'
|
|
22
25
|
|
|
23
26
|
/** Where a bridged route's caller supplies the session id. `'session'` — the
|
|
24
27
|
* data is scoped to one session, forwarded after the server checks the share.
|
|
@@ -30,12 +33,48 @@ export type BridgeScope = 'session' | 'server'
|
|
|
30
33
|
* only; the write methods arrived with the `methods` field. */
|
|
31
34
|
export const BRIDGE_METHODS = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'] as const
|
|
32
35
|
|
|
33
|
-
/** The body cap a route may declare (`maxBody`), in bytes
|
|
34
|
-
|
|
36
|
+
/** The body cap a route may declare (`maxBody`), in bytes. This is the SAME
|
|
37
|
+
* single 500 MiB ceiling {@link MAX_UPLOAD_BYTES} defines in
|
|
38
|
+
* src/upload-limits.ts, re-exported under the bridge-local name so existing
|
|
39
|
+
* consumers stay untouched: three ceilings that used to be three numbers —
|
|
40
|
+
* the relay's `/v1/upload` route, the sub-client's streaming upload forward
|
|
41
|
+
* (both reading upload-limits.ts directly) and this bridge `maxBody` cap
|
|
42
|
+
* (hand-copied 64 MiB before declaration-v2) — now share one definition, so
|
|
43
|
+
* they can never drift apart again. */
|
|
44
|
+
export { MAX_UPLOAD_BYTES as MAX_ROUTE_BODY_BYTES }
|
|
35
45
|
|
|
36
46
|
/** The body cap a route gets when it declares none: 1 MiB. */
|
|
37
47
|
export const DEFAULT_ROUTE_BODY_BYTES = 1024 * 1024
|
|
38
48
|
|
|
49
|
+
/** The `fields` form of a route's `rewriteIds` (PB-25a): the DOT PATHS where
|
|
50
|
+
* the response's NEWLY-CREATED session ids sit — ids a creation route mints
|
|
51
|
+
* (`sidechat.start`'s `childId`), which were never in the request and so are
|
|
52
|
+
* invisible to the value deep-scan. Each path walks objects only: segments
|
|
53
|
+
* of `[A-Za-z0-9_-]`, non-empty, no wildcards, no numeric (array-index)
|
|
54
|
+
* segments — array elements stay the value scan's business. */
|
|
55
|
+
export interface BridgeRewriteFields {
|
|
56
|
+
fields: readonly string[]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A route's `rewriteIds` value: `true` — deep-scan the answer for the
|
|
60
|
+
* request's own ids (the 2.4 echo shape) — or the {@link BridgeRewriteFields}
|
|
61
|
+
* object, which adds the position-based rewrite on top. The two are
|
|
62
|
+
* DIFFERENT declarations: each hashes its own way, and an old `true`
|
|
63
|
+
* declaration's digest is bit-for-bit what it always was. */
|
|
64
|
+
export type BridgeRewriteIds = true | BridgeRewriteFields
|
|
65
|
+
|
|
66
|
+
/** Where a bridged route's caller supplies the session id — the `locator`
|
|
67
|
+
* field (declaration-v2). The same three shapes as the adapter core's
|
|
68
|
+
* `AdapterLocator`: `query` names the search parameter; `path-segment`
|
|
69
|
+
* counts segments AFTER the declared path (a `prefix` route's variable
|
|
70
|
+
* part); `json-body` names TOP-LEVEL fields of the parsed JSON request
|
|
71
|
+
* body. A route without a locator keeps the 2.1 behavior: the session id
|
|
72
|
+
* rides the query parameter named `sessionParam`. */
|
|
73
|
+
export type BridgeLocator =
|
|
74
|
+
| { in: 'query'; name: string }
|
|
75
|
+
| { in: 'path-segment'; index: number }
|
|
76
|
+
| { in: 'json-body'; fields: string[] }
|
|
77
|
+
|
|
39
78
|
/** One forwardable route as a plugin declares it. `sessionParam` names the
|
|
40
79
|
* URL query parameter carrying the session id (default `'sessionId'`);
|
|
41
80
|
* `stream` marks routes answered as an event stream rather than one JSON
|
|
@@ -45,7 +84,19 @@ export const DEFAULT_ROUTE_BODY_BYTES = 1024 * 1024
|
|
|
45
84
|
* (PB-9) caps the forwarded request body in bytes; omitted means
|
|
46
85
|
* {@link DEFAULT_ROUTE_BODY_BYTES}. `upgrade` (PB-10) marks the route as a
|
|
47
86
|
* WebSocket endpoint carried over the byte tunnel — GET-only, mutually
|
|
48
|
-
* exclusive with `stream`.
|
|
87
|
+
* exclusive with `stream`.
|
|
88
|
+
*
|
|
89
|
+
* The four declaration-v2 fields: `locator` replaces the query-parameter
|
|
90
|
+
* session-id convention with an explicit {@link BridgeLocator} (mutually
|
|
91
|
+
* exclusive with `sessionParam`); `prefix: true` admits the route's
|
|
92
|
+
* variable path tail (sub-paths under `path`) — required for a
|
|
93
|
+
* `path-segment` locator, refused together with `upgrade` for now; `deny:
|
|
94
|
+
* true` marks a route the sub-client refuses to serve from a REMOTE
|
|
95
|
+
* is local-only data); `rewriteIds` asks the sub-client to rewrite
|
|
96
|
+
* real↔virtual session ids inside the JSON it forwards — `true` deep-scans
|
|
97
|
+
* for the request's own ids, `{ fields }` additionally wraps the
|
|
98
|
+
* NEWLY-CREATED ids sitting at the declared positions (PB-25a) — never
|
|
99
|
+
* legal on a `stream` route, whose response is never buffered. */
|
|
49
100
|
export interface BridgeRoute {
|
|
50
101
|
path: string
|
|
51
102
|
scope: BridgeScope
|
|
@@ -54,6 +105,10 @@ export interface BridgeRoute {
|
|
|
54
105
|
methods?: readonly string[]
|
|
55
106
|
maxBody?: number
|
|
56
107
|
upgrade?: boolean
|
|
108
|
+
locator?: BridgeLocator
|
|
109
|
+
prefix?: boolean
|
|
110
|
+
deny?: boolean
|
|
111
|
+
rewriteIds?: BridgeRewriteIds
|
|
57
112
|
}
|
|
58
113
|
|
|
59
114
|
/** What a plugin hands `zenRemote.bridge(...)` — the raw, unvalidated shape. */
|
|
@@ -68,7 +123,11 @@ export interface BridgeDeclaration {
|
|
|
68
123
|
* explicitly — the distinction is load-bearing for the digest (see the
|
|
69
124
|
* canonical form in {@link normalizeDeclaration}), while consumers read the
|
|
70
125
|
* EFFECTIVE values through {@link routeMethodsOf} / {@link routeMaxBodyOf}
|
|
71
|
-
* / {@link routeUpgradesOf}.
|
|
126
|
+
* / {@link routeUpgradesOf}. The four declaration-v2 fields (`locator`,
|
|
127
|
+
* `prefix`, `deny`, `rewriteIds`) follow the same explicit-only rule, and
|
|
128
|
+
* each carries its own effective reader:
|
|
129
|
+
* {@link routeLocatorOf} / {@link routePrefixOf} / {@link routeDeniesRemoteOf}
|
|
130
|
+
* / {@link routeRewritesIdsOf} / {@link routeRewriteFieldsOf}. */
|
|
72
131
|
export interface NormalizedBridgeRoute {
|
|
73
132
|
path: string
|
|
74
133
|
scope: BridgeScope
|
|
@@ -77,6 +136,12 @@ export interface NormalizedBridgeRoute {
|
|
|
77
136
|
methods: string[] | undefined
|
|
78
137
|
maxBody: number | undefined
|
|
79
138
|
upgrade: boolean | undefined
|
|
139
|
+
/** Present only when declared — an ABSENT key (not a `locator: undefined`
|
|
140
|
+
* key) keeps the digest-compatible shape and deep-equality of 2.1 routes. */
|
|
141
|
+
locator?: BridgeLocator
|
|
142
|
+
prefix?: true
|
|
143
|
+
deny?: true
|
|
144
|
+
rewriteIds?: BridgeRewriteIds
|
|
80
145
|
}
|
|
81
146
|
|
|
82
147
|
/** The effective upgrade mark of one normalized route. */
|
|
@@ -94,6 +159,41 @@ export function routeMaxBodyOf(route: NormalizedBridgeRoute): number {
|
|
|
94
159
|
return route.maxBody ?? DEFAULT_ROUTE_BODY_BYTES
|
|
95
160
|
}
|
|
96
161
|
|
|
162
|
+
/** The effective session-id locator of one normalized route. A route that
|
|
163
|
+
* declared none keeps the 2.1 convention: the session id rides the query
|
|
164
|
+
* parameter named by `sessionParam`. */
|
|
165
|
+
export function routeLocatorOf(route: NormalizedBridgeRoute): BridgeLocator {
|
|
166
|
+
return route.locator ?? { in: 'query', name: route.sessionParam }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The effective prefix mark of one normalized route: does the route admit
|
|
170
|
+
* its variable path tail (sub-paths under `path`)? */
|
|
171
|
+
export function routePrefixOf(route: NormalizedBridgeRoute): boolean {
|
|
172
|
+
return route.prefix === true
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The effective remote-deny mark of one normalized route: must this route
|
|
176
|
+
* refuse to serve a REMOTE session (local-only data)? */
|
|
177
|
+
export function routeDeniesRemoteOf(route: NormalizedBridgeRoute): boolean {
|
|
178
|
+
return route.deny === true
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The effective id-rewrite mark of one normalized route: does the
|
|
182
|
+
* sub-client rewrite real↔virtual session ids while forwarding? Both
|
|
183
|
+
* `rewriteIds` forms enable it (`true` and `{ fields }` alike) — which form
|
|
184
|
+
* decides only whether the POSITION rewrite joins the value scan. */
|
|
185
|
+
export function routeRewritesIdsOf(route: NormalizedBridgeRoute): boolean {
|
|
186
|
+
return route.rewriteIds !== undefined
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The declared field paths of a `{ fields }`-form `rewriteIds` route
|
|
190
|
+
* (PB-25a) — where the response's NEWLY-CREATED session ids sit. `undefined`
|
|
191
|
+
* for a `true` route and for a route without the field: the position
|
|
192
|
+
* rewrite exists only when explicitly declared. */
|
|
193
|
+
export function routeRewriteFieldsOf(route: NormalizedBridgeRoute): readonly string[] | undefined {
|
|
194
|
+
return typeof route.rewriteIds === 'object' ? route.rewriteIds.fields : undefined
|
|
195
|
+
}
|
|
196
|
+
|
|
97
197
|
/** A declaration after validation: every route field filled, routes sorted
|
|
98
198
|
* by path, and the 16-hex-char digest both ends will compare. */
|
|
99
199
|
export interface NormalizedDeclaration {
|
|
@@ -119,6 +219,13 @@ const PLUGIN_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/u
|
|
|
119
219
|
/** Query-parameter name carrying the session id. */
|
|
120
220
|
const SESSION_PARAM_PATTERN = /^[A-Za-z][A-Za-z0-9_]{0,63}$/u
|
|
121
221
|
|
|
222
|
+
/** One dot-path segment of a `rewriteIds: { fields }` declaration (PB-25a):
|
|
223
|
+
* `[A-Za-z0-9_-]`, non-empty. The full path splits on `.`; a segment of
|
|
224
|
+
* ONLY digits is an array index and refused separately below — array
|
|
225
|
+
* elements are the value deep-scan's business, and a declaration that could
|
|
226
|
+
* never match (the position rewrite walks objects only) must not register. */
|
|
227
|
+
const REWRITE_FIELD_SEGMENT_PATTERN = /^[A-Za-z0-9_-]+$/u
|
|
228
|
+
|
|
122
229
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
123
230
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
124
231
|
}
|
|
@@ -162,11 +269,11 @@ export function normalizeDeclaration(input: unknown): NormalizedDeclaration {
|
|
|
162
269
|
const normalizedRoutes = routes.map((route, index): NormalizedBridgeRoute => {
|
|
163
270
|
if (!isPlainObject(route)) fail(`routes[${index}]`, 'expected an object')
|
|
164
271
|
for (const key of Object.keys(route)) {
|
|
165
|
-
if (key !== 'path' && key !== 'scope' && key !== 'sessionParam' && key !== 'stream' && key !== 'methods' && key !== 'maxBody' && key !== 'upgrade') {
|
|
272
|
+
if (key !== 'path' && key !== 'scope' && key !== 'sessionParam' && key !== 'stream' && key !== 'methods' && key !== 'maxBody' && key !== 'upgrade' && key !== 'locator' && key !== 'prefix' && key !== 'deny' && key !== 'rewriteIds') {
|
|
166
273
|
fail(`routes[${index}].${key}`, 'unknown field (typo guard)')
|
|
167
274
|
}
|
|
168
275
|
}
|
|
169
|
-
const { path, scope, sessionParam, stream, methods, maxBody, upgrade } = route
|
|
276
|
+
const { path, scope, sessionParam, stream, methods, maxBody, upgrade, locator, prefix, deny, rewriteIds } = route
|
|
170
277
|
if (typeof path !== 'string' || path === '') fail(`routes[${index}].path`, 'must be a non-empty string')
|
|
171
278
|
if (!path.startsWith('/')) fail(`routes[${index}].path`, `"${path}" must start with "/"`)
|
|
172
279
|
if (path.includes('?')) fail(`routes[${index}].path`, `"${path}" must not contain "?"`)
|
|
@@ -215,8 +322,8 @@ export function normalizeDeclaration(input: unknown): NormalizedDeclaration {
|
|
|
215
322
|
// default.
|
|
216
323
|
let normalizedMaxBody: number | undefined
|
|
217
324
|
if (maxBody !== undefined) {
|
|
218
|
-
if (typeof maxBody !== 'number' || !Number.isInteger(maxBody) || maxBody <= 0 || maxBody >
|
|
219
|
-
fail(`routes[${index}].maxBody`, `must be a positive integer up to ${
|
|
325
|
+
if (typeof maxBody !== 'number' || !Number.isInteger(maxBody) || maxBody <= 0 || maxBody > MAX_UPLOAD_BYTES) {
|
|
326
|
+
fail(`routes[${index}].maxBody`, `must be a positive integer up to ${MAX_UPLOAD_BYTES}`)
|
|
220
327
|
}
|
|
221
328
|
normalizedMaxBody = maxBody
|
|
222
329
|
}
|
|
@@ -224,6 +331,15 @@ export function normalizeDeclaration(input: unknown): NormalizedDeclaration {
|
|
|
224
331
|
// declaration, not a toggle), it cannot coexist with `stream` (a route
|
|
225
332
|
// is one thing), and it is GET-only — an explicit `methods` naming
|
|
226
333
|
// anything else cannot be an upgrade route.
|
|
334
|
+
// PB-20: `prefix` — a declaration that the route admits its variable
|
|
335
|
+
// path tail (sub-paths under `path`), required by a `path-segment`
|
|
336
|
+
// locator below and refused next to `upgrade` (checked in the upgrade
|
|
337
|
+
// block). Only `true` is legal — this is a declaration, not a toggle.
|
|
338
|
+
let normalizedPrefix: true | undefined
|
|
339
|
+
if (prefix !== undefined) {
|
|
340
|
+
if (prefix !== true) fail(`routes[${index}].prefix`, 'must be true or omitted')
|
|
341
|
+
normalizedPrefix = true
|
|
342
|
+
}
|
|
227
343
|
let normalizedUpgrade: boolean | undefined
|
|
228
344
|
if (upgrade !== undefined) {
|
|
229
345
|
if (upgrade !== true) fail(`routes[${index}].upgrade`, 'must be true or omitted')
|
|
@@ -231,18 +347,112 @@ export function normalizeDeclaration(input: unknown): NormalizedDeclaration {
|
|
|
231
347
|
if (normalizedMethods !== undefined && normalizedMethods.some((entry) => entry !== 'GET')) {
|
|
232
348
|
fail(`routes[${index}].upgrade`, 'upgrade routes are GET-only')
|
|
233
349
|
}
|
|
350
|
+
if (normalizedPrefix === true) {
|
|
351
|
+
fail(`routes[${index}].prefix`, 'cannot be combined with upgrade — a WebSocket tunnel over a prefix route is left for when a need shows up')
|
|
352
|
+
}
|
|
234
353
|
normalizedUpgrade = true
|
|
235
354
|
}
|
|
236
|
-
|
|
355
|
+
// PB-20: `locator` — where the session id lives, REPLACING the
|
|
356
|
+
// query-parameter convention (so the two fields can never coexist; a
|
|
357
|
+
// route without one keeps `sessionParam`'s 2.1 semantics). Shapes and
|
|
358
|
+
// the path-segment/prefix pairing mirror the adapter core's locator
|
|
359
|
+
// (PB-11) so the two ends read identically.
|
|
360
|
+
let normalizedLocator: BridgeLocator | undefined
|
|
361
|
+
if (locator !== undefined) {
|
|
362
|
+
if (sessionParam !== undefined) {
|
|
363
|
+
fail(`routes[${index}].locator`, 'cannot be combined with sessionParam — a route locates its session id one way')
|
|
364
|
+
}
|
|
365
|
+
if (!isPlainObject(locator)) fail(`routes[${index}].locator`, 'expected an object')
|
|
366
|
+
if (locator.in === 'query') {
|
|
367
|
+
if (typeof locator.name !== 'string' || !SESSION_PARAM_PATTERN.test(locator.name)) {
|
|
368
|
+
fail(`routes[${index}].locator.name`, `"${String(locator.name)}" must match ${SESSION_PARAM_PATTERN.source}`)
|
|
369
|
+
}
|
|
370
|
+
normalizedLocator = { in: 'query', name: locator.name }
|
|
371
|
+
} else if (locator.in === 'path-segment') {
|
|
372
|
+
if (typeof locator.index !== 'number' || !Number.isInteger(locator.index) || locator.index < 0) {
|
|
373
|
+
fail(`routes[${index}].locator.index`, 'must be a non-negative integer')
|
|
374
|
+
}
|
|
375
|
+
if (normalizedPrefix !== true) {
|
|
376
|
+
fail(`routes[${index}].locator`, 'path-segment locating needs prefix: true (the id lives in the variable tail only a prefix route admits)')
|
|
377
|
+
}
|
|
378
|
+
normalizedLocator = { in: 'path-segment', index: locator.index }
|
|
379
|
+
} else if (locator.in === 'json-body') {
|
|
380
|
+
if (!Array.isArray(locator.fields) || locator.fields.length === 0 || locator.fields.some((entry) => typeof entry !== 'string' || entry === '')) {
|
|
381
|
+
fail(`routes[${index}].locator.fields`, 'must be a non-empty array of field names')
|
|
382
|
+
}
|
|
383
|
+
normalizedLocator = { in: 'json-body', fields: [...locator.fields] }
|
|
384
|
+
} else {
|
|
385
|
+
fail(`routes[${index}].locator.in`, 'must be "query", "path-segment" or "json-body"')
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
// PB-20: `deny` — the route must refuse a REMOTE session (its data is
|
|
389
|
+
// local-only). Only `true` is legal.
|
|
390
|
+
let normalizedDeny: true | undefined
|
|
391
|
+
if (deny !== undefined) {
|
|
392
|
+
if (deny !== true) fail(`routes[${index}].deny`, 'must be true or omitted')
|
|
393
|
+
normalizedDeny = true
|
|
394
|
+
}
|
|
395
|
+
// PB-20/PB-25a: `rewriteIds` — the sub-client rewrites real↔virtual
|
|
396
|
+
// session ids while forwarding. Two forms: `true` (the 2.4 echo shape —
|
|
397
|
+
// the value deep-scan over the request's own ids) and `{ fields }`
|
|
398
|
+
// (PB-25a — adds the position rewrite where NEWLY-CREATED ids sit).
|
|
399
|
+
// Never legal on a stream route: a streaming response is never buffered,
|
|
400
|
+
// so there is nothing to rewrite. The two forms hash DIFFERENTLY (see
|
|
401
|
+
// the canonical form below); an old `true` declaration's digest is
|
|
402
|
+
// bit-for-bit what it always was.
|
|
403
|
+
let normalizedRewriteIds: BridgeRewriteIds | undefined
|
|
404
|
+
if (rewriteIds !== undefined) {
|
|
405
|
+
if (rewriteIds === true) {
|
|
406
|
+
normalizedRewriteIds = true
|
|
407
|
+
} else if (isPlainObject(rewriteIds)) {
|
|
408
|
+
for (const key of Object.keys(rewriteIds)) {
|
|
409
|
+
if (key !== 'fields') fail(`routes[${index}].rewriteIds.${key}`, 'unknown field (typo guard)')
|
|
410
|
+
}
|
|
411
|
+
const { fields } = rewriteIds
|
|
412
|
+
if (!Array.isArray(fields) || fields.length === 0 || fields.some((entry) => typeof entry !== 'string')) {
|
|
413
|
+
fail(`routes[${index}].rewriteIds.fields`, 'must be a non-empty array of dot paths')
|
|
414
|
+
}
|
|
415
|
+
for (const entry of fields as string[]) {
|
|
416
|
+
const segments = entry.split('.')
|
|
417
|
+
if (segments.some((segment) => !REWRITE_FIELD_SEGMENT_PATTERN.test(segment))) {
|
|
418
|
+
fail(`routes[${index}].rewriteIds.fields`, `"${entry}" must be a dot path of non-empty [A-Za-z0-9_-] segments`)
|
|
419
|
+
}
|
|
420
|
+
if (segments.some((segment) => /^\d+$/u.test(segment))) {
|
|
421
|
+
fail(`routes[${index}].rewriteIds.fields`, `"${entry}" must not carry an array index — array elements are the value deep-scan's business`)
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
normalizedRewriteIds = { fields: [...fields] }
|
|
425
|
+
} else {
|
|
426
|
+
fail(`routes[${index}].rewriteIds`, 'must be true, { fields: [...] } or omitted')
|
|
427
|
+
}
|
|
428
|
+
if (streamFlag) fail(`routes[${index}].rewriteIds`, 'cannot be combined with stream — a streaming response is never buffered, so there are no ids to rewrite')
|
|
429
|
+
}
|
|
430
|
+
// Explicit-only (digest rule): the four v2 fields attach to the
|
|
431
|
+
// normalized route ONLY when declared — an absent key, not an
|
|
432
|
+
// `undefined` one, so the canonical form and the deep-equality shape of
|
|
433
|
+
// 2.1-era routes stay byte-for-byte what they were.
|
|
434
|
+
return {
|
|
435
|
+
path,
|
|
436
|
+
scope,
|
|
437
|
+
sessionParam: param,
|
|
438
|
+
stream: streamFlag,
|
|
439
|
+
methods: normalizedMethods,
|
|
440
|
+
maxBody: normalizedMaxBody,
|
|
441
|
+
upgrade: normalizedUpgrade,
|
|
442
|
+
...(normalizedLocator !== undefined && { locator: normalizedLocator }),
|
|
443
|
+
...(normalizedPrefix && { prefix: normalizedPrefix }),
|
|
444
|
+
...(normalizedDeny && { deny: normalizedDeny }),
|
|
445
|
+
...(normalizedRewriteIds && { rewriteIds: normalizedRewriteIds }),
|
|
446
|
+
}
|
|
237
447
|
})
|
|
238
448
|
normalizedRoutes.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
|
|
239
449
|
|
|
240
450
|
// Fixed key order = the canonical form both ends hash; insertion order of
|
|
241
451
|
// the object literal IS the contract, JSON.stringify never sorts. The
|
|
242
|
-
// route entries carry `methods` / `maxBody`
|
|
243
|
-
// them — `JSON.stringify` drops
|
|
244
|
-
//
|
|
245
|
-
// visible instead of incidental.
|
|
452
|
+
// route entries carry `methods` / `maxBody` / `upgrade` and the four v2
|
|
453
|
+
// fields ONLY when the plugin declared them — `JSON.stringify` drops
|
|
454
|
+
// `undefined` values, but the explicit conditional documents the intent
|
|
455
|
+
// and keeps the PB-1 compatibility visible instead of incidental.
|
|
246
456
|
const canonical = JSON.stringify({
|
|
247
457
|
plugin,
|
|
248
458
|
version,
|
|
@@ -256,6 +466,10 @@ export function normalizeDeclaration(input: unknown): NormalizedDeclaration {
|
|
|
256
466
|
if (route.methods !== undefined) entry.methods = route.methods
|
|
257
467
|
if (route.maxBody !== undefined) entry.maxBody = route.maxBody
|
|
258
468
|
if (route.upgrade !== undefined) entry.upgrade = route.upgrade
|
|
469
|
+
if (route.locator !== undefined) entry.locator = route.locator
|
|
470
|
+
if (route.prefix !== undefined) entry.prefix = route.prefix
|
|
471
|
+
if (route.deny !== undefined) entry.deny = route.deny
|
|
472
|
+
if (route.rewriteIds !== undefined) entry.rewriteIds = route.rewriteIds
|
|
259
473
|
return entry
|
|
260
474
|
}),
|
|
261
475
|
})
|