Files
openchamber/packages/web/server/lib/relay/tunnel-codec.js
T
Bohdan Triapitsyn af84735388 fix(relay): keep bulk streaming from blocking client requests
Negotiate downstream delivery credit and schedule response streams fairly before encryption. Bound queued output, preserve deltas and WebSocket close ordering, and retain legacy peer compatibility.

Validated with 81 relay tests, workspace type-check and lint, web build and mobile assets, and slow-link tests through the production relay. Confirmed on LTE by the maintainer.
2026-09-09 17:41:14 +03:00

383 lines
12 KiB
JavaScript

// Tunnel mux frame codec (Layer 3 of the protocol spec). Pure functions, no I/O.
// JS mirror of packages/ui/src/lib/relay/tunnel-codec.ts (+ the Layer 3
// constants from protocol.ts) — MUST stay byte-compatible with those modules.
// Frame layout: [1 byte frameType (high bit = fragment-continues)][4 byte BE streamId][payload].
// Client-initiated streams use odd streamIds starting at 1; even ids are reserved.
// Spec: .opencode/plans/private-relay/01-protocol-spec.md (Layer 3).
import { MAX_PLAINTEXT_FRAME_BYTES } from './e2ee.js';
export const TUNNEL_FRAME_HEADER_BYTES = 5;
export const TUNNEL_FRAGMENT_FLAG = 0x80;
// Batch envelope container (mirror of protocol.ts). Only used when both peers
// negotiated `batch`. Reserve the per-frame envelope overhead from the payload
// budget so any single frame still fits one 64 KiB encrypted plaintext.
export const BATCH_CONTAINER_TAG_SINGLE = 0x00;
export const BATCH_CONTAINER_TAG_BATCH = 0x01;
export const BATCH_FRAME_LENGTH_BYTES = 4;
export const BATCH_ENVELOPE_RESERVED_BYTES = 1 + BATCH_FRAME_LENGTH_BYTES;
export const MAX_TUNNEL_PAYLOAD_BYTES =
MAX_PLAINTEXT_FRAME_BYTES - TUNNEL_FRAME_HEADER_BYTES - BATCH_ENVELOPE_RESERVED_BYTES;
export const TunnelFrameType = {
HttpRequest: 1,
HttpBody: 2,
HttpResponse: 3,
StreamEnd: 4,
StreamAbort: 5,
WsOpen: 6,
WsOpened: 7,
WsText: 8,
WsBinary: 9,
WsClose: 10,
Ping: 11,
Pong: 12,
DeliveryAck: 13,
};
const TUNNEL_FRAME_TYPE_VALUES = new Set(Object.values(TunnelFrameType));
/** @param {number} value */
export const isTunnelFrameType = (value) => TUNNEL_FRAME_TYPE_VALUES.has(value);
const MAX_STREAM_ID = 0xffffffff;
/** Decode the client's cumulative raw tunnel-frame byte count. */
export const decodeDeliveryAck = (payload) => {
if (payload.length !== 8) throw new Error('invalid delivery acknowledgement');
const bytes = Number(new DataView(payload.buffer, payload.byteOffset, payload.byteLength).getBigUint64(0));
if (!Number.isSafeInteger(bytes)) throw new Error('invalid delivery acknowledgement');
return bytes;
};
export class TunnelCodecError extends Error {
constructor(message) {
super(message);
this.name = 'TunnelCodecError';
}
}
/**
* @param {number} frameType
* @param {number} streamId
* @param {Uint8Array} payload
* @param {boolean} [hasMoreFragments]
*/
export const encodeTunnelFrame = (frameType, streamId, payload, hasMoreFragments = false) => {
if (!Number.isInteger(streamId) || streamId < 0 || streamId > MAX_STREAM_ID) {
throw new TunnelCodecError('invalid stream id');
}
if (payload.length > MAX_TUNNEL_PAYLOAD_BYTES) {
throw new TunnelCodecError('tunnel payload exceeds maximum size');
}
const frame = new Uint8Array(TUNNEL_FRAME_HEADER_BYTES + payload.length);
frame[0] = hasMoreFragments ? frameType | TUNNEL_FRAGMENT_FLAG : frameType;
frame[1] = (streamId >>> 24) & 0xff;
frame[2] = (streamId >>> 16) & 0xff;
frame[3] = (streamId >>> 8) & 0xff;
frame[4] = streamId & 0xff;
frame.set(payload, TUNNEL_FRAME_HEADER_BYTES);
return frame;
};
/**
* @param {Uint8Array} frame
* @returns {{ frameType: number, streamId: number, payload: Uint8Array, hasMoreFragments: boolean }}
*/
export const decodeTunnelFrame = (frame) => {
if (frame.length < TUNNEL_FRAME_HEADER_BYTES) {
throw new TunnelCodecError('tunnel frame too short');
}
const rawType = frame[0];
const hasMoreFragments = (rawType & TUNNEL_FRAGMENT_FLAG) !== 0;
const frameType = rawType & ~TUNNEL_FRAGMENT_FLAG;
if (!isTunnelFrameType(frameType)) {
throw new TunnelCodecError(`unknown tunnel frame type ${frameType}`);
}
const streamId = ((frame[1] << 24) | (frame[2] << 16) | (frame[3] << 8) | frame[4]) >>> 0;
return {
frameType,
streamId,
payload: frame.slice(TUNNEL_FRAME_HEADER_BYTES),
hasMoreFragments,
};
};
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
/** @param {unknown} value */
export const encodeJsonPayload = (value) => textEncoder.encode(JSON.stringify(value));
/**
* @param {Uint8Array} payload
* @param {(parsed: unknown) => boolean} validate
*/
export const decodeJsonPayload = (payload, validate) => {
let parsed;
try {
parsed = JSON.parse(textDecoder.decode(payload));
} catch {
throw new TunnelCodecError('malformed JSON tunnel payload');
}
if (!validate(parsed)) {
throw new TunnelCodecError('unexpected JSON tunnel payload shape');
}
return parsed;
};
/**
* Split a body/message into payload-sized chunks. Empty input yields one empty chunk.
* @param {Uint8Array} bytes
* @param {number} [chunkSize]
*/
export const chunkPayload = (bytes, chunkSize = MAX_TUNNEL_PAYLOAD_BYTES) => {
if (chunkSize <= 0 || chunkSize > MAX_TUNNEL_PAYLOAD_BYTES) {
throw new TunnelCodecError('invalid chunk size');
}
if (bytes.length === 0) return [new Uint8Array(0)];
const chunks = [];
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
chunks.push(bytes.slice(offset, offset + chunkSize));
}
return chunks;
};
/**
* Encode one logical message as one or more frames, setting the fragment flag
* on all but the last. Used for WS messages that exceed the frame budget.
* @param {number} frameType
* @param {number} streamId
* @param {Uint8Array} payload
*/
export const encodeFragmentedMessage = (frameType, streamId, payload) => {
const chunks = chunkPayload(payload);
return chunks.map((chunk, index) => encodeTunnelFrame(frameType, streamId, chunk, index < chunks.length - 1));
};
/**
* Reassembles fragmented messages per (streamId, frameType). Bounded to protect memory.
* @param {number} [maxMessageBytes]
*/
export const createFragmentAssembler = (maxMessageBytes = 16 * 1024 * 1024) => {
const pending = new Map();
return {
/**
* Returns the complete message payload once all fragments arrived, or null
* while more fragments are expected.
* @param {{ frameType: number, streamId: number, payload: Uint8Array, hasMoreFragments: boolean }} frame
*/
push(frame) {
const key = `${frame.streamId}:${frame.frameType}`;
const entry = pending.get(key);
if (!frame.hasMoreFragments && !entry) {
return frame.payload;
}
const chunks = entry?.chunks ?? [];
const totalBytes = (entry?.totalBytes ?? 0) + frame.payload.length;
if (totalBytes > maxMessageBytes) {
pending.delete(key);
throw new TunnelCodecError('fragmented message exceeds maximum size');
}
chunks.push(frame.payload);
if (frame.hasMoreFragments) {
pending.set(key, { chunks, totalBytes });
return null;
}
pending.delete(key);
const message = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
message.set(chunk, offset);
offset += chunk.length;
}
return message;
},
/** @param {number} streamId */
dropStream(streamId) {
for (const key of pending.keys()) {
if (key.startsWith(`${streamId}:`)) pending.delete(key);
}
},
};
};
/**
* Batch envelope encoder (mirror of tunnel-codec.ts encodeFrameBatch). Only used
* when both peers negotiated `batch`. One encrypted WS message still equals one
* encrypt() call — this only changes how many tunnel frames it carries.
* @param {Uint8Array[]} frames
* @returns {Uint8Array}
*/
export const encodeFrameBatch = (frames) => {
if (frames.length === 0) {
throw new TunnelCodecError('cannot encode an empty frame batch');
}
if (frames.length === 1) {
const frame = frames[0];
const out = new Uint8Array(1 + frame.length);
out[0] = BATCH_CONTAINER_TAG_SINGLE;
out.set(frame, 1);
if (out.length > MAX_PLAINTEXT_FRAME_BYTES) {
throw new TunnelCodecError('frame batch exceeds maximum plaintext size');
}
return out;
}
let total = 1;
for (const frame of frames) total += BATCH_FRAME_LENGTH_BYTES + frame.length;
if (total > MAX_PLAINTEXT_FRAME_BYTES) {
throw new TunnelCodecError('frame batch exceeds maximum plaintext size');
}
const out = new Uint8Array(total);
out[0] = BATCH_CONTAINER_TAG_BATCH;
let offset = 1;
for (const frame of frames) {
out[offset] = (frame.length >>> 24) & 0xff;
out[offset + 1] = (frame.length >>> 16) & 0xff;
out[offset + 2] = (frame.length >>> 8) & 0xff;
out[offset + 3] = frame.length & 0xff;
offset += BATCH_FRAME_LENGTH_BYTES;
out.set(frame, offset);
offset += frame.length;
}
return out;
};
/**
* Decodes a batch-envelope plaintext into its ordered tunnel frames.
* @param {Uint8Array} plaintext
* @returns {Uint8Array[]}
*/
export const decodeFrameBatch = (plaintext) => {
if (plaintext.length < 1) {
throw new TunnelCodecError('empty batch plaintext');
}
const tag = plaintext[0];
if (tag === BATCH_CONTAINER_TAG_SINGLE) {
return [plaintext.slice(1)];
}
if (tag !== BATCH_CONTAINER_TAG_BATCH) {
throw new TunnelCodecError(`unknown batch container tag ${tag}`);
}
const frames = [];
let offset = 1;
while (offset < plaintext.length) {
if (offset + BATCH_FRAME_LENGTH_BYTES > plaintext.length) {
throw new TunnelCodecError('truncated batch frame length');
}
const length =
((plaintext[offset] << 24)
| (plaintext[offset + 1] << 16)
| (plaintext[offset + 2] << 8)
| plaintext[offset + 3]) >>> 0;
offset += BATCH_FRAME_LENGTH_BYTES;
if (offset + length > plaintext.length) {
throw new TunnelCodecError('truncated batch frame body');
}
frames.push(plaintext.slice(offset, offset + length));
offset += length;
}
if (frames.length === 0) {
throw new TunnelCodecError('empty frame batch');
}
return frames;
};
// Only high-volume body/stream data is buffered; setup/teardown/keepalive frames
// flush immediately so TTFT, terminal echo, and liveness stay snappy.
const BUFFERED_FRAME_TYPES = new Set([
TunnelFrameType.HttpBody,
TunnelFrameType.WsText,
TunnelFrameType.WsBinary,
]);
// See the TS mirror (tunnel-codec.ts) for the 150ms rationale: the chat render pipeline's
// 100ms input throttle + ~64ms paced-reveal smoothing make a 150ms batch window invisible.
export const DEFAULT_BATCH_WINDOW_MS = 150;
export const DEFAULT_BATCH_MAX_BYTES = 24 * 1024;
export const DEFAULT_BATCH_MAX_FRAMES = 32;
/**
* Outbound batching buffer (mirror of tunnel-codec.ts createOutboundFrameBatcher).
* @param {{
* windowMs?: number,
* maxBatchBytes?: number,
* maxBatchFrames?: number,
* sendBatch: (plaintext: Uint8Array) => void,
* now?: () => number,
* setTimer?: (fn: () => void, ms: number) => any,
* clearTimer?: (handle: any) => void,
* }} options
*/
export const createOutboundFrameBatcher = (options) => {
const windowMs = options.windowMs ?? DEFAULT_BATCH_WINDOW_MS;
const maxBatchBytes = options.maxBatchBytes ?? DEFAULT_BATCH_MAX_BYTES;
const maxBatchFrames = options.maxBatchFrames ?? DEFAULT_BATCH_MAX_FRAMES;
const now = options.now ?? (() => Date.now());
const setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
let buffer = [];
let bufferedBytes = 0;
let timer = null;
let lastFlushAt = 0;
let disposed = false;
const clearPendingTimer = () => {
if (timer !== null) {
clearTimer(timer);
timer = null;
}
};
const flush = () => {
clearPendingTimer();
if (buffer.length === 0) return;
const frames = buffer;
buffer = [];
bufferedBytes = 0;
lastFlushAt = now();
options.sendBatch(encodeFrameBatch(frames));
};
const enqueue = (frame) => {
if (disposed) return;
const frameType = frame[0] & ~TUNNEL_FRAGMENT_FLAG;
if (!BUFFERED_FRAME_TYPES.has(frameType)) {
buffer.push(frame);
flush();
return;
}
const at = now();
if (buffer.length === 0 && at - lastFlushAt >= windowMs) {
buffer.push(frame);
flush();
return;
}
const frameCost = BATCH_FRAME_LENGTH_BYTES + frame.length;
if (buffer.length > 0 && 1 + bufferedBytes + frameCost > MAX_PLAINTEXT_FRAME_BYTES) {
flush();
}
buffer.push(frame);
bufferedBytes += frameCost;
if (bufferedBytes >= maxBatchBytes || buffer.length >= maxBatchFrames) {
flush();
return;
}
if (timer === null) timer = setTimer(flush, windowMs);
};
return {
enqueue,
flush,
dispose() {
disposed = true;
clearPendingTimer();
buffer = [];
bufferedBytes = 0;
},
};
};