feat(relay-run): Phase 0 single-host relay — real data-plane wiring + integration test

New relay-run/ package boots the REAL term-relay relay-node between a browser WSS
listener and an agent mTLS listener, delegating every authz verdict to the real
relay-auth onUpgrade (Origin/CSWSH + capability verify + DPoP PoP + single-use jti),
with the real P4 E2E crypto. No audited package modified.

- P1 (done): in-process integration test round-trips a sealed payload both ways
  through the real createRelayNode splice + real createMuxSession; INV2 asserted
  (relay sees only ciphertext); negative controls (foreign Origin / missing DPoP -> 401).
- P2 (boots): main.ts brings up self-signed TLS + 3 listeners + in-memory control-plane.
- P3 (not landed): no agent dial / node-pty / relay-web serve yet.
- 4 tests green, tsc clean. node_modules via symlinks (gitignored).

Two real seam drifts surfaced (documented in PLAN_RELAY_RUN_PHASE0, not hacked):
(1) term-relay authz-port DpopContext shape vs relay-auth — reconciled (server-derived htu/htm);
(2) control-plane CapabilityVerifier.verify is sync but relay-auth verify is async — blocks
    HTTP account/pairing seeding until a 1-line async widen.
This commit is contained in:
Yaojia Wang
2026-07-04 14:13:04 +02:00
parent 3d17bed322
commit ba85871227
17 changed files with 1239 additions and 0 deletions

View File

@@ -0,0 +1,85 @@
/**
* Authorizer adapter — implements the term-relay (P1) `Authorizer` port by DELEGATING every verdict
* to the REAL relay-auth (P5) `onUpgrade`/`onReattach`. P1 holds no authz logic; this file only
* reconciles two structurally-drifted-but-equivalent seam shapes:
*
* 1. DpopContext. P1's port carries `{ proof, publicKeyThumbprint }`; P5 needs `{ proofJws, htu,
* htm }`. The relay derives `htu`/`htm` from the request's OWN authority (`expectedAud` = the
* resolved subdomain), never from client-supplied claims — the spec-faithful DPoP binding.
* 2. AuthzOutcome. P5's `principal` is a full AuthenticatedPrincipal; P1's port only needs a
* string, so we surface `principal.accountId` (P1's adapter reads only `hostId`/`jti`).
*
* F2 (step-up principal wiring): Phase 0 uses a not-required step-up policy, so P5 resolves identity
* from the signed token (INV3) and a null session-principal is correct. `resolvePrincipal` is the
* seam a later phase injects to pass an authenticated principal for step-up-required hosts.
*/
import {
onUpgrade as p5OnUpgrade,
onReattach as p5OnReattach,
type EnforceDeps,
type AuthenticatedPrincipal,
type AuthzOutcome as P5AuthzOutcome,
} from 'relay-auth'
import type {
Authorizer,
UpgradeContext as PortUpgradeContext,
AuthzOutcome as PortAuthzOutcome,
} from 'term-relay/data-plane/authz-port.js'
import { DPOP_HTM, htuFor } from './capability.js'
export interface AuthorizerDeps {
readonly deps: EnforceDeps
readonly allowedOrigins: readonly string[]
readonly now: () => number
/** F2 seam: resolve the authenticated session principal for step-up. Phase 0 default → null. */
readonly resolvePrincipal?: (ctx: PortUpgradeContext) => AuthenticatedPrincipal | null
}
function toP5Context(
ctx: PortUpgradeContext,
resolvePrincipal: (ctx: PortUpgradeContext) => AuthenticatedPrincipal | null,
) {
return {
capabilityRaw: ctx.capabilityRaw,
originHeader: ctx.originHeader,
expectedAud: ctx.expectedAud,
requestedHostId: ctx.requestedHostId,
requiredRight: ctx.requiredRight,
remoteAddrHash: ctx.remoteAddrHash,
activeSessionCount: ctx.activeSessionCount,
// Reconstruct the full DPoP context; htu/htm come from the relay's authority, not the client.
dpop: { proofJws: ctx.dpop.proof ?? '', htu: htuFor(ctx.expectedAud), htm: DPOP_HTM },
principal: resolvePrincipal(ctx),
}
}
function toPortOutcome(outcome: P5AuthzOutcome): PortAuthzOutcome {
return outcome.ok
? { ok: true, hostId: outcome.hostId, principal: outcome.principal.accountId, jti: outcome.jti }
: { ok: false, status: outcome.status }
}
/** Build the P1 `Authorizer` port backed by the real P5 enforcement pipeline. */
export function createAuthorizer(deps: AuthorizerDeps): Authorizer {
const resolvePrincipal = deps.resolvePrincipal ?? (() => null)
return {
async onUpgrade(ctx, now) {
const outcome = await p5OnUpgrade(
toP5Context(ctx, resolvePrincipal),
deps.deps,
deps.allowedOrigins,
now,
)
return toPortOutcome(outcome)
},
async onReattach(ctx, now) {
const outcome = await p5OnReattach(
{ ...toP5Context(ctx, resolvePrincipal), sessionId: ctx.sessionId },
deps.deps,
deps.allowedOrigins,
now,
)
return toPortOutcome(outcome)
},
}
}

