dsh-zen-remote 2.2.1 → 2.2.3
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 +23 -5
- package/README.zh-CN.md +23 -5
- package/docs/interface.md +1 -1
- package/docs/plugin-bridge.md +1 -1
- package/docs/remote-access.en.md +13 -1
- package/docs/remote-access.md +14 -2
- package/lib/adapters/dsh-better-sidebar/index.js +25 -13
- package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
- package/lib/adapters/dsh-better-sidebar/methods.js +30 -7
- package/lib/adapters/dsh-better-sidebar/methods.js.map +1 -1
- package/lib/admin-routes.js +3 -2
- package/lib/admin-routes.js.map +1 -1
- package/lib/index.js +16 -1
- package/lib/index.js.map +1 -1
- package/lib/intercept-shape.js +10 -4
- package/lib/intercept-shape.js.map +1 -1
- package/lib/intercept.js +47 -12
- package/lib/intercept.js.map +1 -1
- package/lib/invoke-binary.js +126 -0
- package/lib/invoke-binary.js.map +1 -0
- package/lib/owner-peer.js +72 -0
- package/lib/owner-peer.js.map +1 -0
- package/lib/relay-server.js +18 -10
- package/lib/relay-server.js.map +1 -1
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts +16 -11
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
- package/lib/types/adapters/dsh-better-sidebar/methods.d.ts +16 -7
- package/lib/types/adapters/dsh-better-sidebar/methods.d.ts.map +1 -1
- package/lib/types/admin-routes.d.ts +2 -0
- package/lib/types/admin-routes.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/intercept-shape.d.ts.map +1 -1
- package/lib/types/intercept.d.ts +11 -0
- package/lib/types/intercept.d.ts.map +1 -1
- package/lib/types/invoke-binary.d.ts +41 -0
- package/lib/types/invoke-binary.d.ts.map +1 -0
- package/lib/types/owner-peer.d.ts +57 -0
- package/lib/types/owner-peer.d.ts.map +1 -0
- package/lib/types/relay-server.d.ts +8 -0
- package/lib/types/relay-server.d.ts.map +1 -1
- package/llms.txt +1 -1
- package/package.json +36 -36
- package/src/adapters/dsh-better-sidebar/index.ts +25 -13
- package/src/adapters/dsh-better-sidebar/methods.ts +30 -7
- package/src/admin-routes.ts +5 -2
- package/src/index.ts +16 -1
- package/src/intercept-shape.ts +10 -3
- package/src/intercept.ts +55 -9
- package/src/invoke-binary.ts +134 -0
- package/src/owner-peer.ts +81 -0
- package/src/relay-server.ts +27 -11
package/src/intercept.ts
CHANGED
|
@@ -84,6 +84,7 @@ import type { RelayClient, RelayClock, RelayState } from './relay-client.js'
|
|
|
84
84
|
import { checkGatewayShape } from './intercept-shape.js'
|
|
85
85
|
import type { GatewayShapeCheck } from './intercept-shape.js'
|
|
86
86
|
import { fromVirtual, isVirtual, toVirtual } from './virtual-id.js'
|
|
87
|
+
import { INVOKE_BINARY_RESULT_METHODS, extractResultAttachments, restoreInvokeBinary, type ResultAttachment } from './invoke-binary.js'
|
|
87
88
|
import {
|
|
88
89
|
createControlMerger,
|
|
89
90
|
createWorkspaceMerger,
|
|
@@ -240,7 +241,7 @@ export const CLIENT_METHOD_FIELDS: Readonly<Record<string, readonly SessionField
|
|
|
240
241
|
* envelope WE construct carries `details: {}` (the host rpcFailure's
|
|
241
242
|
* fallback). */
|
|
242
243
|
export type DispatchEnvelope =
|
|
243
|
-
| { ok: true; value: unknown }
|
|
244
|
+
| { ok: true; value: unknown; attachments?: ResultAttachment[] }
|
|
244
245
|
| { ok: false; error: { code: string; message?: string; details: Record<string, unknown> } }
|
|
245
246
|
|
|
246
247
|
/**
|
|
@@ -2699,7 +2700,26 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
|
|
|
2699
2700
|
// The session answered: any closed-session entry for it is stale
|
|
2700
2701
|
// (re-shared and served again) — the banner may go.
|
|
2701
2702
|
clearClosed(virtuals)
|
|
2702
|
-
|
|
2703
|
+
// 2026-10-14: a binary-result method's answer arrives marker-encoded
|
|
2704
|
+
// (invoke-binary.ts) — the relay's JSON channel cannot carry a typed
|
|
2705
|
+
// array, so the server marked it and the caller's own zod validation
|
|
2706
|
+
// needs the real Uint8Array back.
|
|
2707
|
+
const restored = INVOKE_BINARY_RESULT_METHODS.has(endpoint) ? restoreInvokeBinary(value) : value
|
|
2708
|
+
// 2026-10-14 (root cause, live-probed): dispatchRpc's envelope contract
|
|
2709
|
+
// carries Uint8Arrays in the ATTACHMENTS side channel (dsh-api-gateway
|
|
2710
|
+
// encodeRpcResult) — value positions become null and the renderer
|
|
2711
|
+
// re-attaches by path. A raw typed array in the value dies on the
|
|
2712
|
+
// background→browser bridge (JSON), which is exactly the remote image
|
|
2713
|
+
// preview's "expected Uint8Array" zod failure. Restore, then re-extract.
|
|
2714
|
+
if (INVOKE_BINARY_RESULT_METHODS.has(endpoint)) {
|
|
2715
|
+
const extracted = extractResultAttachments(restored)
|
|
2716
|
+
return {
|
|
2717
|
+
ok: true,
|
|
2718
|
+
value: rewriteResult(endpoint, extracted.value, serverId),
|
|
2719
|
+
...(extracted.attachments.length > 0 ? { attachments: extracted.attachments } : {}),
|
|
2720
|
+
}
|
|
2721
|
+
}
|
|
2722
|
+
return { ok: true, value: rewriteResult(endpoint, restored, serverId) }
|
|
2703
2723
|
} catch (error) {
|
|
2704
2724
|
const code = error instanceof RelayError ? error.code : 'internal'
|
|
2705
2725
|
recordFailure(endpoint, code)
|
|
@@ -3192,6 +3212,9 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
|
|
|
3192
3212
|
peer: unknown,
|
|
3193
3213
|
signal: AbortSignal | undefined,
|
|
3194
3214
|
control: unknown,
|
|
3215
|
+
// DSH 0.2.1-alpha.3: the mux's uplink window, passed through verbatim
|
|
3216
|
+
// (undefined on older hosts and on the 5-argument wireStream.open path).
|
|
3217
|
+
uplinkWindow?: unknown,
|
|
3195
3218
|
): unknown {
|
|
3196
3219
|
if (!installed) {
|
|
3197
3220
|
return (chainTarget.openWireStream as (...callArgs: unknown[]) => unknown).call(
|
|
@@ -3202,6 +3225,7 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
|
|
|
3202
3225
|
peer,
|
|
3203
3226
|
signal,
|
|
3204
3227
|
control,
|
|
3228
|
+
uplinkWindow,
|
|
3205
3229
|
)
|
|
3206
3230
|
}
|
|
3207
3231
|
counters.openWireStream += 1
|
|
@@ -3215,7 +3239,7 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
|
|
|
3215
3239
|
if (endpoint === '$events') {
|
|
3216
3240
|
const open = chainTarget.openWireStream as (this: unknown, ...callArgs: unknown[]) => unknown
|
|
3217
3241
|
return (async (): Promise<unknown> => {
|
|
3218
|
-
const local = await open.call(raw, endpoint, payload, uplink, peer, signal, control)
|
|
3242
|
+
const local = await open.call(raw, endpoint, payload, uplink, peer, signal, control, uplinkWindow)
|
|
3219
3243
|
if (!isAsyncIterable(local)) return local
|
|
3220
3244
|
return mergedEventsStream({ local, relay, signal, recordFailure, log, clock, summarySync })
|
|
3221
3245
|
})()
|
|
@@ -3236,6 +3260,7 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
|
|
|
3236
3260
|
peer,
|
|
3237
3261
|
signal,
|
|
3238
3262
|
control,
|
|
3263
|
+
uplinkWindow,
|
|
3239
3264
|
)
|
|
3240
3265
|
}
|
|
3241
3266
|
// The T23b-2 global merge. The 0.2.0 openWireStream is an ASYNC
|
|
@@ -3249,7 +3274,7 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
|
|
|
3249
3274
|
return (async (): Promise<unknown> => {
|
|
3250
3275
|
// The uplink rides to the LOCAL method verbatim (it may consume
|
|
3251
3276
|
// it); the remote leg carries no uplink.
|
|
3252
|
-
const local = await open.call(raw, endpoint, payload, uplink, peer, signal, control)
|
|
3277
|
+
const local = await open.call(raw, endpoint, payload, uplink, peer, signal, control, uplinkWindow)
|
|
3253
3278
|
if (!isAsyncIterable(local)) return local
|
|
3254
3279
|
return mergedGlobalStream({
|
|
3255
3280
|
endpoint,
|
|
@@ -3275,6 +3300,7 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
|
|
|
3275
3300
|
peer,
|
|
3276
3301
|
signal,
|
|
3277
3302
|
control,
|
|
3303
|
+
uplinkWindow,
|
|
3278
3304
|
)
|
|
3279
3305
|
}
|
|
3280
3306
|
const verdict = validate(endpoint, virtuals)
|
|
@@ -3379,6 +3405,11 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
|
|
|
3379
3405
|
export interface SelfCheckOptions {
|
|
3380
3406
|
/** How long the probe may wait for its first frame; default 5000 ms. */
|
|
3381
3407
|
timeoutMs?: number
|
|
3408
|
+
/** The owner Peer the probe opens as. DSH 0.2.1-alpha.3 removed
|
|
3409
|
+
* `gateway.operatorPeer()` and requires a live Peer on every stream; see
|
|
3410
|
+
* owner-peer.ts. Undefined falls back to `operatorPeer()` where it still
|
|
3411
|
+
* exists (≤ alpha.2), else to no Peer. */
|
|
3412
|
+
getPeer?: () => unknown
|
|
3382
3413
|
}
|
|
3383
3414
|
|
|
3384
3415
|
/**
|
|
@@ -3410,7 +3441,15 @@ export async function behaviorSelfCheck(raw: object, options?: SelfCheckOptions)
|
|
|
3410
3441
|
const wireStream = gateway.wireStream
|
|
3411
3442
|
const open = isPlainObject(wireStream) ? wireStream.open : undefined
|
|
3412
3443
|
if (typeof open !== 'function') return { ok: false, reason: 'wireStream.open disappeared before the self-check' }
|
|
3413
|
-
|
|
3444
|
+
// ≤ 0.2.1-alpha.2 only: alpha.3 deleted operatorPeer (and connection.operator).
|
|
3445
|
+
const legacyOperatorPeer = typeof gateway.operatorPeer === 'function' ? gateway.operatorPeer : undefined
|
|
3446
|
+
let peer: unknown
|
|
3447
|
+
try {
|
|
3448
|
+
peer = options?.getPeer?.()
|
|
3449
|
+
} catch (error) {
|
|
3450
|
+
return { ok: false, reason: `the self-check could not obtain an owner Peer: ${messageOf(error)}` }
|
|
3451
|
+
}
|
|
3452
|
+
if (peer === undefined && legacyOperatorPeer !== undefined) peer = legacyOperatorPeer.call(raw)
|
|
3414
3453
|
const controller = new AbortController()
|
|
3415
3454
|
// NOT unref'd: an unref'd timer lets the process exit (or a test runner
|
|
3416
3455
|
// judge the loop empty) before the timeout ever fires when this probe is
|
|
@@ -3438,7 +3477,7 @@ export async function behaviorSelfCheck(raw: object, options?: SelfCheckOptions)
|
|
|
3438
3477
|
'workspace/follow',
|
|
3439
3478
|
payload,
|
|
3440
3479
|
undefined,
|
|
3441
|
-
|
|
3480
|
+
peer,
|
|
3442
3481
|
controller.signal,
|
|
3443
3482
|
),
|
|
3444
3483
|
),
|
|
@@ -3482,6 +3521,10 @@ export interface RunSelfCheckOptions {
|
|
|
3482
3521
|
retryDelayMs?: number
|
|
3483
3522
|
/** Failure logging, wired to the context logger by index.ts. */
|
|
3484
3523
|
log?: (format: string, ...args: unknown[]) => void
|
|
3524
|
+
/** Success logging (one line once the probe passes). */
|
|
3525
|
+
info?: (format: string, ...args: unknown[]) => void
|
|
3526
|
+
/** Forwarded to {@link behaviorSelfCheck}. */
|
|
3527
|
+
getPeer?: () => unknown
|
|
3485
3528
|
}
|
|
3486
3529
|
|
|
3487
3530
|
/**
|
|
@@ -3501,7 +3544,7 @@ export async function runSelfCheck(raw: object, options: RunSelfCheckOptions): P
|
|
|
3501
3544
|
const { handle, log } = options
|
|
3502
3545
|
const retryDelayMs = options.retryDelayMs ?? 3_000
|
|
3503
3546
|
const stillInstalled = (): boolean => handle.diagnostics().installed
|
|
3504
|
-
let result = await behaviorSelfCheck(raw)
|
|
3547
|
+
let result = await behaviorSelfCheck(raw, { getPeer: options.getPeer })
|
|
3505
3548
|
if (!result.ok && stillInstalled()) {
|
|
3506
3549
|
await new Promise<void>((resolve) => {
|
|
3507
3550
|
const timer = setTimeout(resolve, retryDelayMs)
|
|
@@ -3509,11 +3552,14 @@ export async function runSelfCheck(raw: object, options: RunSelfCheckOptions): P
|
|
|
3509
3552
|
// loop-empty judgement) open for the retry delay.
|
|
3510
3553
|
if (typeof timer.unref === 'function') timer.unref()
|
|
3511
3554
|
})
|
|
3512
|
-
if (stillInstalled()) result = await behaviorSelfCheck(raw)
|
|
3555
|
+
if (stillInstalled()) result = await behaviorSelfCheck(raw, { getPeer: options.getPeer })
|
|
3513
3556
|
}
|
|
3514
3557
|
if (!stillInstalled()) return result
|
|
3515
3558
|
handle.noteSelfCheck(result)
|
|
3516
|
-
if (result.ok)
|
|
3559
|
+
if (result.ok) {
|
|
3560
|
+
options.info?.('client intercept self-check passed; interception active')
|
|
3561
|
+
return result
|
|
3562
|
+
}
|
|
3517
3563
|
handle.uninstall()
|
|
3518
3564
|
log?.('client intercept self-check failed (%s), interception removed', result.reason)
|
|
3519
3565
|
return result
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* invoke 二进制载荷的线上标记(2026-10-14 修复):`v1/invoke` 是纯 JSON
|
|
3
|
+
* 通道,而 `workspaceFiles/readBytes` 的结果带 `data: Uint8Array`——直接
|
|
4
|
+
* `JSON.stringify` 会把 typed array 打成普通对象,子客户端侧的 zod 校验
|
|
5
|
+
* (expected "Uint8Array")当场拒绝,远程会话的图片预览报「读取失败」。
|
|
6
|
+
*
|
|
7
|
+
* 两端共用同一份规则:服务端在应答前对已知二进制方法的结果做深度标记
|
|
8
|
+
* (typed array → `{ $zr-u8: <base64> }`),子客户端在 forwardInvoke 拿回
|
|
9
|
+
* 值后还原成真 `Uint8Array`。两端都按同一个 `INVOKE_BINARY_RESULT_METHODS`
|
|
10
|
+
* 常量开关——单一来源,不可能漂移(这是登记表同步硬约束之外的第三张
|
|
11
|
+
* 「小表」,但它只此一处定义)。
|
|
12
|
+
*
|
|
13
|
+
* 纯逻辑:不 import DSH、不碰 I/O。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** 标记键。线上只会出现服务端写入的标记;客户端只认这个键。 */
|
|
17
|
+
export const BINARY_MARKER = '$zr-u8'
|
|
18
|
+
|
|
19
|
+
/** 结果里带二进制的方法(`namespace/method`)。请求侧参数不带二进制
|
|
20
|
+
* (readBytes 的参数是 scope/paths),故只有结果方向需要标记。 */
|
|
21
|
+
export const INVOKE_BINARY_RESULT_METHODS: ReadonlySet<string> = new Set(['workspaceFiles/readBytes'])
|
|
22
|
+
|
|
23
|
+
/** 深度上限:与其余 walker(share-store 祖先链、会话引用扫描)一致。 */
|
|
24
|
+
const MAX_DEPTH = 16
|
|
25
|
+
|
|
26
|
+
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
27
|
+
value !== null && typeof value === 'object' && !Array.isArray(value) && !isTypedArray(value)
|
|
28
|
+
|
|
29
|
+
const isTypedArray = (value: unknown): value is ArrayBufferView =>
|
|
30
|
+
ArrayBuffer.isView(value) === true && !(value instanceof DataView)
|
|
31
|
+
|
|
32
|
+
/** 深度遍历,把 typed array(含 Buffer)换成 `{ $zr-u8: base64 }` 标记。 */
|
|
33
|
+
export function markInvokeBinary(value: unknown, depth = 0): unknown {
|
|
34
|
+
if (depth >= MAX_DEPTH) return value
|
|
35
|
+
if (isTypedArray(value)) return { [BINARY_MARKER]: Buffer.from(value.buffer, value.byteOffset, value.byteLength).toString('base64') }
|
|
36
|
+
if (Array.isArray(value)) {
|
|
37
|
+
let changed = false
|
|
38
|
+
const out: unknown[] = []
|
|
39
|
+
for (const item of value) {
|
|
40
|
+
const marked = markInvokeBinary(item, depth + 1)
|
|
41
|
+
if (marked !== item) changed = true
|
|
42
|
+
out.push(marked)
|
|
43
|
+
}
|
|
44
|
+
return changed ? out : value
|
|
45
|
+
}
|
|
46
|
+
if (isPlainObject(value)) {
|
|
47
|
+
let changed = false
|
|
48
|
+
const out: Record<string, unknown> = {}
|
|
49
|
+
for (const [key, item] of Object.entries(value)) {
|
|
50
|
+
const marked = markInvokeBinary(item, depth + 1)
|
|
51
|
+
if (marked !== item) changed = true
|
|
52
|
+
out[key] = marked
|
|
53
|
+
}
|
|
54
|
+
return changed ? out : value
|
|
55
|
+
}
|
|
56
|
+
return value
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 深度遍历,把 `{ $zr-u8: base64 }` 标记还原成 `Uint8Array`。
|
|
60
|
+
* 标记只会是本模块的 `markInvokeBinary` 写下的(键名即凭据),base64
|
|
61
|
+
* 恒有效;万一混入同键的怪值,`Buffer.from(..., 'base64')` 的宽容解码
|
|
62
|
+
* 也不会抛。非标记对象原样返回(同一引用,不惊动浅比较的调用方)。 */
|
|
63
|
+
export function restoreInvokeBinary(value: unknown, depth = 0): unknown {
|
|
64
|
+
if (depth >= MAX_DEPTH) return value
|
|
65
|
+
if (Array.isArray(value)) {
|
|
66
|
+
let changed = false
|
|
67
|
+
const out: unknown[] = value.map((item) => {
|
|
68
|
+
const restored = restoreInvokeBinary(item, depth + 1)
|
|
69
|
+
if (restored !== item) changed = true
|
|
70
|
+
return restored
|
|
71
|
+
})
|
|
72
|
+
return changed ? out : value
|
|
73
|
+
}
|
|
74
|
+
if (isPlainObject(value)) {
|
|
75
|
+
const marked = value[BINARY_MARKER]
|
|
76
|
+
// 空串也是合法标记(base64 空串 = 0 字节,mark 自己写下的);
|
|
77
|
+
// 只排除非字符串与带兄弟键的诱饵形状。
|
|
78
|
+
if (typeof marked === 'string' && Object.keys(value).length === 1) {
|
|
79
|
+
return new Uint8Array(Buffer.from(marked, 'base64'))
|
|
80
|
+
}
|
|
81
|
+
let changed = false
|
|
82
|
+
const out: Record<string, unknown> = {}
|
|
83
|
+
for (const [key, item] of Object.entries(value)) {
|
|
84
|
+
const restored = restoreInvokeBinary(item, depth + 1)
|
|
85
|
+
if (restored !== item) changed = true
|
|
86
|
+
out[key] = restored
|
|
87
|
+
}
|
|
88
|
+
return changed ? out : value
|
|
89
|
+
}
|
|
90
|
+
return value
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** dispatchRpc 信封的附件旁路形状(dsh-api-gateway 的 encodeRpcResult):
|
|
94
|
+
* Uint8Array 不进 JSON 值,抽到 `attachments: [{path, bytes}]`,原位置
|
|
95
|
+
* 置 null,渲染层按 path 回填。远程路径必须产出同样的契约,否则
|
|
96
|
+
* typed array 在后台→浏览器的桥上被 JSON 压扁(2026-10-14 实机定位)。 */
|
|
97
|
+
export interface ResultAttachment {
|
|
98
|
+
path: (string | number)[]
|
|
99
|
+
bytes: Uint8Array
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 把(已还原的)结果值里的 Uint8Array 抽成附件;值内原位置置 null。
|
|
103
|
+
* 没有 typed array 时 value 原引用返回、attachments 为空数组。 */
|
|
104
|
+
export function extractResultAttachments(value: unknown): { value: unknown; attachments: ResultAttachment[] } {
|
|
105
|
+
const attachments: ResultAttachment[] = []
|
|
106
|
+
const walk = (input: unknown, path: (string | number)[], depth: number): unknown => {
|
|
107
|
+
if (depth >= MAX_DEPTH) return input
|
|
108
|
+
if (isTypedArray(input)) {
|
|
109
|
+
attachments.push({ path, bytes: input as Uint8Array })
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
112
|
+
if (Array.isArray(input)) {
|
|
113
|
+
let changed = false
|
|
114
|
+
const out: unknown[] = input.map((item, index) => {
|
|
115
|
+
const extracted = walk(item, [...path, index], depth + 1)
|
|
116
|
+
if (extracted !== item) changed = true
|
|
117
|
+
return extracted
|
|
118
|
+
})
|
|
119
|
+
return changed ? out : input
|
|
120
|
+
}
|
|
121
|
+
if (isPlainObject(input)) {
|
|
122
|
+
let changed = false
|
|
123
|
+
const out: Record<string, unknown> = {}
|
|
124
|
+
for (const [key, item] of Object.entries(input)) {
|
|
125
|
+
const extracted = walk(item, [...path, key], depth + 1)
|
|
126
|
+
if (extracted !== item) changed = true
|
|
127
|
+
out[key] = extracted
|
|
128
|
+
}
|
|
129
|
+
return changed ? out : input
|
|
130
|
+
}
|
|
131
|
+
return input
|
|
132
|
+
}
|
|
133
|
+
return { value: walk(value, [], 0), attachments }
|
|
134
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The owner Peer this plugin speaks as when it calls the typert gateway on
|
|
3
|
+
* its own behalf (relay routes, admin projections, the intercept
|
|
4
|
+
* self-check, the `$events` relay subscription).
|
|
5
|
+
*
|
|
6
|
+
* DSH 0.2.1-alpha.3 removed the gateway's implicit operator fallback
|
|
7
|
+
* (`gateway.operatorPeer()`, `connection.operator` and the `OperatorPeer`
|
|
8
|
+
* export are gone): every gateway call — `invoke`, `stream`,
|
|
9
|
+
* `openWireStream`, `dispatchRpc` — now asserts a live Peer, and `$events` /
|
|
10
|
+
* `$events/result` additionally demand `principal.kind === 'owner'` and
|
|
11
|
+
* `interactions === 'answer'`, with the result answered by the SAME Peer that
|
|
12
|
+
* opened the stream. The sanctioned in-process door is
|
|
13
|
+
* `connection.createPeer({ principal: { kind: 'owner' }, interactions:
|
|
14
|
+
* 'answer' })` (docs/upgrade-guide/v0.2.1-alpha.2/operator-connect), whose
|
|
15
|
+
* Peer is disposed with the Context that read `connection`.
|
|
16
|
+
*
|
|
17
|
+
* Resolution order: a cached `createPeer` Peer (recreated once its scope is
|
|
18
|
+
* disposed), else `connection.browserPeer` (also an owner/answer Peer),
|
|
19
|
+
* else `undefined` — the pre-alpha.3 behavior, where the gateway itself
|
|
20
|
+
* defaulted a missing Peer to its operator.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Minimal structural view of a typert `PeerScope`. */
|
|
24
|
+
export interface PeerLike {
|
|
25
|
+
readonly signal?: AbortSignal
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The connection-service members this module reads. */
|
|
29
|
+
export interface PeerConnectionLike {
|
|
30
|
+
createPeer?: (options: { principal: { kind: 'owner' }; interactions?: 'answer' | 'none' }) => unknown
|
|
31
|
+
browserPeer?: unknown
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isLive(peer: unknown): boolean {
|
|
35
|
+
if (peer === null || typeof peer !== 'object') return false
|
|
36
|
+
const signal = (peer as PeerLike).signal
|
|
37
|
+
return !(signal instanceof AbortSignal) || !signal.aborted
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build a lazy, caching owner-Peer source over a connection-service reader.
|
|
42
|
+
* The reader runs per call (the service may be absent or restarted). The
|
|
43
|
+
* cache is keyed on the Peer's LIVENESS, not on the connection object's
|
|
44
|
+
* identity: `ctx.reflect.get('connection')` hands out a fresh proxy per read,
|
|
45
|
+
* so an identity key would mint a new Peer per call — and the gateway
|
|
46
|
+
* refuses a `$events/result` from any Peer but the one that opened
|
|
47
|
+
* `$events` (found by the alpha.3 live smoke). A restarted connection
|
|
48
|
+
* service disposes its Peers, which is exactly when a new one is minted.
|
|
49
|
+
* @param getConnection - live reader of `ctx.connection` (may return undefined).
|
|
50
|
+
* @returns a function answering the Peer to pass, or undefined to let the
|
|
51
|
+
* host default it (pre-alpha.3 hosts).
|
|
52
|
+
*/
|
|
53
|
+
export function createOwnerPeerSource(getConnection: () => unknown): () => unknown {
|
|
54
|
+
let cached: unknown
|
|
55
|
+
return () => {
|
|
56
|
+
let connection: PeerConnectionLike | undefined
|
|
57
|
+
try {
|
|
58
|
+
const value = getConnection()
|
|
59
|
+
connection = value !== null && typeof value === 'object' ? (value as PeerConnectionLike) : undefined
|
|
60
|
+
} catch {
|
|
61
|
+
connection = undefined
|
|
62
|
+
}
|
|
63
|
+
if (connection === undefined) return undefined
|
|
64
|
+
if (typeof connection.createPeer === 'function') {
|
|
65
|
+
if (isLive(cached)) return cached
|
|
66
|
+
cached = connection.createPeer({ principal: { kind: 'owner' }, interactions: 'answer' })
|
|
67
|
+
return cached
|
|
68
|
+
}
|
|
69
|
+
const browser = connection.browserPeer
|
|
70
|
+
return browser !== null && typeof browser === 'object' ? browser : undefined
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Attach `peer` to one gateway request object when a Peer is available;
|
|
76
|
+
* otherwise return the request unchanged (pre-alpha.3 shape).
|
|
77
|
+
*/
|
|
78
|
+
export function withPeer<T extends object>(request: T, getPeer: (() => unknown) | undefined): T & { peer?: unknown } {
|
|
79
|
+
const peer = getPeer?.()
|
|
80
|
+
return peer === undefined ? request : { ...request, peer }
|
|
81
|
+
}
|
package/src/relay-server.ts
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
* a 404.
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
+
import { withPeer } from './owner-peer.js'
|
|
29
30
|
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
|
30
31
|
import { Readable, Transform } from 'node:stream'
|
|
31
32
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
@@ -48,6 +49,7 @@ import { routeMethodsOf, routeMaxBodyOf } from './plugin-bridge.js'
|
|
|
48
49
|
import { createPluginTunnel, validatePluginCall, type PluginTunnelRoutes } from './plugin-tunnel.js'
|
|
49
50
|
import type { PluginLoopback } from './plugin-loopback.js'
|
|
50
51
|
import { isLoopbackAuthError } from './plugin-loopback.js'
|
|
52
|
+
import { INVOKE_BINARY_RESULT_METHODS, markInvokeBinary } from './invoke-binary.js'
|
|
51
53
|
import type { NormalizedAdapter, AdapterVerdict } from './plugin-adapter-core.js'
|
|
52
54
|
import { matchAdapterRoute, locateAdapterIds, adapterOwnsRegistryEntry, AdapterMultiValueQueryError } from './plugin-adapter-core.js'
|
|
53
55
|
|
|
@@ -207,12 +209,13 @@ const JOB_CACHE_LIMIT = 1024
|
|
|
207
209
|
* before its typert dispatch — and returns the `{ok,…}` envelope itself.
|
|
208
210
|
*/
|
|
209
211
|
export interface RelayGateway {
|
|
210
|
-
invoke(call: { namespace: string; method: string; args: unknown; signal?: AbortSignal }): Promise<unknown>
|
|
212
|
+
invoke(call: { namespace: string; method: string; args: unknown; signal?: AbortSignal; peer?: unknown }): Promise<unknown>
|
|
211
213
|
stream(call: {
|
|
212
214
|
namespace: string
|
|
213
215
|
method: string
|
|
214
216
|
args: unknown
|
|
215
217
|
signal?: AbortSignal
|
|
218
|
+
peer?: unknown
|
|
216
219
|
}): Promise<AsyncIterable<unknown>>
|
|
217
220
|
/** The host's wire adapter, shared by the WebSocket mux and the local
|
|
218
221
|
* transports. `open` is the async 0.2.0 method. */
|
|
@@ -267,6 +270,12 @@ export interface RelayHandlerOptions {
|
|
|
267
270
|
* synthetic Request to the same exact-fetch route table the browser's
|
|
268
271
|
* `/api` transport uses — no loopback HTTP, no login state. */
|
|
269
272
|
getApiFetch?: () => ((request: Request) => Promise<Response>) | undefined
|
|
273
|
+
/** The owner Peer every gateway call is made as (DSH 0.2.1-alpha.3+
|
|
274
|
+
* requires one; `$events` and `$events/result` need an owner Peer with
|
|
275
|
+
* `interactions: 'answer'`, the SAME one for both). Built by
|
|
276
|
+
* {@link createOwnerPeerSource}; absent or answering undefined keeps the
|
|
277
|
+
* pre-alpha.3 behavior (the host defaults the Peer). */
|
|
278
|
+
getPeer?: () => unknown
|
|
270
279
|
/** Stream heartbeat interval in ms (a `{"type":"ping"}` line that keeps
|
|
271
280
|
* reverse proxies from timing the idle stream away); defaults to 15000.
|
|
272
281
|
* Tests inject a small value. */
|
|
@@ -874,12 +883,12 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
|
|
|
874
883
|
const cached = recentJobs.get(sessionId)
|
|
875
884
|
if (cached !== undefined && cached.has(jobId)) return cached.get(jobId) === sessionId
|
|
876
885
|
try {
|
|
877
|
-
const iterable = await gateway.stream({
|
|
886
|
+
const iterable = await gateway.stream(withPeer({
|
|
878
887
|
namespace: 'job',
|
|
879
888
|
method: 'list',
|
|
880
889
|
args: { request: { sessionId } },
|
|
881
890
|
signal,
|
|
882
|
-
})
|
|
891
|
+
}, options.getPeer))
|
|
883
892
|
for await (const frame of iterable) {
|
|
884
893
|
rememberJobs(sessionId, frame)
|
|
885
894
|
const jobs = recentJobs.get(sessionId)
|
|
@@ -905,7 +914,7 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
|
|
|
905
914
|
*/
|
|
906
915
|
const workspaceExists = async (workspaceId: string, signal: AbortSignal): Promise<boolean> => {
|
|
907
916
|
try {
|
|
908
|
-
const iterable = await gateway.stream({ namespace: 'workspace', method: 'follow', args: {}, signal })
|
|
917
|
+
const iterable = await gateway.stream(withPeer({ namespace: 'workspace', method: 'follow', args: {}, signal }, options.getPeer))
|
|
909
918
|
for await (const frame of iterable) {
|
|
910
919
|
if (!isPlainObject(frame) || frame.type !== 'baseline') return false
|
|
911
920
|
const value = isPlainObject(frame.value) ? frame.value : undefined
|
|
@@ -938,7 +947,7 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
|
|
|
938
947
|
'$events',
|
|
939
948
|
{ args: {} },
|
|
940
949
|
undefined,
|
|
941
|
-
|
|
950
|
+
options.getPeer?.(),
|
|
942
951
|
signal,
|
|
943
952
|
)) as unknown
|
|
944
953
|
if (
|
|
@@ -1216,7 +1225,7 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
|
|
|
1216
1225
|
'$events/result',
|
|
1217
1226
|
{ args: { clientId: owner.clientId, eventId: originalEventId, outcome: parsed.result } },
|
|
1218
1227
|
hangUp.signal,
|
|
1219
|
-
|
|
1228
|
+
options.getPeer?.(),
|
|
1220
1229
|
)) as unknown
|
|
1221
1230
|
responseJson(res, 200, isPlainObject(envelope) ? envelope : { ok: false, error: { code: 'internal' } })
|
|
1222
1231
|
} catch (error) {
|
|
@@ -1340,7 +1349,7 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
|
|
|
1340
1349
|
responseJson(res, 403, { ok: false, error: { code: 'not-shared' } })
|
|
1341
1350
|
return
|
|
1342
1351
|
}
|
|
1343
|
-
const value = await gateway.invoke({ namespace, method, args, signal: hangUp.signal })
|
|
1352
|
+
const value = await gateway.invoke(withPeer({ namespace, method, args, signal: hangUp.signal }, options.getPeer))
|
|
1344
1353
|
// T31: a session created or forked THROUGH the relay is shared
|
|
1345
1354
|
// automatically — the client only ever sees shared sessions, so an
|
|
1346
1355
|
// unshared result would be born unreachable. Re-sharing (a fork the
|
|
@@ -1363,7 +1372,14 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
|
|
|
1363
1372
|
: decision.filter === 'model-catalog'
|
|
1364
1373
|
? filterModelCatalogResult(value)
|
|
1365
1374
|
: value
|
|
1366
|
-
|
|
1375
|
+
// 2026-10-14: a binary-result method (workspaceFiles/readBytes) cannot
|
|
1376
|
+
// ride JSON.stringify — typed arrays collapse into plain objects and
|
|
1377
|
+
// the sub-client's own zod validation rejects the answer (remote image
|
|
1378
|
+
// preview: "expected Uint8Array"). The marker round-trip
|
|
1379
|
+
// (invoke-binary.ts) is restored on the client half (intercept.ts).
|
|
1380
|
+
const wire =
|
|
1381
|
+
INVOKE_BINARY_RESULT_METHODS.has(`${namespace}/${method}`) ? markInvokeBinary(travels) : travels
|
|
1382
|
+
responseJson(res, 200, { ok: true, value: wire })
|
|
1367
1383
|
} catch (error) {
|
|
1368
1384
|
// The gateway's own failures (unknown namespace, absent service) are
|
|
1369
1385
|
// business answers, not transport errors: they ride a 200 envelope
|
|
@@ -2463,12 +2479,12 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
|
|
|
2463
2479
|
const syncProjections = async (sessionId: string): Promise<void> => {
|
|
2464
2480
|
let result: unknown
|
|
2465
2481
|
try {
|
|
2466
|
-
result = await gateway.invoke({
|
|
2482
|
+
result = await gateway.invoke(withPeer({
|
|
2467
2483
|
namespace: 'session',
|
|
2468
2484
|
method: 'projections',
|
|
2469
2485
|
args: { request: { sessionId } },
|
|
2470
2486
|
signal: hangUp.signal,
|
|
2471
|
-
})
|
|
2487
|
+
}, options.getPeer))
|
|
2472
2488
|
} catch {
|
|
2473
2489
|
return // projections unavailable — the next live projection frame will flow anyway
|
|
2474
2490
|
}
|
|
@@ -2588,7 +2604,7 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
|
|
|
2588
2604
|
const iterable =
|
|
2589
2605
|
eventsSub !== undefined
|
|
2590
2606
|
? await openEventsStream(hangUp.signal)
|
|
2591
|
-
: await gateway.stream({ namespace, method, args, signal: hangUp.signal })
|
|
2607
|
+
: await gateway.stream(withPeer({ namespace, method, args, signal: hangUp.signal }, options.getPeer))
|
|
2592
2608
|
for await (const frame of iterable) {
|
|
2593
2609
|
if (finished || clientGone) break
|
|
2594
2610
|
if (namespace === 'job' && method === 'list') rememberJobs(ownerSessionIdOf(args), frame)
|