feat(relay): rendezvous-relay service — 7 packages + plans (contracts/transport/agent/control-plane/e2e/auth/web)
Multi-tenant reverse-tunnel service ("ngrok for Claude Code" with E2E): a
host-agent dials OUT to an operator-run relay; external devices reach the host
THROUGH the relay, routed by per-tenant subdomain, forwarding ciphertext only
(the relay never sees plaintext). Lets a customer reach their own self-hosted
web-terminal from anywhere with zero networking setup.
Packages — all tsc-strict + vitest green (656 tests), cross-package integration verified:
- relay-contracts: frozen shared contracts (mux frame codec, data model,
capability token, E2E envelope, pairing) — the src/types.ts analog
- term-relay: native WS mux + stateless data plane (subdomain routing, ciphertext forward)
- agent: host-agent (pairing, per-host Ed25519 + mTLS dial-out, forwards to 127.0.0.1:3000)
- control-plane: accounts/hosts registry, pairing-code flow, routing table, provisioning
- relay-e2e: browser<->agent E2E (X25519 ECDH through relay, AEAD, anti-replay, recoverable replay key)
- relay-auth: Passkey/WebAuthn, capability tokens, per-host certs, deny-by-default tenant isolation
- relay-web: browser login + Web Crypto E2E + client-side preview rendering
Security invariants INV1-15 enforced; cross-tenant isolation CI tripwire live
(.github/workflows/relay-tripwire.yml). Design + implementation-level plans in
docs/PLAN_RELAY_*.md and docs/EXPLORE_RELAY_SERVICE.md.
NOTE: generated autonomously per the reviewed plans. The security-critical
packages (relay-e2e, relay-auth) REQUIRE expert security audit before any real
deployment — passing tests prove self-consistency, not resistance to attackers.
Base app (src/, public/) unchanged; concurrent desktop work left uncommitted.
This commit is contained in:
76
term-relay/mux/flow-control.ts
Normal file
76
term-relay/mux/flow-control.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* T4 · Credit-based per-stream flow control (§4.1) — pure, in-memory, deny-by-default.
|
||||
*
|
||||
* Each stream has an independent send window: exhausting stream A's credit MUST NOT affect
|
||||
* stream B (heavy `vim`/`top` redraw can't starve another stream). Unknown/released streams
|
||||
* carry no credit (deny-by-default: prevents send-after-close write amplification).
|
||||
* Immutable-style updates (replace the record, never mutate in place).
|
||||
*/
|
||||
export const DEFAULT_INITIAL_WINDOW = 256 * 1024
|
||||
export const WINDOW_REPLENISH_THRESHOLD = 0.5 // emit WINDOW_UPDATE when half consumed
|
||||
|
||||
interface StreamCredit {
|
||||
readonly window: number // remaining SEND credit
|
||||
readonly initial: number // initial window (for replenish threshold)
|
||||
readonly delivered: number // RECEIVED bytes since last replenish
|
||||
}
|
||||
|
||||
export interface FlowController {
|
||||
registerStream(streamId: number, initialWindow: number): void
|
||||
releaseStream(streamId: number): void
|
||||
canSend(streamId: number, bytes: number): boolean
|
||||
consumeSendCredit(streamId: number, bytes: number): void
|
||||
grantCredit(streamId: number, credit: number): void
|
||||
creditFor(streamId: number): number
|
||||
onDelivered(streamId: number, bytes: number): number
|
||||
}
|
||||
|
||||
export function createFlowController(): FlowController {
|
||||
const streams = new Map<number, StreamCredit>()
|
||||
|
||||
return {
|
||||
registerStream(streamId, initialWindow) {
|
||||
streams.set(streamId, { window: initialWindow, initial: initialWindow, delivered: 0 })
|
||||
},
|
||||
|
||||
releaseStream(streamId) {
|
||||
streams.delete(streamId)
|
||||
},
|
||||
|
||||
canSend(streamId, bytes) {
|
||||
const c = streams.get(streamId)
|
||||
if (c === undefined) return false // deny-by-default (unknown/released stream)
|
||||
return bytes <= c.window
|
||||
},
|
||||
|
||||
consumeSendCredit(streamId, bytes) {
|
||||
const c = streams.get(streamId)
|
||||
if (c === undefined) return
|
||||
const nextWindow = c.window - bytes
|
||||
// Never go negative (canSend gates callers; clamp defensively).
|
||||
streams.set(streamId, { ...c, window: Math.max(0, nextWindow) })
|
||||
},
|
||||
|
||||
grantCredit(streamId, credit) {
|
||||
const c = streams.get(streamId)
|
||||
if (c === undefined) return
|
||||
streams.set(streamId, { ...c, window: c.window + credit })
|
||||
},
|
||||
|
||||
creditFor(streamId) {
|
||||
return streams.get(streamId)?.window ?? 0
|
||||
},
|
||||
|
||||
onDelivered(streamId, bytes) {
|
||||
const c = streams.get(streamId)
|
||||
if (c === undefined) return 0
|
||||
const delivered = c.delivered + bytes
|
||||
if (delivered >= c.initial * WINDOW_REPLENISH_THRESHOLD) {
|
||||
streams.set(streamId, { ...c, delivered: 0 })
|
||||
return delivered // replenish this many bytes of credit back to the peer
|
||||
}
|
||||
streams.set(streamId, { ...c, delivered })
|
||||
return 0
|
||||
},
|
||||
}
|
||||
}
|
||||
28
term-relay/mux/frame-codec.ts
Normal file
28
term-relay/mux/frame-codec.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* T2 · Mux frame codec (§4.1) — the 15-byte header + opaque payload, and the CBOR/int
|
||||
* control-frame payload codecs.
|
||||
*
|
||||
* The binary codec is FROZEN in `relay-contracts` (INDEX §4.1, OQ2 resolved: the codec ships
|
||||
* in contracts so P1 relay and P2 agent share ONE encoder). P1 re-exports it as the stable
|
||||
* `mux/` surface that T6 (mux-session) and the data plane consume, adding NO wire behavior.
|
||||
*
|
||||
* Security (INV2/INV11): this module imports NO terminal/ANSI parser. DATA payloads are opaque
|
||||
* `Uint8Array` and are never inspected here; only OPEN/WINDOW_UPDATE/GOAWAY control frames are
|
||||
* shape-validated (via the contracts' Zod guards).
|
||||
*/
|
||||
export {
|
||||
MUX_HEADER_BYTES,
|
||||
MUX_VERSION,
|
||||
encodeMuxFrame,
|
||||
decodeHeader,
|
||||
decodeMuxFrame,
|
||||
encodeOpen,
|
||||
decodeOpen,
|
||||
encodeWindowUpdate,
|
||||
decodeWindowUpdate,
|
||||
encodeGoaway,
|
||||
decodeGoaway,
|
||||
} from 'relay-contracts'
|
||||
|
||||
export { TYPE_TO_BYTE, BYTE_TO_TYPE } from './type-bytes.js'
|
||||
export type { MuxFrameType, MuxFrameHeader, MuxOpen, GoAwayReason } from 'relay-contracts'
|
||||
22
term-relay/mux/frame-guards.ts
Normal file
22
term-relay/mux/frame-guards.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* T2 · Boundary Zod validation of control-frame payloads (OPEN / WINDOW_UPDATE / GOAWAY).
|
||||
*
|
||||
* The decode+validate functions live in `relay-contracts` (they CBOR/int-decode then Zod-guard,
|
||||
* throwing `ContractDecodeError` on a bad shape — never a partial value, INV boundary validation).
|
||||
* P1 re-exports them plus a small `assertWithinFrameCeiling` helper the mux session uses to reject
|
||||
* an oversized `payloadLen` before allocating (kills the OOM footgun, T6 frame-ceiling case).
|
||||
*
|
||||
* Security: NEVER validates DATA content (INV2) — DATA is opaque bytes.
|
||||
*/
|
||||
import { ContractDecodeError } from 'relay-contracts'
|
||||
|
||||
export { decodeOpen, decodeWindowUpdate, decodeGoaway } from 'relay-contracts'
|
||||
|
||||
/** Throw if a decoded `payloadLen` exceeds the configured ceiling (caller RSTs the stream). */
|
||||
export function assertWithinFrameCeiling(payloadLen: number, maxFrameBytes: number): void {
|
||||
if (payloadLen > maxFrameBytes) {
|
||||
throw new ContractDecodeError(
|
||||
`frame payloadLen ${payloadLen} exceeds ceiling ${maxFrameBytes}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
96
term-relay/mux/heartbeat.ts
Normal file
96
term-relay/mux/heartbeat.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* T5 · Heartbeat / liveness (§4.1) — 15s PING/PONG with an injectable timer.
|
||||
*
|
||||
* PING carries a fresh 8-byte crypto-random token every interval; the matching PONG must echo
|
||||
* the EXACT outstanding token to reset the miss counter. An unmatched/stale/replayed PONG is
|
||||
* ignored (INV13-adjacent: a replayed PONG can't keep a dead/hijacked tunnel "alive").
|
||||
* `HEARTBEAT_MISS_LIMIT` consecutive missed PONGs (~45s) ⇒ `onDead` fires exactly once.
|
||||
*/
|
||||
import { randomBytes } from 'node:crypto'
|
||||
|
||||
export const HEARTBEAT_INTERVAL_MS = 15_000
|
||||
export const HEARTBEAT_MISS_LIMIT = 3
|
||||
const TOKEN_BYTES = 8
|
||||
|
||||
export interface ScheduleHandle {
|
||||
cancel(): void
|
||||
}
|
||||
|
||||
export interface HeartbeatDeps {
|
||||
sendPing(token: Uint8Array): void
|
||||
onDead(): void
|
||||
now?: () => number
|
||||
schedule?: (fn: () => void, ms: number) => ScheduleHandle
|
||||
intervalMs?: number
|
||||
}
|
||||
|
||||
export interface Heartbeat {
|
||||
start(): void
|
||||
stop(): void
|
||||
onPong(token: Uint8Array): void
|
||||
}
|
||||
|
||||
function defaultSchedule(fn: () => void, ms: number): ScheduleHandle {
|
||||
const id = setInterval(fn, ms)
|
||||
return { cancel: () => clearInterval(id) }
|
||||
}
|
||||
|
||||
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||
if (a.length !== b.length) return false
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function createHeartbeat(deps: HeartbeatDeps): Heartbeat {
|
||||
const schedule = deps.schedule ?? defaultSchedule
|
||||
const intervalMs = deps.intervalMs ?? HEARTBEAT_INTERVAL_MS
|
||||
|
||||
let handle: ScheduleHandle | null = null
|
||||
let outstanding: Uint8Array | null = null // token awaiting a PONG, or null once matched
|
||||
let misses = 0
|
||||
let dead = false
|
||||
|
||||
function tick(): void {
|
||||
if (dead) return
|
||||
// If the previous PING was never answered, count a miss.
|
||||
if (outstanding !== null) {
|
||||
misses += 1
|
||||
if (misses >= HEARTBEAT_MISS_LIMIT) {
|
||||
dead = true
|
||||
stop()
|
||||
deps.onDead()
|
||||
return
|
||||
}
|
||||
}
|
||||
const token = new Uint8Array(randomBytes(TOKEN_BYTES))
|
||||
outstanding = token
|
||||
deps.sendPing(token)
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
if (handle !== null) {
|
||||
handle.cancel()
|
||||
handle = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
if (handle !== null || dead) return
|
||||
// Send the first PING immediately, then on every interval.
|
||||
tick()
|
||||
handle = schedule(tick, intervalMs)
|
||||
},
|
||||
stop,
|
||||
onPong(token) {
|
||||
if (dead || outstanding === null) return
|
||||
if (bytesEqual(token, outstanding)) {
|
||||
outstanding = null
|
||||
misses = 0
|
||||
}
|
||||
// A stale/unknown token is ignored — it does NOT reset the miss counter.
|
||||
},
|
||||
}
|
||||
}
|
||||
296
term-relay/mux/mux-session.ts
Normal file
296
term-relay/mux/mux-session.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* T6 · Mux session — the multiplexer over ONE WebSocket tunnel. Assembles the T2 codec,
|
||||
* T3 stream state machine, T4 credit flow-control, and T5 heartbeat into a demux/dispatch loop.
|
||||
*
|
||||
* Security (INV2/INV5/INV11): DATA payloads are copied OPAQUE and never inspected; buffers are
|
||||
* per-stream, per-session (no global pool → no cross-tenant buffer bleed) and freed on CLOSE;
|
||||
* this module imports NO terminal/ANSI parser. An inbound DATA for an unknown/closed stream, an
|
||||
* illegal transition, or a `payloadLen > maxFrameBytes` frame RSTs that ONE stream — the tunnel
|
||||
* stays up (INV13). Ordering is preserved and never reordered/deduped (so P4's `seq` holds).
|
||||
*/
|
||||
import { concatBytes, GOAWAY_CODE_TO_REASON, type MuxOpen } from 'relay-contracts'
|
||||
import {
|
||||
MUX_HEADER_BYTES,
|
||||
encodeMuxFrame,
|
||||
decodeHeader,
|
||||
encodeOpen,
|
||||
decodeOpen,
|
||||
encodeWindowUpdate,
|
||||
decodeWindowUpdate,
|
||||
encodeGoaway,
|
||||
type MuxFrameHeader,
|
||||
type MuxFrameType,
|
||||
} from './frame-codec.js'
|
||||
import { assertWithinFrameCeiling } from './frame-guards.js'
|
||||
import {
|
||||
initialStreamState,
|
||||
nextStreamState,
|
||||
isTerminal,
|
||||
type StreamState,
|
||||
} from './stream.js'
|
||||
import { createFlowController, type FlowController } from './flow-control.js'
|
||||
import {
|
||||
createHeartbeat,
|
||||
type Heartbeat,
|
||||
type ScheduleHandle,
|
||||
} from './heartbeat.js'
|
||||
|
||||
export interface MuxStreamHandle {
|
||||
readonly streamId: number
|
||||
writeData(payload: Uint8Array): boolean // false ⇒ backpressured (buffered until WINDOW_UPDATE)
|
||||
close(rst?: boolean): void
|
||||
onData(cb: (payload: Uint8Array) => void): void // inbound opaque bytes for THIS stream (INV2)
|
||||
onClose(cb: (rst: boolean) => void): void // remote/RST close of THIS stream
|
||||
}
|
||||
|
||||
export type MuxRole = 'relay' | 'agent'
|
||||
|
||||
export interface MuxSessionDeps {
|
||||
role: MuxRole
|
||||
sendWire(frame: Uint8Array): void
|
||||
onOpen(open: MuxOpen, stream: MuxStreamHandle): void
|
||||
onData(streamId: number, payload: Uint8Array): void // fallback if no per-stream subscriber
|
||||
onClose(streamId: number, rst: boolean): void
|
||||
onDead(): void
|
||||
maxFrameBytes: number
|
||||
initialWindowBytes: number
|
||||
// P1-owned testability seam (NOT a frozen contract): inject the heartbeat timer.
|
||||
schedule?: (fn: () => void, ms: number) => ScheduleHandle
|
||||
heartbeatIntervalMs?: number
|
||||
}
|
||||
|
||||
export interface MuxSession {
|
||||
openStream(open: MuxOpen): MuxStreamHandle
|
||||
onWire(buf: Uint8Array): void
|
||||
drain(lastStreamId: number, reason: number): void
|
||||
close(): void
|
||||
}
|
||||
|
||||
interface StreamCtx {
|
||||
state: StreamState
|
||||
readonly handle: MuxStreamHandle
|
||||
sendBuffer: Uint8Array[]
|
||||
dataSub: ((payload: Uint8Array) => void) | null
|
||||
closeSub: ((rst: boolean) => void) | null
|
||||
}
|
||||
|
||||
const EMPTY = new Uint8Array(0)
|
||||
|
||||
export function createMuxSession(deps: MuxSessionDeps): MuxSession {
|
||||
const flow: FlowController = createFlowController()
|
||||
const streams = new Map<number, StreamCtx>()
|
||||
let pending: Uint8Array = EMPTY
|
||||
let nextStreamId = 1 // relay allocates monotonic, never reused
|
||||
let draining = false
|
||||
let closed = false
|
||||
|
||||
const heartbeat: Heartbeat = createHeartbeat({
|
||||
sendPing: (token) => sendFrame('ping', 0, token, false, false),
|
||||
onDead: () => deps.onDead(),
|
||||
...(deps.schedule ? { schedule: deps.schedule } : {}),
|
||||
...(deps.heartbeatIntervalMs !== undefined ? { intervalMs: deps.heartbeatIntervalMs } : {}),
|
||||
})
|
||||
|
||||
function sendFrame(
|
||||
type: MuxFrameType,
|
||||
streamId: number,
|
||||
payload: Uint8Array,
|
||||
fin: boolean,
|
||||
rst: boolean,
|
||||
): void {
|
||||
if (closed) return
|
||||
const header: MuxFrameHeader = {
|
||||
version: 1,
|
||||
type,
|
||||
fin,
|
||||
rst,
|
||||
streamId,
|
||||
payloadLen: payload.length,
|
||||
}
|
||||
deps.sendWire(encodeMuxFrame(header, payload))
|
||||
}
|
||||
|
||||
function makeHandle(streamId: number): MuxStreamHandle {
|
||||
return {
|
||||
streamId,
|
||||
writeData: (payload) => writeData(streamId, payload),
|
||||
close: (rst = false) => localClose(streamId, rst),
|
||||
onData: (cb) => {
|
||||
const ctx = streams.get(streamId)
|
||||
if (ctx) ctx.dataSub = cb
|
||||
},
|
||||
onClose: (cb) => {
|
||||
const ctx = streams.get(streamId)
|
||||
if (ctx) ctx.closeSub = cb
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function registerStream(streamId: number, state: StreamState): StreamCtx {
|
||||
const handle = makeHandle(streamId)
|
||||
const ctx: StreamCtx = { state, handle, sendBuffer: [], dataSub: null, closeSub: null }
|
||||
streams.set(streamId, ctx)
|
||||
flow.registerStream(streamId, deps.initialWindowBytes)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function writeData(streamId: number, payload: Uint8Array): boolean {
|
||||
const ctx = streams.get(streamId)
|
||||
if (ctx === undefined || isTerminal(ctx.state)) return false
|
||||
if (payload.length > deps.maxFrameBytes) return false // never emit an over-ceiling frame
|
||||
if (!flow.canSend(streamId, payload.length)) {
|
||||
ctx.sendBuffer.push(payload) // backpressure: buffer until WINDOW_UPDATE
|
||||
return false
|
||||
}
|
||||
flow.consumeSendCredit(streamId, payload.length)
|
||||
sendFrame('data', streamId, payload, false, false)
|
||||
return true
|
||||
}
|
||||
|
||||
function flushBuffer(streamId: number): void {
|
||||
const ctx = streams.get(streamId)
|
||||
if (ctx === undefined) return
|
||||
while (ctx.sendBuffer.length > 0) {
|
||||
const next = ctx.sendBuffer[0]!
|
||||
if (!flow.canSend(streamId, next.length)) break
|
||||
ctx.sendBuffer.shift()
|
||||
flow.consumeSendCredit(streamId, next.length)
|
||||
sendFrame('data', streamId, next, false, false)
|
||||
}
|
||||
}
|
||||
|
||||
function localClose(streamId: number, rst: boolean): void {
|
||||
const ctx = streams.get(streamId)
|
||||
if (ctx === undefined) return
|
||||
sendFrame('close', streamId, EMPTY, !rst, rst)
|
||||
cleanupStream(streamId)
|
||||
}
|
||||
|
||||
function cleanupStream(streamId: number): void {
|
||||
flow.releaseStream(streamId)
|
||||
streams.delete(streamId)
|
||||
}
|
||||
|
||||
/** RST exactly ONE stream (illegal transition / unknown stream / ceiling) — tunnel stays up. */
|
||||
function rstStream(streamId: number): void {
|
||||
const ctx = streams.get(streamId)
|
||||
sendFrame('close', streamId, EMPTY, false, true)
|
||||
if (ctx) {
|
||||
if (ctx.closeSub) ctx.closeSub(true)
|
||||
else deps.onClose(streamId, true)
|
||||
}
|
||||
cleanupStream(streamId)
|
||||
}
|
||||
|
||||
function deliverData(ctx: StreamCtx, streamId: number, payload: Uint8Array): void {
|
||||
if (ctx.dataSub) ctx.dataSub(payload)
|
||||
else deps.onData(streamId, payload)
|
||||
// Receiver-side replenishment: grant credit back once the threshold is crossed.
|
||||
const replenish = flow.onDelivered(streamId, payload.length)
|
||||
if (replenish > 0) sendFrame('windowUpdate', streamId, encodeWindowUpdate(replenish), false, false)
|
||||
}
|
||||
|
||||
function dispatch(header: MuxFrameHeader, payload: Uint8Array): void {
|
||||
const { type, streamId, fin, rst } = header
|
||||
|
||||
// Connection-level control (streamId 0).
|
||||
if (streamId === 0) {
|
||||
if (type === 'ping') sendFrame('pong', 0, payload, false, false)
|
||||
else if (type === 'pong') heartbeat.onPong(payload)
|
||||
else if (type === 'goaway') draining = true
|
||||
return
|
||||
}
|
||||
|
||||
if (type === 'open') {
|
||||
if (deps.role !== 'agent') return // relay never receives OPEN
|
||||
if (streams.get(streamId) !== undefined) {
|
||||
rstStream(streamId) // re-OPEN of a live stream is illegal
|
||||
return
|
||||
}
|
||||
const open = decodeOpen(payload)
|
||||
const ctx = registerStream(streamId, 'open')
|
||||
deps.onOpen(open, ctx.handle)
|
||||
return
|
||||
}
|
||||
|
||||
const ctx = streams.get(streamId)
|
||||
if (ctx === undefined) {
|
||||
// DATA/CLOSE/WU for an unknown/closed stream → RST that stream only.
|
||||
sendFrame('close', streamId, EMPTY, false, true)
|
||||
return
|
||||
}
|
||||
|
||||
const transition = nextStreamState(ctx.state, type, fin, 'inbound')
|
||||
if ('illegal' in transition) {
|
||||
rstStream(streamId)
|
||||
return
|
||||
}
|
||||
ctx.state = transition.next
|
||||
|
||||
if (type === 'data') {
|
||||
deliverData(ctx, streamId, payload)
|
||||
} else if (type === 'windowUpdate') {
|
||||
flow.grantCredit(streamId, decodeWindowUpdate(payload))
|
||||
flushBuffer(streamId)
|
||||
} else if (type === 'close') {
|
||||
if (ctx.closeSub) ctx.closeSub(rst)
|
||||
else deps.onClose(streamId, rst)
|
||||
cleanupStream(streamId)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
openStream(open) {
|
||||
if (draining || closed) throw new Error('session draining/closed: cannot open new stream')
|
||||
const streamId = nextStreamId++
|
||||
const ctx = registerStream(streamId, 'open')
|
||||
const full: MuxOpen = { ...open, streamId }
|
||||
sendFrame('open', streamId, encodeOpen(full), false, false)
|
||||
heartbeat.start()
|
||||
return ctx.handle
|
||||
},
|
||||
|
||||
onWire(buf) {
|
||||
if (closed) return
|
||||
heartbeat.start()
|
||||
pending = pending.length === 0 ? buf : concatBytes([pending, buf])
|
||||
for (;;) {
|
||||
if (pending.length < MUX_HEADER_BYTES) break
|
||||
let header: MuxFrameHeader
|
||||
try {
|
||||
header = decodeHeader(pending)
|
||||
} catch {
|
||||
// Unrecoverable framing error — drop the tunnel's buffer (do NOT parse further).
|
||||
pending = EMPTY
|
||||
break
|
||||
}
|
||||
try {
|
||||
assertWithinFrameCeiling(header.payloadLen, deps.maxFrameBytes)
|
||||
} catch {
|
||||
if (header.streamId > 0) rstStream(header.streamId)
|
||||
else deps.onDead()
|
||||
pending = EMPTY
|
||||
break
|
||||
}
|
||||
const total = MUX_HEADER_BYTES + header.payloadLen
|
||||
if (pending.length < total) break // wait for the rest of the payload
|
||||
const payload = pending.slice(MUX_HEADER_BYTES, total)
|
||||
pending = pending.slice(total)
|
||||
dispatch(header, payload)
|
||||
}
|
||||
},
|
||||
|
||||
drain(lastStreamId, reason) {
|
||||
draining = true
|
||||
const label = GOAWAY_CODE_TO_REASON[reason]
|
||||
if (label !== undefined) sendFrame('goaway', 0, encodeGoaway(lastStreamId, label), false, false)
|
||||
},
|
||||
|
||||
close() {
|
||||
closed = true
|
||||
heartbeat.stop()
|
||||
streams.clear()
|
||||
pending = EMPTY
|
||||
},
|
||||
}
|
||||
}
|
||||
88
term-relay/mux/stream.ts
Normal file
88
term-relay/mux/stream.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* T3 · Stream state machine (§4.1) — a PURE reducer over the per-stream lifecycle.
|
||||
*
|
||||
* Lifecycle: `OPEN → (DATA | WINDOW_UPDATE)* → CLOSE`. An illegal transition returns
|
||||
* `{ illegal: true }` so the caller RSTs that ONE stream (never the tunnel) — this is what
|
||||
* lets P4's end-to-end `seq` monotonicity mean something (INV13). No I/O, no input mutation.
|
||||
*/
|
||||
import type { MuxFrameType } from 'relay-contracts'
|
||||
|
||||
export type StreamState =
|
||||
| 'idle'
|
||||
| 'open'
|
||||
| 'halfClosedLocal'
|
||||
| 'halfClosedRemote'
|
||||
| 'closed'
|
||||
|
||||
export type StreamDirection = 'inbound' | 'outbound'
|
||||
|
||||
export type StreamTransition = { readonly next: StreamState } | { readonly illegal: true }
|
||||
|
||||
const ILLEGAL: StreamTransition = { illegal: true }
|
||||
|
||||
export function initialStreamState(): StreamState {
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
export function isTerminal(s: StreamState): boolean {
|
||||
return s === 'closed'
|
||||
}
|
||||
|
||||
/** True when the state still accepts DATA/WINDOW_UPDATE flow in at least one direction. */
|
||||
function isFlowing(s: StreamState): boolean {
|
||||
return s === 'open' || s === 'halfClosedLocal' || s === 'halfClosedRemote'
|
||||
}
|
||||
|
||||
/** The half-closed state produced when `dir` sends FIN while fully open. */
|
||||
function halfCloseFor(dir: StreamDirection): StreamState {
|
||||
return dir === 'outbound' ? 'halfClosedLocal' : 'halfClosedRemote'
|
||||
}
|
||||
|
||||
/**
|
||||
* Total transition function. Returns a NEW state (never mutates). `fin` marks a DATA/CLOSE
|
||||
* frame that half/fully closes the sending direction; `dir` is which side sent the frame.
|
||||
*/
|
||||
export function nextStreamState(
|
||||
current: StreamState,
|
||||
type: MuxFrameType,
|
||||
fin: boolean,
|
||||
dir: StreamDirection,
|
||||
): StreamTransition {
|
||||
if (current === 'closed') return ILLEGAL
|
||||
|
||||
switch (type) {
|
||||
case 'open':
|
||||
// OPEN is only legal from idle; re-OPEN of a live stream is illegal.
|
||||
return current === 'idle' ? { next: 'open' } : ILLEGAL
|
||||
|
||||
case 'data':
|
||||
case 'windowUpdate': {
|
||||
if (current === 'idle') return ILLEGAL // DATA/WU before OPEN
|
||||
if (!isFlowing(current)) return ILLEGAL
|
||||
// WINDOW_UPDATE never carries FIN; only DATA may half-close its direction.
|
||||
if (type === 'windowUpdate') return { next: current }
|
||||
if (!fin) return { next: current }
|
||||
return finTransition(current, dir)
|
||||
}
|
||||
|
||||
case 'close': {
|
||||
if (current === 'idle') return ILLEGAL // CLOSE before OPEN
|
||||
return { next: 'closed' }
|
||||
}
|
||||
|
||||
// ping/pong/goaway are connection-level (streamId 0), never per-stream.
|
||||
default:
|
||||
return ILLEGAL
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply a FIN from `dir` to a flowing state; both-side FIN ⇒ closed. */
|
||||
function finTransition(current: StreamState, dir: StreamDirection): StreamTransition {
|
||||
const half = halfCloseFor(dir)
|
||||
if (current === 'open') return { next: half }
|
||||
// Already half-closed on the OTHER side, and now this side FINs ⇒ fully closed.
|
||||
if (current === 'halfClosedLocal' && dir === 'inbound') return { next: 'closed' }
|
||||
if (current === 'halfClosedRemote' && dir === 'outbound') return { next: 'closed' }
|
||||
// Re-FIN on the already-closed direction is illegal.
|
||||
return ILLEGAL
|
||||
}
|
||||
21
term-relay/mux/type-bytes.ts
Normal file
21
term-relay/mux/type-bytes.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* T2 · MuxFrameType ⇄ byte + flag bit maps (§4.1).
|
||||
*
|
||||
* The frozen wire codes live in `relay-contracts` (§4.1). P1 imports them read-only and
|
||||
* re-exports the local stable surface the mux layer consumes — it NEVER redefines a code.
|
||||
*/
|
||||
export {
|
||||
MUX_HEADER_BYTES,
|
||||
MUX_VERSION,
|
||||
MUX_TYPE_TO_CODE as TYPE_TO_BYTE,
|
||||
MUX_CODE_TO_TYPE as BYTE_TO_TYPE,
|
||||
FLAG_FIN,
|
||||
FLAG_RST,
|
||||
FLAG_RESERVED_MASK,
|
||||
CONNECTION_STREAM_ID,
|
||||
UINT32_MAX,
|
||||
GOAWAY_REASON_TO_CODE,
|
||||
GOAWAY_CODE_TO_REASON,
|
||||
} from 'relay-contracts'
|
||||
|
||||
export type { MuxFrameType, MuxFrameHeader, MuxOpen, GoAwayReason } from 'relay-contracts'
|
||||
Reference in New Issue
Block a user