View File

@@ -0,0 +1,111 @@
/**
* P5 signing-key setup + capability-token / DPoP bundle builder for Phase 0. Wires the REAL
* relay-auth issue + DPoP primitives (deep-imported test/internal helpers — relay-auth has no
* `exports` map, so subpath imports resolve through the relay-run node_modules symlink). Lifted
* from `e2e/harness/dpop.ts`.
*
* The DPoP `htu`/`htm` are LOAD-BEARING: the browser signs them into the proof and the relay
* re-derives them from the request's own authority (never from client claims). `htuFor` is the
* SINGLE source of truth used by BOTH sides so issuance and verification agree.
*/
import { randomUUID } from 'node:crypto'
import { issueCapabilityToken, type DpopContext } from 'relay-auth'
import type { CapabilityRight } from 'relay-contracts'
import {
configureVerifyKey,
resetVerifyKeyForTest,
} from 'relay-auth/src/config/keys.js'
import {
generateEd25519KeyPair,
exportEd25519PublicRaw,
} from 'relay-auth/src/crypto/ed25519.js'
import { buildDpopProof, resetDpopCacheForTest } from 'relay-auth/src/capability/verify.js'
import { jwkThumbprint } from 'relay-auth/src/crypto/thumbprint.js'
import { principal } from './memory-stores.js'
export { resetDpopCacheForTest }
/** DPoP HTTP method the browser binds into every proof (WS upgrade is a GET). */
export const DPOP_HTM = 'GET' as const
/**
* Canonical DPoP `htu` for a subdomain/aud. The relay derives this from the resolved subdomain
* (its own authority) and the browser signs the identical value — the ONE place the string is
* defined, so the two never drift.
*/
export function htuFor(aud: string): string {
return `https://${aud}/ws`
}
export interface P5Key {
readonly signingKey: CryptoKey
readonly publicRaw: Uint8Array
}
/** Configure the P5 verifying key globally and return the matching private signing key. */
export async function setupP5SigningKey(): Promise<P5Key> {
resetVerifyKeyForTest()
const pair = await generateEd25519KeyPair()
configureVerifyKey(pair.publicKey)
const publicRaw = await exportEd25519PublicRaw(pair.publicKey)
return { signingKey: pair.privateKey, publicRaw }
}
interface Ephemeral {
readonly privateKey: CryptoKey
readonly publicRaw: Uint8Array
}
async function makeEphemeral(): Promise<Ephemeral> {
const pair = await generateEd25519KeyPair()
return { privateKey: pair.privateKey, publicRaw: await exportEd25519PublicRaw(pair.publicKey) }
}
export interface IssueOpts {
readonly accountId: string
readonly host: string
readonly aud: string
readonly rights?: readonly CapabilityRight[]
readonly ttl?: number
readonly now: number
}
/**
* A capability token bundled with a matching DPoP proof. `newDpop(at?)` mints a FRESH DPoP proof
* (new jti) bound to the SAME ephemeral key — needed to re-present the same token under a distinct
* DPoP without tripping the DPoP replay cache.
*/
export interface CapBundle {
readonly raw: string
readonly dpop: DpopContext
readonly newDpop: (at?: number) => Promise<DpopContext>
}
export async function issueCapBundle(signingKey: CryptoKey, o: IssueOpts): Promise<CapBundle> {
const eph = await makeEphemeral()
const cnfJkt = await jwkThumbprint(eph.publicRaw)
const raw = await issueCapabilityToken(
{
principal: principal(o.accountId),
aud: o.aud,
host: o.host,
rights: o.rights ?? ['attach'],
ttlSeconds: o.ttl ?? 45,
cnfJkt,
},
signingKey,
o.now,
)
const htu = htuFor(o.aud)
const newDpop = async (at: number = o.now): Promise<DpopContext> => ({
proofJws: await buildDpopProof(eph.privateKey, eph.publicRaw, {
htu,
htm: DPOP_HTM,
jti: randomUUID(),
iat: at,
}),
htu,
htm: DPOP_HTM,
})
return { raw, dpop: await newDpop(o.now), newDpop }
}

