dsh-zen-remote 2.3.2 → 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 +178 -282
- package/README.zh-CN.md +179 -283
- package/docs/interface.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 +30 -10
- 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 +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 +14 -14
- package/scripts/check-upload-limits.mjs +167 -0
- package/src/adapters/dsh-better-sidebar/index.ts +30 -11
- 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/index.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { createOwnerPeerSource, createSharedApiFetchGetter } from './owner-peer.js'
|
|
19
|
+
import { readFileSync } from 'node:fs'
|
|
19
20
|
import { lstat, mkdir, open, realpath, rename, rm, writeFile } from 'node:fs/promises'
|
|
20
21
|
import type { FileHandle } from 'node:fs/promises'
|
|
21
22
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
@@ -55,7 +56,7 @@ import { createRelayClient, RelayError, volatileUpdateTouchesServerName } from '
|
|
|
55
56
|
import type { RelayClient } from './relay-client.js'
|
|
56
57
|
import { computeFingerprints } from './fingerprint.js'
|
|
57
58
|
import { runSelfCheck, installIntercept } from './intercept.js'
|
|
58
|
-
import type { CurrentSessionRead, InterceptDiagnostics, InterceptHandle } from './intercept.js'
|
|
59
|
+
import type { CurrentSessionRead, InterceptDiagnostics, InterceptHandle, SessionInventorySlot } from './intercept.js'
|
|
59
60
|
import { checkGatewayShape } from './intercept-shape.js'
|
|
60
61
|
import type { GatewayShapeCheck } from './intercept-shape.js'
|
|
61
62
|
import { checkFetchRouteShape, installFetchRouteIntercept } from './fetch-route-intercept.js'
|
|
@@ -65,6 +66,12 @@ import type { PluginRouteInterceptDiagnostics, PluginRouteInterceptHandle, WebSe
|
|
|
65
66
|
import { normalizeAdapter, adapterOwnsRegistryEntry } from './plugin-adapter-core.js'
|
|
66
67
|
import type { NormalizedAdapter } from './plugin-adapter-core.js'
|
|
67
68
|
import { ADAPTERS } from './adapters/index.js'
|
|
69
|
+
// PB-19: the better-sidebar surface extractor, a PURE utility inside the
|
|
70
|
+
// adapters directory — the one subdirectory import the wiring makes besides
|
|
71
|
+
// the ADAPTERS array (test/adapters-pluggable.test.cjs pins both to this
|
|
72
|
+
// file). The fs read that feeds it also lives HERE: nothing inside
|
|
73
|
+
// src/adapters/ touches the disk.
|
|
74
|
+
import { betterSidebarSurfaceDigest, extractBetterSidebarSurface, surfaceLooksHealthy } from './adapters/dsh-better-sidebar/surface.js'
|
|
68
75
|
import { createRelayHandler, loadServerId, RELAY_PREFIX, resolveDshVersion } from './relay-server.js'
|
|
69
76
|
import type { RelayGateway, RelayHandler } from './relay-server.js'
|
|
70
77
|
import { createBridgeRegistry, compareBridges } from './plugin-bridge.js'
|
|
@@ -80,6 +87,7 @@ import { createVerificationManager } from './wizard/verify.js'
|
|
|
80
87
|
import type { BridgeDeclaration, BridgeRegistry, BridgeStatus, ServerPluginEntry, ZenRemoteService } from './plugin-bridge.js'
|
|
81
88
|
import { createPluginLoopback } from './plugin-loopback.js'
|
|
82
89
|
import { createPluginTunnel } from './plugin-tunnel.js'
|
|
90
|
+
import { DEFAULT_MAX_UPLOAD_BYTES } from './upload-limits.js'
|
|
83
91
|
|
|
84
92
|
// The plugin bridge's public types (PB-1), re-exported so another plugin's
|
|
85
93
|
// author can `import type { … } from 'dsh-zen-remote'` without reaching into
|
|
@@ -127,8 +135,11 @@ export const CLIENT_CONFIG_ROUTE = '/_dsh/mobile-nav/client-config'
|
|
|
127
135
|
/** Workspace-relative directory uploads land in (also the `@` prefix the composer inserts). */
|
|
128
136
|
export const UPLOAD_DIR = '.dsh-uploads'
|
|
129
137
|
|
|
130
|
-
|
|
131
|
-
|
|
138
|
+
// The phone composer's default body cap lives in upload-limits.ts with the
|
|
139
|
+
// plugin's other upload numbers (the comment there spells out why this must
|
|
140
|
+
// stay phone-only and never size the relay); re-exported so the main entry's
|
|
141
|
+
// public surface keeps the name.
|
|
142
|
+
export { DEFAULT_MAX_UPLOAD_BYTES }
|
|
132
143
|
|
|
133
144
|
/** Longest filename, in bytes, that survives sanitization (ext4/APFS leaf limit is 255). */
|
|
134
145
|
const MAX_NAME_BYTES = 180
|
|
@@ -588,12 +599,18 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
588
599
|
// (`packageOf(name, ctx.baseUrl)` — verified on 0.2.0-rc.2 to answer
|
|
589
600
|
// `{name, version, dir, …}` or undefined; any other shape, a missing
|
|
590
601
|
// service or a probe failure reads as "not installed" and the adapter
|
|
591
|
-
// stays out).
|
|
592
|
-
//
|
|
593
|
-
//
|
|
594
|
-
//
|
|
595
|
-
//
|
|
596
|
-
//
|
|
602
|
+
// stays out). PB-19 exception: a version OUTSIDE the list still registers
|
|
603
|
+
// when the installed build's wire-surface digest (extracted here from its
|
|
604
|
+
// lib/index.js, the wiring being the only place the feature touches the
|
|
605
|
+
// disk) is whitelisted in the adapter's `surfaces` table — a wire-identical
|
|
606
|
+
// patch release bridges without a zen-remote release, and every failure of
|
|
607
|
+
// the extraction is logged apart from the digest-mismatch refusal. A plugin
|
|
608
|
+
// that registers its OWN cooperative declaration always wins: the adapter
|
|
609
|
+
// yields at registration time (logged), and the runtime re-checks the
|
|
610
|
+
// registry digest on every request. Both roles run this loop — the registry
|
|
611
|
+
// entries drive the handshake table, the bridge verdicts and the settings
|
|
612
|
+
// listing on either side, while the returned array feeds the two request
|
|
613
|
+
// halves.
|
|
597
614
|
const adapters: NormalizedAdapter[] = []
|
|
598
615
|
{
|
|
599
616
|
const adapterWarn = (text: string): void => { ctx.logger?.warn('dsh-zen-remote adapter: %s', text) }
|
|
@@ -601,7 +618,7 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
601
618
|
try {
|
|
602
619
|
const holder = ctx as unknown as { get?: (name: string) => unknown; baseUrl?: string }
|
|
603
620
|
const packages = holder.get?.('pluginPackages') as { packageOf?: (specifier: string, parentURL: string) => unknown } | undefined
|
|
604
|
-
const found = packages?.packageOf?.(adapter.plugin, holder.baseUrl ?? '') as { version?: unknown } | undefined
|
|
621
|
+
const found = packages?.packageOf?.(adapter.plugin, holder.baseUrl ?? '') as { version?: unknown; dir?: unknown } | undefined
|
|
605
622
|
const installedVersion = found !== null && typeof found === 'object' && typeof (found as { version?: unknown }).version === 'string'
|
|
606
623
|
? (found as { version: string }).version
|
|
607
624
|
: undefined
|
|
@@ -609,14 +626,44 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
609
626
|
adapterWarn(`${adapter.plugin}: target package (or its version) not found; not registered`)
|
|
610
627
|
continue
|
|
611
628
|
}
|
|
612
|
-
|
|
629
|
+
// PB-19: a version outside the adapter's list gets ONE more chance —
|
|
630
|
+
// its wire surface. Extract it, hash it, and let normalizeAdapter
|
|
631
|
+
// judge the digest against the adapter's `surfaces` table. Anything
|
|
632
|
+
// that goes wrong here leaves the digest undefined — the core then
|
|
633
|
+
// refuses as a plain unlisted version, and the warns below name WHY
|
|
634
|
+
// no digest was offered (no dir, unreadable file, lost dispatch
|
|
635
|
+
// table), distinct from a digest that simply missed.
|
|
636
|
+
const unlisted = !adapter.versions.includes(installedVersion)
|
|
637
|
+
let surfaceDigest: string | undefined
|
|
638
|
+
if (unlisted) {
|
|
639
|
+
const dir = typeof found?.dir === 'string' && found.dir !== '' ? found.dir : undefined
|
|
640
|
+
if (dir === undefined) {
|
|
641
|
+
adapterWarn(`${adapter.plugin}: version ${installedVersion} is unlisted and packageOf gave no dir; no surface digest to offer`)
|
|
642
|
+
} else {
|
|
643
|
+
try {
|
|
644
|
+
const surface = extractBetterSidebarSurface(readFileSync(join(dir, 'lib/index.js'), 'utf8'))
|
|
645
|
+
if (!surfaceLooksHealthy(surface)) {
|
|
646
|
+
adapterWarn(`${adapter.plugin}: surface extractor lost the dispatch table (${surface.methods.length} methods) — treating as unknown surface`)
|
|
647
|
+
} else {
|
|
648
|
+
surfaceDigest = betterSidebarSurfaceDigest(surface)
|
|
649
|
+
}
|
|
650
|
+
} catch (error) {
|
|
651
|
+
adapterWarn(`${adapter.plugin}: cannot read ${join(dir, 'lib/index.js')} for a surface digest: ${error instanceof Error ? error.message : String(error)}`)
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
const normalized = normalizeAdapter(adapter, installedVersion, surfaceDigest === undefined ? undefined : { surfaceDigest })
|
|
613
656
|
if (registry.get(normalized.plugin) !== undefined) {
|
|
614
657
|
adapterWarn(`${normalized.plugin}: the plugin registers its own bridge declaration; the adapter yields`)
|
|
615
658
|
continue
|
|
616
659
|
}
|
|
617
660
|
registry.registerNormalized(normalized.declaration)
|
|
618
661
|
adapters.push(normalized)
|
|
619
|
-
|
|
662
|
+
if (unlisted && surfaceDigest !== undefined) {
|
|
663
|
+
ctx.logger?.info('dsh-zen-remote adapter: %s bridged by surface digest %s (version %s unlisted)', normalized.plugin, surfaceDigest, installedVersion)
|
|
664
|
+
} else {
|
|
665
|
+
ctx.logger?.info('dsh-zen-remote adapter: %s adapted at version %s', normalized.plugin, installedVersion)
|
|
666
|
+
}
|
|
620
667
|
} catch (error) {
|
|
621
668
|
adapterWarn(`${adapter.plugin} refused: ${error instanceof Error ? error.message : String(error)}`)
|
|
622
669
|
}
|
|
@@ -1301,6 +1348,39 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
1301
1348
|
// carries nothing new costs one compare.
|
|
1302
1349
|
interceptHandle?.currentSessionChanged()
|
|
1303
1350
|
}
|
|
1351
|
+
// PB-25b: the KNOWN-real-session inventory — the union of the merged
|
|
1352
|
+
// global streams' session snapshots (the workspace merger's sessionHome:
|
|
1353
|
+
// every real id the sidebar currently holds as a `zr~` row). Each merged
|
|
1354
|
+
// stream owns one slot; a report REPLACES that stream's share (never a
|
|
1355
|
+
// delta — merge-streams' contract) and the union rebuilds wholesale on
|
|
1356
|
+
// every report and dispose (stream count is single digits). The
|
|
1357
|
+
// plugin-route rewrite reads the union LIVE per answer through the
|
|
1358
|
+
// getter — the same live-read discipline as getServerId/getStatus: a
|
|
1359
|
+
// snapshot captured at install time would be permanently empty (the
|
|
1360
|
+
// streams start reporting after the install), and an id that appears
|
|
1361
|
+
// between two stream refreshes waits for the next refresh by design
|
|
1362
|
+
// (the documented ceiling in merge-streams.ts). Read-only consumption:
|
|
1363
|
+
// nothing below ever mutates what a merger handed over.
|
|
1364
|
+
const knownSessionShares = new Map<SessionInventorySlot, ReadonlySet<string>>()
|
|
1365
|
+
const knownRealIds = new Set<string>()
|
|
1366
|
+
const rebuildKnownRealIds = (): void => {
|
|
1367
|
+
knownRealIds.clear()
|
|
1368
|
+
for (const share of knownSessionShares.values()) {
|
|
1369
|
+
for (const id of share) knownRealIds.add(id)
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
const createSessionInventorySlot = (): SessionInventorySlot => {
|
|
1373
|
+
const slot: SessionInventorySlot = {
|
|
1374
|
+
replace(ids) {
|
|
1375
|
+
knownSessionShares.set(slot, ids)
|
|
1376
|
+
rebuildKnownRealIds()
|
|
1377
|
+
},
|
|
1378
|
+
dispose() {
|
|
1379
|
+
if (knownSessionShares.delete(slot)) rebuildKnownRealIds()
|
|
1380
|
+
},
|
|
1381
|
+
}
|
|
1382
|
+
return slot
|
|
1383
|
+
}
|
|
1304
1384
|
const interceptDiagnostics = (): InterceptDiagnostics | undefined => {
|
|
1305
1385
|
if (interceptHandle !== undefined) return interceptHandle.diagnostics()
|
|
1306
1386
|
if (interceptRefused !== undefined) {
|
|
@@ -1334,6 +1414,9 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
1334
1414
|
relay: relayClient,
|
|
1335
1415
|
getServerId: () => relayClient.handshakeInfo?.serverId,
|
|
1336
1416
|
getCurrentSession: () => reportedCurrentSession,
|
|
1417
|
+
// PB-25b: each merged stream reports its real-session inventory
|
|
1418
|
+
// share into the union above.
|
|
1419
|
+
createSessionInventorySlot,
|
|
1337
1420
|
log: (format, ...args) => { ctx.logger?.info(`dsh-zen-remote ${format}`, ...args) },
|
|
1338
1421
|
})
|
|
1339
1422
|
interceptHandle = handle
|
|
@@ -1491,6 +1574,10 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
1491
1574
|
// routes' deny hooks read (better-sidebar's open.external carries
|
|
1492
1575
|
// no session field of its own).
|
|
1493
1576
|
getCurrentSession: () => (reportedCurrentSession.kind === 'open' ? reportedCurrentSession.sessionId : undefined),
|
|
1577
|
+
// PB-25b: the known-real-session union, read LIVE per rewriteIds
|
|
1578
|
+
// answer — the rewriteIds deep scan rewrites an id that already sits
|
|
1579
|
+
// in the browser's view even when this request never carried it.
|
|
1580
|
+
getKnownRealIds: () => knownRealIds,
|
|
1494
1581
|
log: (format, ...args) => { ctx.logger?.info(`dsh-zen-remote ${format}`, ...args) },
|
|
1495
1582
|
})
|
|
1496
1583
|
pluginRouteHandle = handle
|
package/src/intercept.ts
CHANGED
|
@@ -605,6 +605,19 @@ export interface InterceptDiagnostics {
|
|
|
605
605
|
closedSessions: ClosedSessionRecord[]
|
|
606
606
|
}
|
|
607
607
|
|
|
608
|
+
/** PB-25b: one merged global stream's share of the KNOWN-real-session
|
|
609
|
+
* inventory. REPLACE semantics: each `replace` call carries that stream's
|
|
610
|
+
* FULL current set (the workspace merger's sessionHome snapshot — every real
|
|
611
|
+
* id the sidebar currently holds as a `zr~` id), never a delta; `dispose`
|
|
612
|
+
* drops the share (the stream ended — its merger is gone, its ids may no
|
|
613
|
+
* longer be in the UI's view). The wiring unions the live shares into the
|
|
614
|
+
* read-only set the plugin-route rewrite consults; nothing here hands out a
|
|
615
|
+
* mutable reference to merge state. */
|
|
616
|
+
export interface SessionInventorySlot {
|
|
617
|
+
replace(ids: ReadonlySet<string>): void
|
|
618
|
+
dispose(): void
|
|
619
|
+
}
|
|
620
|
+
|
|
608
621
|
export interface InstallInterceptOptions {
|
|
609
622
|
/** The RAW gateway instance (`ctx.typertGateway[symbols.original]`), not
|
|
610
623
|
* the cordis proxy — own properties on the proxy are ignored by everyone. */
|
|
@@ -629,6 +642,13 @@ export interface InstallInterceptOptions {
|
|
|
629
642
|
* read got wrong — so the value must be carried in. Default: permanently
|
|
630
643
|
* `unavailable` (the conservative fallback — tombstones stay). */
|
|
631
644
|
getCurrentSession?: () => CurrentSessionRead
|
|
645
|
+
/** PB-25b: the inventory slot factory — one slot per merged global stream
|
|
646
|
+
* (only the workspace merger ever reports; the control merger has no
|
|
647
|
+
* session inventory). The wiring unions the live slots' shares into the
|
|
648
|
+
* set the plugin-route rewrite reads. Absent: no shares are ever reported
|
|
649
|
+
* and the plugin-route value scan stays exactly the request-local
|
|
650
|
+
* behavior. */
|
|
651
|
+
createSessionInventorySlot?: () => SessionInventorySlot
|
|
632
652
|
}
|
|
633
653
|
|
|
634
654
|
export interface InterceptHandle {
|
|
@@ -1339,6 +1359,10 @@ interface MergedGlobalStreamDeps {
|
|
|
1339
1359
|
* sync can announce list rows the UI has no summary for; the control
|
|
1340
1360
|
* route carries no session lists and never calls it. */
|
|
1341
1361
|
summarySync: SessionSummarySync
|
|
1362
|
+
/** PB-25b: the inventory slot factory (the wiring's union feed — see
|
|
1363
|
+
* {@link SessionInventorySlot}). One slot per merged stream, disposed with
|
|
1364
|
+
* it; absent when the wiring passed no factory. */
|
|
1365
|
+
createSessionInventorySlot?: () => SessionInventorySlot
|
|
1342
1366
|
}
|
|
1343
1367
|
|
|
1344
1368
|
/**
|
|
@@ -1366,13 +1390,25 @@ async function* mergedGlobalStream(deps: MergedGlobalStreamDeps): AsyncGenerator
|
|
|
1366
1390
|
log?.('client merge dropped a frame on %s: %s', endpoint, message)
|
|
1367
1391
|
}
|
|
1368
1392
|
const initial = relayIdentityOf(relay)
|
|
1393
|
+
// PB-25b: this stream's inventory share — the workspace merger reports its
|
|
1394
|
+
// full real-id set through it (replace-style), and the share dies with the
|
|
1395
|
+
// stream (both exits below: the degraded local-only path and the main
|
|
1396
|
+
// teardown). The control merger has no session inventory and never
|
|
1397
|
+
// reports.
|
|
1398
|
+
const sessionSlot = deps.createSessionInventorySlot?.()
|
|
1369
1399
|
// Created eagerly so the local leg is observed from its first frame on.
|
|
1370
1400
|
// The empty identity is a placeholder: the remote pump retargets before
|
|
1371
1401
|
// any remote frame is ever fed (it only runs under a real handshake).
|
|
1372
1402
|
let merger: WorkspaceMerger | ControlMerger =
|
|
1373
1403
|
endpoint === 'session/control'
|
|
1374
1404
|
? createControlMerger({ serverId: initial?.serverId ?? '', serverName: initial?.serverName ?? '', onDiagnostic })
|
|
1375
|
-
: createWorkspaceMerger({
|
|
1405
|
+
: createWorkspaceMerger({
|
|
1406
|
+
serverId: initial?.serverId ?? '',
|
|
1407
|
+
serverName: initial?.serverName ?? '',
|
|
1408
|
+
onDiagnostic,
|
|
1409
|
+
now: clock.now,
|
|
1410
|
+
...(sessionSlot !== undefined ? { onKnownSessions: (ids: ReadonlySet<string>) => sessionSlot.replace(ids) } : {}),
|
|
1411
|
+
})
|
|
1376
1412
|
|
|
1377
1413
|
/**
|
|
1378
1414
|
* The status annotation (T34) the CURRENT relay state maps onto, applied
|
|
@@ -1563,6 +1599,9 @@ async function* mergedGlobalStream(deps: MergedGlobalStreamDeps): AsyncGenerator
|
|
|
1563
1599
|
// Remote-side setup failed: degrade to a pure local passthrough so the
|
|
1564
1600
|
// UI's own stream is untouched and the remote leg never starts.
|
|
1565
1601
|
log?.('client merge on %s degraded to local-only: %s', endpoint, messageOf(error))
|
|
1602
|
+
// PB-25b: this stream never reports (no remote leg) — drop its share
|
|
1603
|
+
// before the degraded passthrough takes over.
|
|
1604
|
+
sessionSlot?.dispose()
|
|
1566
1605
|
yield* localOnly(local)
|
|
1567
1606
|
return
|
|
1568
1607
|
}
|
|
@@ -1740,6 +1779,9 @@ async function* mergedGlobalStream(deps: MergedGlobalStreamDeps): AsyncGenerator
|
|
|
1740
1779
|
if (signal !== undefined) signal.removeEventListener('abort', onExternalAbort)
|
|
1741
1780
|
currentController?.abort()
|
|
1742
1781
|
flushWaiters()
|
|
1782
|
+
// PB-25b: the stream is over — its inventory share leaves the wiring's
|
|
1783
|
+
// union (its ids may no longer be in the UI's view).
|
|
1784
|
+
sessionSlot?.dispose()
|
|
1743
1785
|
// Best-effort half-close of the local stream the consumer walked away
|
|
1744
1786
|
// from — never awaited: an upstream that ignores return() must not
|
|
1745
1787
|
// hang the teardown.
|
|
@@ -2425,6 +2467,9 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
|
|
|
2425
2467
|
// outlives individual stream generations so a nudge from a workspace leg
|
|
2426
2468
|
// reaches whichever $events generation is open.
|
|
2427
2469
|
const summarySync = createSessionSummarySync()
|
|
2470
|
+
// PB-25b: the inventory slot factory (the wiring's union feed) — passed
|
|
2471
|
+
// down to every merged stream, which owns one slot for its lifetime.
|
|
2472
|
+
const createSessionInventorySlot = options.createSessionInventorySlot
|
|
2428
2473
|
// T62: the current-session getter and the live-merged-stream listener
|
|
2429
2474
|
// registry behind handle.currentSessionChanged().
|
|
2430
2475
|
const getCurrentSession = options.getCurrentSession ?? (() => ({ kind: 'unavailable' }) as const)
|
|
@@ -3341,6 +3386,7 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
|
|
|
3341
3386
|
watchCurrentSession,
|
|
3342
3387
|
watchUninstall,
|
|
3343
3388
|
summarySync,
|
|
3389
|
+
createSessionInventorySlot,
|
|
3344
3390
|
})
|
|
3345
3391
|
})()
|
|
3346
3392
|
}
|
package/src/merge-streams.ts
CHANGED
|
@@ -220,6 +220,18 @@ export interface WorkspaceMergerOptions extends MergerIdentity {
|
|
|
220
220
|
/** T65: the clock the tombstone hide-grace runs on. Default Date.now;
|
|
221
221
|
* tests advance an injectable fake to cross the grace. */
|
|
222
222
|
now?: () => number
|
|
223
|
+
/** PB-25b: the known-real-session inventory report — every time the
|
|
224
|
+
* session registry changes, the FULL current set of real ids this merger
|
|
225
|
+
* knows (the sessionHome keys: live ids and tombstones alike, each one the
|
|
226
|
+
* UI holds as a `zr~` id under this identity) is handed over as a fresh
|
|
227
|
+
* snapshot. REPLACE semantics, never a delta: the consumer's share is
|
|
228
|
+
* redefined wholesale by every report, so no invalidation bookkeeping
|
|
229
|
+
* exists on either side. Documented ceiling: an id that appears (or
|
|
230
|
+
* leaves) between two registry changes is invisible to the consumer until
|
|
231
|
+
* the next change reports — dumb but correct, and the wiring owns the
|
|
232
|
+
* other half of the contract (read-only consumption; the plugin-route
|
|
233
|
+
* rewrite never mutates merge state). */
|
|
234
|
+
onKnownSessions?: (ids: ReadonlySet<string>) => void
|
|
223
235
|
}
|
|
224
236
|
|
|
225
237
|
export interface WorkspaceMerger {
|
|
@@ -285,6 +297,7 @@ export interface WorkspaceMerger {
|
|
|
285
297
|
*/
|
|
286
298
|
export function createWorkspaceMerger(options: WorkspaceMergerOptions): WorkspaceMerger {
|
|
287
299
|
const onDiagnostic = options.onDiagnostic
|
|
300
|
+
const onKnownSessions = options.onKnownSessions
|
|
288
301
|
let identity: MergerIdentity = { serverId: options.serverId, serverName: options.serverName }
|
|
289
302
|
|
|
290
303
|
// The locally observed truth, updated from every local baseline / order /
|
|
@@ -359,19 +372,43 @@ export function createWorkspaceMerger(options: WorkspaceMergerOptions): Workspac
|
|
|
359
372
|
* remembered under THIS workspace as its last home (originals, cumulative). */
|
|
360
373
|
function learnSessions(record: Record<string, unknown>, workspaceId: string): void {
|
|
361
374
|
if (!Array.isArray(record.sessionIds)) return
|
|
362
|
-
|
|
375
|
+
let learned = false
|
|
376
|
+
for (const id of record.sessionIds) {
|
|
377
|
+
if (typeof id === 'string') {
|
|
378
|
+
sessionHome.set(id, workspaceId)
|
|
379
|
+
learned = true
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
// PB-25b: the inventory grew — re-report the full set (replace-style;
|
|
383
|
+
// see onKnownSessions). Once per record with string ids, never per id.
|
|
384
|
+
if (learned) reportKnownSessions()
|
|
363
385
|
}
|
|
364
386
|
|
|
365
387
|
/** Drop the ids whose last home was this workspace — tombstones die with
|
|
366
388
|
* their group (a server remove / a workspace diffed away; a session that
|
|
367
389
|
* moved on lives under its NEW home and survives this). */
|
|
368
390
|
function forgetWorkspace(workspaceId: string): void {
|
|
391
|
+
let forgot = false
|
|
369
392
|
for (const [id, home] of sessionHome) {
|
|
370
393
|
if (home === workspaceId) {
|
|
371
394
|
sessionHome.delete(id)
|
|
372
395
|
closedAt.delete(id)
|
|
396
|
+
forgot = true
|
|
373
397
|
}
|
|
374
398
|
}
|
|
399
|
+
// PB-25b: the inventory shrank — the gone ids must leave the consumer's
|
|
400
|
+
// share with this refresh, or a session that left the sub-client's view
|
|
401
|
+
// would keep being rewritten.
|
|
402
|
+
if (forgot) reportKnownSessions()
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** PB-25b: the inventory report — the FULL sessionHome key set as a fresh
|
|
406
|
+
* snapshot, handed to the callback replace-style. Fired at the three
|
|
407
|
+
* registry mutation points (learn / forget / the identity end) rather than
|
|
408
|
+
* once per frame: a frame that changes nothing costs nothing, and any
|
|
409
|
+
* future frame type that touches the registry reports automatically. */
|
|
410
|
+
function reportKnownSessions(): void {
|
|
411
|
+
onKnownSessions?.(new Set(sessionHome.keys()))
|
|
375
412
|
}
|
|
376
413
|
|
|
377
414
|
/** T65: stamp the ids that JUST left a workspace's live list (their
|
|
@@ -885,6 +922,9 @@ export function createWorkspaceMerger(options: WorkspaceMergerOptions): Workspac
|
|
|
885
922
|
// So does the shown set: a NEW server may reuse original workspace ids,
|
|
886
923
|
// and a stale entry would forward the new server's empty groups.
|
|
887
924
|
shown.clear()
|
|
925
|
+
// PB-25b: the inventory died with the identity — the empty report is
|
|
926
|
+
// what empties the consumer's share (a NEW server mints new ids).
|
|
927
|
+
reportKnownSessions()
|
|
888
928
|
return out
|
|
889
929
|
},
|
|
890
930
|
}
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
import { createHash } from 'node:crypto'
|
|
26
26
|
import { isVirtual, fromVirtual } from './virtual-id.js'
|
|
27
|
+
import { MAX_UPLOAD_BYTES } from './upload-limits.js'
|
|
27
28
|
import type { NormalizedBridgeRoute, NormalizedDeclaration } from './plugin-bridge.js'
|
|
28
29
|
import { BRIDGE_METHODS, DEFAULT_ROUTE_BODY_BYTES } from './plugin-bridge.js'
|
|
29
30
|
|
|
@@ -146,7 +147,9 @@ export interface AdapterHooks {
|
|
|
146
147
|
export interface NormalizedAdapter {
|
|
147
148
|
plugin: string
|
|
148
149
|
/** The exact versions this adapter was written against; the INSTALLED
|
|
149
|
-
* version must be among them or
|
|
150
|
+
* version must be among them — or its wire surface must be whitelisted in
|
|
151
|
+
* the adapter's `surfaces` table (PB-19) — or the adapter does not
|
|
152
|
+
* register. */
|
|
150
153
|
versions: readonly string[]
|
|
151
154
|
routes: NormalizedAdapterRoute[]
|
|
152
155
|
/** The digest of the ROUTE TABLE (locator and match included) — the
|
|
@@ -163,13 +166,12 @@ export interface NormalizedAdapter {
|
|
|
163
166
|
const PLUGIN_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/u
|
|
164
167
|
const MAX_ROUTES = 64
|
|
165
168
|
|
|
166
|
-
/** The body cap an ADAPTER route may declare:
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
* adapter exists to carry
|
|
171
|
-
|
|
172
|
-
export const MAX_ADAPTER_BODY_BYTES = 500 * 1024 * 1024
|
|
169
|
+
/** The body cap an ADAPTER route may declare: the relay's own upload
|
|
170
|
+
* budget (`MAX_UPLOAD_BYTES` in upload-limits.ts), which the better-sidebar
|
|
171
|
+
* upload route rides. The same 500 MiB the cooperative declaration's
|
|
172
|
+
* maxBody allows — both read the one constant since PB-20; raw media
|
|
173
|
+
* uploads are exactly what an adapter exists to carry. */
|
|
174
|
+
export const MAX_ADAPTER_BODY_BYTES = MAX_UPLOAD_BYTES
|
|
173
175
|
|
|
174
176
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
175
177
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
@@ -186,13 +188,19 @@ function fail(field: string, detail: string): never {
|
|
|
186
188
|
* absolute paths, off the host-owned `/_dsh/` and `/api` prefixes) — an
|
|
187
189
|
* adapter must not be able to reach anything a declaration cannot. The
|
|
188
190
|
* installed version must be listed EXACTLY (`versions` are what the
|
|
189
|
-
* adapter was written against)
|
|
191
|
+
* adapter was written against) — with ONE out (PB-19): a version outside
|
|
192
|
+
* the list is admitted anyway when `options.surfaceDigest` — the wiring's
|
|
193
|
+
* extraction of the INSTALLED build's wire surface — is a key of the
|
|
194
|
+
* adapter's `surfaces` table; the registration then carries the installed
|
|
195
|
+
* version with the usual route-table digest, so two machines on the same
|
|
196
|
+
* unlisted-but-surface-identical release still agree. A miss refuses with
|
|
197
|
+
* the digest and the table's size in the message, for the logs.
|
|
190
198
|
* @throws with a field-naming message on any illegal shape.
|
|
191
199
|
*/
|
|
192
|
-
export function normalizeAdapter(input: unknown, installedVersion: string): NormalizedAdapter {
|
|
200
|
+
export function normalizeAdapter(input: unknown, installedVersion: string, options?: { surfaceDigest?: string }): NormalizedAdapter {
|
|
193
201
|
if (!isRecord(input)) fail('adapter', 'expected an object')
|
|
194
202
|
for (const key of Object.keys(input)) {
|
|
195
|
-
if (key !== 'plugin' && key !== 'versions' && key !== 'routes' && key !== 'client' && key !== 'server') {
|
|
203
|
+
if (key !== 'plugin' && key !== 'versions' && key !== 'routes' && key !== 'client' && key !== 'server' && key !== 'surfaces') {
|
|
196
204
|
fail(key, 'unknown field (typo guard)')
|
|
197
205
|
}
|
|
198
206
|
}
|
|
@@ -203,8 +211,21 @@ export function normalizeAdapter(input: unknown, installedVersion: string): Norm
|
|
|
203
211
|
if (!Array.isArray(versions) || versions.length === 0 || versions.some((v) => typeof v !== 'string' || v === '')) {
|
|
204
212
|
fail('versions', 'must be a non-empty array of version strings')
|
|
205
213
|
}
|
|
214
|
+
// The surfaces table is validated whenever present, listed version or not
|
|
215
|
+
// (a malformed credential must not lurk until the one version that needs it).
|
|
216
|
+
if (input.surfaces !== undefined) {
|
|
217
|
+
if (!isRecord(input.surfaces)) fail('surfaces', 'expected an object (digest16 → remark)')
|
|
218
|
+
for (const [key, value] of Object.entries(input.surfaces)) {
|
|
219
|
+
if (!/^[0-9a-f]{16}$/u.test(key)) fail(`surfaces.${key}`, 'the key must be a 16-hex surface digest')
|
|
220
|
+
if (typeof value !== 'string' || value === '') fail(`surfaces.${key}`, 'the remark must be a non-empty string')
|
|
221
|
+
}
|
|
222
|
+
}
|
|
206
223
|
if (!versions.includes(installedVersion)) {
|
|
207
|
-
|
|
224
|
+
const surfaceDigest = options?.surfaceDigest
|
|
225
|
+
const surfaces = input.surfaces as Record<string, string> | undefined
|
|
226
|
+
if (!(surfaceDigest !== undefined && surfaces !== undefined && Object.hasOwn(surfaces, surfaceDigest))) {
|
|
227
|
+
fail('versions', `installed version "${installedVersion}" is not among the adapter's versions (surface ${surfaceDigest ?? 'not computed'} vs ${surfaces === undefined ? 0 : Object.keys(surfaces).length} listed)`)
|
|
228
|
+
}
|
|
208
229
|
}
|
|
209
230
|
if (!Array.isArray(routes) || routes.length === 0) fail('routes', 'must be a non-empty array')
|
|
210
231
|
if (routes.length > MAX_ROUTES) fail('routes', `${routes.length} routes exceeds the ${MAX_ROUTES} cap`)
|
|
@@ -350,13 +371,47 @@ export function adapterDigestOf(plugin: string, version: string, routes: Normali
|
|
|
350
371
|
return createHash('sha256').update(canonical, 'utf8').digest('hex').slice(0, 16)
|
|
351
372
|
}
|
|
352
373
|
|
|
353
|
-
/** The prefix-match rule, shared verbatim by both halves: equality or
|
|
354
|
-
* path
|
|
374
|
+
/** The prefix-match rule, shared verbatim by both halves: equality, or any
|
|
375
|
+
* deeper sub-path (`startsWith(path + '/')`), on the RAW undecoded pathname. */
|
|
355
376
|
export function adapterPathMatches(route: NormalizedAdapterRoute, pathname: string): boolean {
|
|
356
377
|
if (route.match === 'exact') return pathname === route.path
|
|
357
378
|
return pathname === route.path || pathname.startsWith(`${route.path}/`)
|
|
358
379
|
}
|
|
359
380
|
|
|
381
|
+
/** The prefix TAIL's shape red line (PB-23b): the part of the RAW pathname
|
|
382
|
+
* AFTER a matched prefix path — the variable tail prefix matching admits —
|
|
383
|
+
* refuses `?`, `#`, `..` and whitespace. Exact equality inherited these
|
|
384
|
+
* declaration-side red lines structurally (the declared path itself passed
|
|
385
|
+
* them at registration); a prefix hit forwards an arbitrary tail straight
|
|
386
|
+
* into the loopback request line, so the tail carries them itself. A `?`
|
|
387
|
+
* would break the parse-once query discipline (the loopback appends ITS
|
|
388
|
+
* `?query`, and a host splits at the FIRST `?` — the unchecked part would
|
|
389
|
+
* ride in as query); a `#` is fragment garbage in the request line;
|
|
390
|
+
* whitespace mirrors the declaration rule. `..` is refused RAW and in its
|
|
391
|
+
* `%2e`/`%2E` encoding — ANY encoded dot, not just the `%2e%2e` pair, since
|
|
392
|
+
* a mixed form like `.%2e.` decodes to dot-dot and would slip a pair-only
|
|
393
|
+
* check. The verdict deliberately runs on the RAW bytes (reject-on-sight),
|
|
394
|
+
* not decode-then-check: `.` is RFC 3986-unreserved, so no legitimate
|
|
395
|
+
* encoder — including this module's own `encodeURIComponent` rewrite — ever
|
|
396
|
+
* produces `%2e`, while a decoded verdict would depend on the decode depth
|
|
397
|
+
* (`%252e%252e` survives one decode) and on malformed-escape handling,
|
|
398
|
+
* i.e. on exactly the host-decoder behavior this red line exists because it
|
|
399
|
+
* is NOT pinned. The check is tail-only because a declaration may legally
|
|
400
|
+
* carry `%` (only these four shapes are refused at registration) — judging
|
|
401
|
+
* the whole pathname would retro-illegalize blessed declarations. Both
|
|
402
|
+
* halves answer the refusal with 400 `bad-request`, the coordinate-shape
|
|
403
|
+
* code the entries already use (no client classifies on it). */
|
|
404
|
+
const TAIL_SHAPE_VIOLATION = /[?#\s]|\.\.|%2e/iu
|
|
405
|
+
|
|
406
|
+
/** Whether {@link adapterPathMatches} hit via a deeper tail whose shape
|
|
407
|
+
* breaks the red line — `false` for a non-match (the caller's routing
|
|
408
|
+
* refusal answers first) and for an equality hit (no tail at all). */
|
|
409
|
+
export function adapterTailShapeViolation(route: NormalizedAdapterRoute, pathname: string): boolean {
|
|
410
|
+
if (!adapterPathMatches(route, pathname)) return false
|
|
411
|
+
if (pathname === route.path) return false
|
|
412
|
+
return TAIL_SHAPE_VIOLATION.test(pathname.slice(route.path.length))
|
|
413
|
+
}
|
|
414
|
+
|
|
360
415
|
/** The adapter route a forwarded request hit, or `undefined`. `local`
|
|
361
416
|
* routes never match here — they stay on the sub-client's own machine in
|
|
362
417
|
* both directions, so the server half must never see (nor serve) them. */
|