View File

@@ -0,0 +1,119 @@
/**
* Data-plane composition — assembles the REAL term-relay (P1) `createRelayNode` +
* `createAgentListener` and routes every browser upgrade through the REAL `authorizeUpgrade`
* adapter (→ our P5 `Authorizer`). The relay-node performs the OPAQUE byte splice (INV2): it never
* decodes DATA. This is the one composition both the integration test and `main.ts` reuse.
*/
import { createRelayNode, type RelayNode } from 'term-relay/data-plane/relay-node.js'
import {
createAgentListener,
type AgentListener,
type AgentTunnel,
type MtlsVerifier,
type RouteRegistrar,
type TlsServerFactory,
} from 'term-relay/data-plane/agent-listener.js'
import {
authorizeUpgrade,
type UpgradeRequest,
type UpgradeDecision,
} from 'term-relay/data-plane/upgrade.js'
import type { RouteResolver } from 'term-relay/data-plane/subdomain-router.js'
import type { DataPlaneConfig } from 'term-relay/data-plane/config.js'
import type { Authorizer } from 'term-relay/data-plane/authz-port.js'
import type { CapabilityRight } from 'relay-contracts'
import type { ScheduleHandle } from 'term-relay/mux/heartbeat.js'
const RELAY_NODE_ID = 'relay-run-node-0'
const REMOTE_ADDR_SALT = 'relay-run-dev-salt'
/** Build a frozen DataPlaneConfig without touching disk (cert paths are informational here — the
* live TLS servers are constructed by main.ts, not from these fields). */
export function makeDataPlaneConfig(overrides: Partial<DataPlaneConfig> = {}): DataPlaneConfig {
return Object.freeze({
baseDomain: 'term.localhost',
bindHost: '127.0.0.1',
bindPort: 8443,
agentBindPort: 8444,
tlsCertPath: '(in-memory)',
tlsKeyPath: '(in-memory)',
agentCaCertPath: '(in-memory)',
agentCaChainPath: '(in-memory)',
relayNodeId: RELAY_NODE_ID,
maxFrameBytes: 1024 * 1024,
initialWindowBytes: 256 * 1024,
heartbeatIntervalMs: 15_000,
routeTtlMs: 45_000,
...overrides,
})
}
/** A no-op route registrar (Phase 0 has no Redis routing table; routes live in the listener's Map). */
export function noopRegistrar(): RouteRegistrar {
return {
register: async () => {},
heartbeat: async () => {},
deregister: async () => {},
}
}
export interface DataPlaneDeps {
readonly config: DataPlaneConfig
readonly authorizer: Authorizer
readonly resolver: RouteResolver
readonly mtls: MtlsVerifier
readonly now: () => number
readonly requiredRight?: CapabilityRight
readonly caBundle?: readonly Uint8Array[]
readonly registrar?: RouteRegistrar
readonly onTunnel?: (t: AgentTunnel) => void
/** Inject a manual scheduler to keep heartbeat timers out of unit tests. */
readonly schedule?: (fn: () => void, ms: number) => ScheduleHandle
readonly onError?: (e: unknown) => void
/** Real agent-facing mTLS server factory (main.ts). Omitted in tests → no TLS server started. */
readonly tlsServerFactory?: TlsServerFactory
}
export interface DataPlane {
readonly node: RelayNode
readonly listener: AgentListener
authorize(req: UpgradeRequest): Promise<UpgradeDecision>
}
export function buildDataPlane(deps: DataPlaneDeps): DataPlane {
const listener = createAgentListener({
config: deps.config,
mtls: deps.mtls,
registrar: deps.registrar ?? noopRegistrar(),
onTunnel: deps.onTunnel ?? (() => {}),
caBundle: deps.caBundle ?? [],
...(deps.tlsServerFactory ? { tlsServerFactory: deps.tlsServerFactory } : {}),
...(deps.schedule ? { schedule: deps.schedule } : {}),
...(deps.onError ? { onError: deps.onError } : {}),
})
const authorize = (req: UpgradeRequest): Promise<UpgradeDecision> =>
authorizeUpgrade(req, {
authorizer: deps.authorizer,
resolver: deps.resolver,
baseDomain: deps.config.baseDomain,
now: deps.now,
remoteAddrSalt: REMOTE_ADDR_SALT,
requiredRight: deps.requiredRight ?? 'attach',
})
const node = createRelayNode({ config: deps.config, listener, authorize })
return { node, listener, authorize }
}
/** In-memory RouteResolver: subdomain label → resolved host (P3's job in production). */
export function memoryRouteResolver(
hosts: ReadonlyMap<string, { hostId: string; accountId: string }>,
): RouteResolver {
return {
resolveSubdomain: async (subdomain) => {
const h = hosts.get(subdomain)
return h ? { hostId: h.hostId, accountId: h.accountId, subdomain } : null
},
}
}

View File

@@ -0,0 +1,134 @@
/**
* Phase 0 in-memory control-plane stores — the ONLY seams that stand in for the true I/O
* boundaries P1/P3 own (host registry / session registry / revocation store / token buckets /
* audit sink / revocation bus). Every security DECISION still runs through the REAL relay-auth
* exports; these fakes only supply storage. Lifted verbatim from `e2e/harness/fakes.ts` — the
* reference wiring named in docs/PLAN_RELAY_RUN_PHASE0.md.
*/
import type { HostRecord, KillSignal, RevocationBus } from 'relay-contracts'
import type {
AuthenticatedPrincipal,
AuditEvent,
AuditSink,
HostRegistryPort,
SessionRegistryPort,
RevocationStore,
TokenBucketStore,
} from 'relay-auth'
/** An authenticated principal (the ONLY source of accountId, INV3). */
export function principal(
accountId: string,
overrides: Partial<AuthenticatedPrincipal> = {},
): AuthenticatedPrincipal {
return {
kind: 'human',
accountId,
principalId: 'cred-' + accountId,
amr: ['passkey'],
authAt: 1000,
stepUpAt: null,
...overrides,
}
}
/** A minimal online HostRecord for the authz registry (agentPubkey here is unused by authz). */
export function makeHostRecord(
accountId: string,
hostId: string,
subdomain: string,
status: HostRecord['status'] = 'online',
): HostRecord {
return {
hostId,
accountId,
subdomain,
agentPubkey: new Uint8Array(32),
enrollFpr: 'fpr-' + hostId,
status,
lastSeen: '2026-01-01T00:00:00.000Z',
createdAt: '2026-01-01T00:00:00.000Z',
revokedAt: null,
}
}
export function fakeHostRegistry(hosts: readonly HostRecord[]): HostRegistryPort {
const map = new Map(hosts.map((h) => [h.hostId, h]))
return { getById: async (id) => map.get(id) ?? null }
}
export interface MutableSessionRegistry extends SessionRegistryPort {
add(entry: { sessionId: string; hostId: string; accountId: string }): void
}
export function fakeSessionRegistry(
seed: readonly { sessionId: string; hostId: string; accountId: string }[] = [],
): MutableSessionRegistry {
const map = new Map(seed.map((s) => [s.sessionId, { hostId: s.hostId, accountId: s.accountId }]))
return {
getById: async (id) => map.get(id) ?? null,
add: (e) => {
map.set(e.sessionId, { hostId: e.hostId, accountId: e.accountId })
},
}
}
export interface RevocationFake extends RevocationStore {
readonly revoked: Set<string>
readonly consumed: Set<string>
}
export function fakeRevocationStore(): RevocationFake {
const revoked = new Set<string>()
const consumed = new Set<string>()
return {
revoked,
consumed,
isRevoked: async (jti) => revoked.has(jti),
revokeJti: async (jti) => {
revoked.add(jti)
},
consumeOnce: async (jti) => {
if (consumed.has(jti)) return false
consumed.add(jti)
return true
},
}
}
export interface TokenBucketFake extends TokenBucketStore {
readonly blocked: Set<string>
readonly calls: string[]
}
/** Token bucket that always allows unless a key is added to `blocked`. */
export function fakeTokenBucket(): TokenBucketFake {
const blocked = new Set<string>()
const calls: string[] = []
return {
blocked,
calls,
take: async (key) => {
calls.push(key)
return !blocked.has(key)
},
}
}
export interface AuditFake extends AuditSink {
readonly events: AuditEvent[]
}
export function fakeAuditSink(): AuditFake {
const events: AuditEvent[] = []
return { events, append: async (e) => void events.push(e) }
}
export interface RevocationBusFake extends RevocationBus {
readonly published: KillSignal[]
}
export function fakeRevocationBus(): RevocationBusFake {
const published: KillSignal[] = []
return { published, publish: async (s) => void published.push(s) }
}

View File

@@ -0,0 +1,212 @@
/**
* Phase 0 world — composes the REAL relay-auth (P5) enforcement + relay-e2e (P4) handshake through
* the in-memory stores, tailored for the live data-plane path: a host's capability-token `aud` is
* the SINGLE subdomain label (what `authorizeUpgrade` derives from the Host header), not the FQDN.
* Directly modelled on `e2e/harness/world.ts` (the reference wiring named in the build spec).
*
* Only the true I/O boundaries are faked; every security check (Origin/CSWSH, token verify, DPoP
* PoP, cross-tenant gate, single-use jti) is the production relay-auth path.
*/
import { randomUUID, randomBytes } from 'node:crypto'
import type { AeadAlg, E2ESession, HostHello } from 'relay-contracts'
import {
createClientHandshake,
createHostHandshake,
createE2ESession,
MemoryDevicePinStore,
} from 'relay-e2e'
import {
signDeviceAuthProof,
verifyDeviceProof,
type EnforceDeps,
type StepUpPolicy,
} from 'relay-auth'
import {
generateEd25519KeyPair,
exportEd25519PublicRaw,
importEd25519PublicRaw,
signEd25519,
verifyEd25519,
} from 'relay-auth/src/crypto/ed25519.js'
import type { MtlsVerifier } from 'term-relay/data-plane/agent-listener.js'
import type { Authorizer } from 'term-relay/data-plane/authz-port.js'
import type { RouteResolver } from 'term-relay/data-plane/subdomain-router.js'
import {
fakeAuditSink,
fakeHostRegistry,
fakeRevocationStore,
fakeSessionRegistry,
fakeTokenBucket,
makeHostRecord,
principal,
type AuditFake,
type MutableSessionRegistry,
type RevocationFake,
type TokenBucketFake,
} from './memory-stores.js'
import { issueCapBundle, setupP5SigningKey, resetDpopCacheForTest, type CapBundle } from './capability.js'
import { createAuthorizer } from './authorizer.js'
import { memoryRouteResolver } from './data-plane.js'
export const DEFAULT_NOW = 1_700_000_000
const REPLAY_ALG: AeadAlg = 'xchacha20-poly1305'
/** Phase 0 default: step-up never required (F2: null session-principal is correct). */
export const NO_STEP_UP: StepUpPolicy = {
required: false,
maxAgeSeconds: Number.MAX_SAFE_INTEGER,
requiredMethod: 'passkey',
}
/** A seeded host: authz identity (hostId/accountId/label) + §4.4 e2e identity + replay secret. */
export interface HostFixture {
readonly label: string
readonly hostId: string
readonly accountId: string
readonly subdomain: string
readonly origin: string
readonly agentPubkey: Uint8Array
readonly hostContentSecret: Uint8Array
readonly replayAlg: AeadAlg
readonly signingPriv: CryptoKey
}
async function makeHostFixture(
label: string,
accountId: string,
baseDomain: string,
): Promise<HostFixture> {
const kp = await generateEd25519KeyPair()
const agentPubkey = await exportEd25519PublicRaw(kp.publicKey)
return {
label,
hostId: randomUUID(),
accountId,
subdomain: label,
origin: `https://${label}.${baseDomain}`,
agentPubkey,
hostContentSecret: new Uint8Array(randomBytes(32)),
replayAlg: REPLAY_ALG,
signingPriv: kp.privateKey,
}
}
export interface EstablishedSessions {
readonly client: E2ESession
readonly host: E2ESession
readonly agentPubkey: Uint8Array
}
export interface Phase0World {
readonly now: number
readonly signingKey: CryptoKey
/** Raw 32-byte Ed25519 P5 verify pubkey (for CAPABILITY_SIGN_PUBKEY_B64). */
readonly publicRaw: Uint8Array
readonly baseDomain: string
readonly host: HostFixture
readonly allowedOrigins: readonly string[]
readonly deps: EnforceDeps
readonly audit: AuditFake
readonly revocation: RevocationFake
readonly buckets: TokenBucketFake
readonly sessions: MutableSessionRegistry
readonly resolver: RouteResolver
readonly authorizer: Authorizer
readonly mtls: MtlsVerifier
issueCap(o?: { ttl?: number; now?: number }): Promise<CapBundle>
establishSession(opts?: {
now?: number
verifyAgainstPubkey?: Uint8Array
tamperHostHello?: (h: HostHello) => HostHello
}): Promise<EstablishedSessions>
}
export async function buildPhase0World(now: number = DEFAULT_NOW): Promise<Phase0World> {
const baseDomain = 'term.localhost'
const { signingKey, publicRaw } = await setupP5SigningKey()
resetDpopCacheForTest()
const host = await makeHostFixture('alice', 'acct-alice', baseDomain)
const hosts = fakeHostRegistry([makeHostRecord(host.accountId, host.hostId, host.subdomain)])
const sessions = fakeSessionRegistry([])
const revocation = fakeRevocationStore()
const buckets = fakeTokenBucket()
const audit = fakeAuditSink()
const allowedOrigins = [host.origin]
const deps: EnforceDeps = {
hosts,
sessions,
revocation,
buckets,
audit,
stepUpPolicyFor: () => NO_STEP_UP,
}
const routeMap = new Map([[host.subdomain, { hostId: host.hostId, accountId: host.accountId }]])
const resolver = memoryRouteResolver(routeMap)
const authorizer = createAuthorizer({ deps, allowedOrigins, now: () => now })
const mtls: MtlsVerifier = {
verifyPeer: () => ({ hostId: host.hostId, accountId: host.accountId }),
}
return {
now,
signingKey,
publicRaw,
baseDomain,
host,
allowedOrigins,
deps,
audit,
revocation,
buckets,
sessions,
resolver,
authorizer,
mtls,
issueCap(o = {}) {
return issueCapBundle(signingKey, {
accountId: host.accountId,
host: host.hostId,
aud: host.subdomain,
ttl: o.ttl,
now: o.now ?? now,
})
},
async establishSession(opts = {}): Promise<EstablishedSessions> {
const hsNow = opts.now ?? now
const client = createClientHandshake({
aeadOffer: ['xchacha20-poly1305', 'aes-256-gcm'],
deviceAuthProofProvider: {
proofFor: (_hostId, binding) =>
signDeviceAuthProof(principal(host.accountId), binding, signingKey, hsNow),
},
verifier: {
verify: async (pub, transcript, sig) =>
verifyEd25519(await importEd25519PublicRaw(pub), sig, transcript),
},
pinStore: new MemoryDevicePinStore(),
hostId: host.hostId,
})
const hostHs = createHostHandshake({
signer: { sign: (transcript) => signEd25519(host.signingPriv, transcript) },
agentPubkey: host.agentPubkey,
supported: ['xchacha20-poly1305', 'aes-256-gcm'],
verifyDeviceProof: (proof, binding) => verifyDeviceProof(proof, binding, hsNow),
})
const clientHello = await client.start()
const rawHostHello = await hostHs.onClientHello(clientHello)
const hostHello = opts.tamperHostHello ? opts.tamperHostHello(rawHostHello) : rawHostHello
const clientResult = await client.onHostHello(
hostHello,
opts.verifyAgainstPubkey ?? host.agentPubkey,
)
return {
client: createE2ESession('client', clientResult),
host: createE2ESession('host', hostHs.result!),
agentPubkey: host.agentPubkey,
}
},
}
}

View File

@@ -0,0 +1,104 @@
/**
* `WebSocketLike` adapters. Two of them:
* - `makeSocketPair()` — an in-process duplex pair used to drive the REAL relay-node/mux in tests
* (no sockets, deterministic). Delivery is synchronous but non-reentrant (a per-endpoint
* trampoline) so a send emitted while draining the peer's inbox can't recurse unboundedly.
* - `wsToWebSocketLike()` — wraps a real `ws` WebSocket to the same interface for `main.ts`.
* Bytes are COPIED on send (the mux slices/retains buffers) so no caller can alias another's memory.
*/
import type { WebSocket as WsWebSocket } from 'ws'
import type { WebSocketLike } from 'term-relay/data-plane/ws-like.js'
interface Endpoint extends WebSocketLike {
/** Internal: deliver bytes that the peer sent to THIS endpoint. */
_deliver(data: Uint8Array): void
_closed(): void
}
function makeEndpoint(): Endpoint {
let onMessage: ((data: Uint8Array) => void) | null = null
let onClose: (() => void) | null = null
let peer: Endpoint | null = null
const inbox: Uint8Array[] = []
let draining = false
let closed = false
const ep: Endpoint & { _link(p: Endpoint): void } = {
_link(p: Endpoint) {
peer = p
},
send(data: Uint8Array) {
if (closed || peer === null) return
peer._deliver(new Uint8Array(data)) // copy: never alias the sender's buffer
},
close(_code?: number) {
if (closed) return
closed = true
onClose?.()
peer?._closed()
},
onMessage(handler) {
onMessage = handler
},
onClose(handler) {
onClose = handler
},
_deliver(data: Uint8Array) {
inbox.push(data)
if (draining) return
draining = true
try {
while (inbox.length > 0) {
const next = inbox.shift()!
onMessage?.(next)
}
} finally {
draining = false
}
},
_closed() {
if (closed) return
closed = true
onClose?.()
},
}
return ep as Endpoint & { _link(p: Endpoint): void }
}
/** An in-process duplex `WebSocketLike` pair: `a.send` → `b`'s message handler and vice versa. */
export function makeSocketPair(): { a: WebSocketLike; b: WebSocketLike } {
const a = makeEndpoint() as Endpoint & { _link(p: Endpoint): void }
const b = makeEndpoint() as Endpoint & { _link(p: Endpoint): void }
a._link(b)
b._link(a)
return { a, b }
}
function toUint8(data: unknown): Uint8Array | null {
if (data instanceof Uint8Array) return data
if (data instanceof ArrayBuffer) return new Uint8Array(data)
if (Array.isArray(data)) return Buffer.concat(data.map((d) => (d instanceof Uint8Array ? d : Buffer.from(d))))
if (Buffer.isBuffer(data)) return new Uint8Array(data)
return null
}
/** Adapt a live `ws` WebSocket to `WebSocketLike` (binary frames only). */
export function wsToWebSocketLike(ws: WsWebSocket): WebSocketLike {
return {
send(data: Uint8Array) {
ws.send(data, { binary: true })
},
close(code?: number) {
ws.close(code)
},
onMessage(handler) {
ws.on('message', (data: unknown) => {
const bytes = toUint8(data)
if (bytes !== null) handler(bytes)
})
},
onClose(handler) {
ws.on('close', () => handler())
},
}
}