Squashed 'third_party/pi/' content from commit d981de122

git-subtree-dir: third_party/pi
git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
This commit is contained in:
2026-09-14 10:40:08 -04:00
commit 67b361f0e9
1669 changed files with 397627 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
# Changelog
## [0.85.1] - 2026-09-05
## [0.85.0] - 2026-09-04
## [0.84.4] - 2026-08-28
## [0.84.3] - 2026-08-24
## [0.84.2] - 2026-08-14
## [0.84.1] - 2026-08-07
## [0.84.0] - 2026-08-06
### Breaking Changes
- Restricted assistant and tool transcript lifecycle schemas to valid state combinations and terminal items.
- Replaced `SessionSummarySchema` and `SessionSummary` with durable `SessionMetadataSchema` and `SessionMetadata` for session lists; runtime state remains in acquired `SessionSnapshot` values ([#7708](https://github.com/earendil-works/pi/pull/7708)).
### Added
- Added transport-neutral CBOR protocol schemas, codecs, and length-prefixed framing for remote pi sessions.
- Added `not_implemented` and `internal_error` protocol error codes for sanitized server failures ([#7644](https://github.com/earendil-works/pi/pull/7644)).

View File

@@ -0,0 +1,41 @@
# @earendil-works/pi-protocol
Runtime-neutral routed envelopes, CBOR encoding, and byte-stream framing for the experimental Pi protocol.
Protocol version `8` defines:
- a version handshake that identifies the logical `serverId`;
- explicit server and Session request targets;
- correlated requests and responses with opaque strict-JSON payloads;
- request cancellation, opaque subscription updates, and out-of-band attachment changes;
- non-empty opaque error codes and bounded transport messages.
A server target contains `{ serverId }`; a Session target contains `{ serverId, sessionId, attachmentId }`. The combined route fences calls to one logical server, durable Session, and live presentation attachment. Management `attach()` and `detach()` return no routing identifiers; the server publishes the selected live route in an out-of-band `attachment` message. Disconnecting releases only that presentation's attachment after admitted calls settle.
Chord owns the payload semantics carried inside these envelopes: `{ serviceId, instance?, member, args }` calls, the `$chord.service` control vocabulary, service catalogues, subscription snapshots and updates, service error codes, and the independent Delta path codecs for replicated states. `pi-protocol` validates that each opaque payload is strict JSON but does not validate or export its Chord grammar. Clients and servers parse those values through `@earendil-works/chord` at the service adapter boundary.
Session-directory state, management results, transcripts, models, plugins, and all other application values remain opaque service data. The real `Session` and `AgentHarness` remain process-local. Server and Session calls route opaquely to their owning providers, where Chord and the application validate and invoke them.
Server and worker lifecycle is intentionally outside this public protocol. The experimental local coordinator is only an opaque message router; each replaceable server process owns the private lifecycle protocol.
Each wire frame consists of a four-byte unsigned big-endian payload length followed by one definite-length CBOR item. `encodeClientMessage()` and `encodeServerMessage()` validate and encode complete frames. `ClientMessageDecoder` and `ServerMessageDecoder` accept arbitrary stream fragmentation and coalescing.
```ts
import {
PROTOCOL_VERSION,
encodeClientMessage,
ServerMessageDecoder,
type ClientHello,
} from "@earendil-works/pi-protocol";
const hello: ClientHello = { type: "hello", version: PROTOCOL_VERSION };
transport.send(encodeClientMessage(hello));
const decoder = new ServerMessageDecoder({ maxFrameLength: 1024 * 1024 });
for (const message of decoder.push(incomingChunk)) handleServerMessage(message);
decoder.end();
```
All envelope schemas reject unknown object properties, and codecs recursively reject non-JSON opaque payloads, including non-finite numbers, byte arrays, `undefined`, prototypes, and cycles. Envelope violations, malformed CBOR, and invalid framing throw `ProtocolValidationError`. Payload-specific adapters must perform their own semantic validation after decoding. Transports must preserve byte order. Peer authentication and authenticated service contexts are not implemented by the experimental transport.
Default limits are 16 MiB per CBOR payload/frame, 1,000,000 array elements or map entries, and 64 nested item levels. The protocol is experimental and has no compatibility guarantees.

View File

@@ -0,0 +1,49 @@
{
"name": "@earendil-works/pi-protocol",
"version": "0.85.1",
"description": "Transport-neutral CBOR protocol for remote pi sessions",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist",
"README.md"
],
"scripts": {
"clean": "shx rm -rf dist",
"build": "tsgo -p tsconfig.build.json",
"test": "vitest --run",
"prepublishOnly": "npm run clean && npm run build"
},
"keywords": [
"pi",
"protocol",
"cbor",
"binary",
"framing"
],
"author": "Earendil Works",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/earendil-works/pi.git",
"directory": "packages/protocol"
},
"engines": {
"node": ">=22.19.0"
},
"dependencies": {
"@earendil-works/chord": "^0.85.1",
"typebox": "1.3.7"
},
"devDependencies": {
"shx": "0.4.0",
"vitest": "4.1.9"
}
}

View File

@@ -0,0 +1,168 @@
import {
CborError,
type CborOptions,
type ResolvedCborOptions,
resolveOptions,
textDecoder,
UINT32_BASE,
} from "./options.ts";
class CborReader {
private readonly bytes: Uint8Array;
private offset = 0;
private readonly options: ResolvedCborOptions;
constructor(bytes: Uint8Array, options: ResolvedCborOptions) {
this.bytes = bytes;
this.options = options;
}
decode(): unknown {
const value = this.readItem(0);
if (this.offset !== this.bytes.byteLength) throw new CborError("CBOR payload contains trailing data");
return value;
}
private readItem(depth: number): unknown {
if (depth > this.options.maxDepth) {
throw new CborError(`CBOR nesting depth exceeds configured limit of ${this.options.maxDepth}`);
}
const initial = this.readByte();
const majorType = initial >>> 5;
const additionalInformation = initial & 0x1f;
switch (majorType) {
case 0:
return this.readArgument(additionalInformation);
case 1: {
const value = -1 - this.readArgument(additionalInformation);
if (!Number.isSafeInteger(value)) throw new CborError("Decoded CBOR integer is outside the safe range");
return value;
}
case 2: {
const length = this.readLength(additionalInformation, "byte string", this.options.maxByteLength);
return new Uint8Array(this.readBytes(length));
}
case 3: {
const length = this.readLength(additionalInformation, "text string", this.options.maxByteLength);
const bytes = this.readBytes(length);
try {
return textDecoder.decode(bytes);
} catch (_error) {
throw new CborError("CBOR text string contains invalid UTF-8");
}
}
case 4: {
const length = this.readLength(additionalInformation, "array", this.options.maxContainerLength);
const result: unknown[] = [];
for (let index = 0; index < length; index++) result.push(this.readItem(depth + 1));
return result;
}
case 5: {
const length = this.readLength(additionalInformation, "map", this.options.maxContainerLength);
const result: Record<string, unknown> = {};
const keys = new Set<string>();
for (let index = 0; index < length; index++) {
const key = this.readItem(depth + 1);
if (typeof key !== "string") throw new CborError("CBOR map keys must be strings");
if (keys.has(key)) throw new CborError("CBOR map contains a duplicate key");
keys.add(key);
Object.defineProperty(result, key, {
configurable: true,
enumerable: true,
value: this.readItem(depth + 1),
writable: true,
});
}
return result;
}
case 6:
throw new CborError("CBOR tags are not supported");
case 7:
return this.readSimple(additionalInformation);
default:
throw new CborError("Malformed CBOR major type");
}
}
private readSimple(additionalInformation: number): unknown {
switch (additionalInformation) {
case 20:
return false;
case 21:
return true;
case 22:
return null;
case 27: {
const bytes = this.readBytes(8);
const value = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getFloat64(0, false);
if (!Number.isFinite(value)) throw new CborError("Decoded CBOR number must be finite");
if (Number.isInteger(value) && !Number.isSafeInteger(value)) {
throw new CborError("Decoded CBOR integer is outside the safe range");
}
return value;
}
case 31:
throw new CborError("CBOR break marker is not supported");
default:
throw new CborError("Unsupported CBOR simple value or floating-point width");
}
}
private readLength(additionalInformation: number, kind: string, limit: number): number {
if (additionalInformation === 31) throw new CborError(`Indefinite-length CBOR ${kind}s are not supported`);
const length = this.readArgument(additionalInformation);
if (length > limit) throw new CborError(`CBOR ${kind} length exceeds configured limit of ${limit}`);
return length;
}
private readArgument(additionalInformation: number): number {
if (additionalInformation < 24) return additionalInformation;
switch (additionalInformation) {
case 24:
return this.readByte();
case 25: {
const bytes = this.readBytes(2);
return bytes[0]! * 0x100 + bytes[1]!;
}
case 26: {
const bytes = this.readBytes(4);
return bytes[0]! * 0x1_000_000 + bytes[1]! * 0x1_0000 + bytes[2]! * 0x100 + bytes[3]!;
}
case 27: {
const high = this.readArgument(26);
const low = this.readArgument(26);
if (high > 0x1f_ffff) throw new CborError("Decoded CBOR integer or length is outside the safe range");
return high * UINT32_BASE + low;
}
case 31:
throw new CborError("Indefinite-length CBOR items are not supported");
default:
throw new CborError("Malformed CBOR additional information");
}
}
private readByte(): number {
if (this.offset >= this.bytes.byteLength) throw new CborError("Truncated CBOR payload");
const value = this.bytes[this.offset]!;
this.offset++;
return value;
}
private readBytes(length: number): Uint8Array {
if (length > this.bytes.byteLength - this.offset) throw new CborError("Truncated CBOR payload");
const value = this.bytes.subarray(this.offset, this.offset + length);
this.offset += length;
return value;
}
}
/** Decodes exactly one item from the protocol's strict RFC 8949 subset. */
export function decodeCbor(bytes: Uint8Array, options?: CborOptions): unknown {
if (!(bytes instanceof Uint8Array)) throw new TypeError("CBOR input must be a Uint8Array");
const resolved = resolveOptions(options);
if (bytes.byteLength > resolved.maxByteLength) {
throw new CborError(`CBOR byte length exceeds configured limit of ${resolved.maxByteLength}`);
}
return new CborReader(bytes, resolved).decode();
}

View File

@@ -0,0 +1,216 @@
import {
CborError,
type CborOptions,
MAX_UINT32,
type ResolvedCborOptions,
resolveOptions,
textDecoder,
textEncoder,
UINT32_BASE,
} from "./options.ts";
class CborWriter {
private buffer: Uint8Array;
private offset = 0;
private readonly maxByteLength: number;
constructor(maxByteLength: number) {
this.maxByteLength = maxByteLength;
this.buffer = new Uint8Array(Math.min(256, maxByteLength));
}
writeByte(value: number): void {
this.ensureCapacity(1);
this.buffer[this.offset] = value;
this.offset++;
}
writeBytes(bytes: Uint8Array): void {
this.ensureCapacity(bytes.byteLength);
this.buffer.set(bytes, this.offset);
this.offset += bytes.byteLength;
}
writeUint16(value: number): void {
this.ensureCapacity(2);
this.buffer[this.offset] = value >>> 8;
this.buffer[this.offset + 1] = value;
this.offset += 2;
}
writeUint32(value: number): void {
this.ensureCapacity(4);
this.buffer[this.offset] = value >>> 24;
this.buffer[this.offset + 1] = value >>> 16;
this.buffer[this.offset + 2] = value >>> 8;
this.buffer[this.offset + 3] = value;
this.offset += 4;
}
writeUint64(value: number): void {
const high = Math.floor(value / UINT32_BASE);
const low = value - high * UINT32_BASE;
this.writeUint32(high);
this.writeUint32(low);
}
writeFloat64(value: number): void {
this.ensureCapacity(9);
this.buffer[this.offset] = 0xfb;
new DataView(this.buffer.buffer).setFloat64(this.offset + 1, value, false);
this.offset += 9;
}
finish(): Uint8Array {
return this.buffer.slice(0, this.offset);
}
private ensureCapacity(additionalBytes: number): void {
const required = this.offset + additionalBytes;
if (required > this.maxByteLength) {
throw new CborError(`CBOR byte length exceeds configured limit of ${this.maxByteLength}`);
}
if (required <= this.buffer.byteLength) return;
let capacity = Math.max(1, this.buffer.byteLength);
while (capacity < required) capacity = Math.min(this.maxByteLength, Math.max(required, capacity * 2));
const expanded = new Uint8Array(capacity);
expanded.set(this.buffer);
this.buffer = expanded;
}
}
function writeArgument(writer: CborWriter, majorType: number, value: number): void {
const prefix = majorType << 5;
if (value < 24) {
writer.writeByte(prefix | value);
} else if (value <= 0xff) {
writer.writeByte(prefix | 24);
writer.writeByte(value);
} else if (value <= 0xffff) {
writer.writeByte(prefix | 25);
writer.writeUint16(value);
} else if (value <= MAX_UINT32) {
writer.writeByte(prefix | 26);
writer.writeUint32(value);
} else {
writer.writeByte(prefix | 27);
writer.writeUint64(value);
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (typeof value !== "object" || value === null) return false;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
function encodeText(writer: CborWriter, value: string, options: ResolvedCborOptions): void {
const bytes = textEncoder.encode(value);
if (bytes.byteLength > options.maxByteLength) {
throw new CborError(`CBOR text string length exceeds configured limit of ${options.maxByteLength}`);
}
if (textDecoder.decode(bytes) !== value)
throw new CborError("CBOR text strings must contain valid Unicode scalar values");
writeArgument(writer, 3, bytes.byteLength);
writer.writeBytes(bytes);
}
function encodeValue(
writer: CborWriter,
value: unknown,
options: ResolvedCborOptions,
depth: number,
ancestors: Set<object>,
): void {
if (depth > options.maxDepth)
throw new CborError(`CBOR nesting depth exceeds configured limit of ${options.maxDepth}`);
if (value === null) {
writer.writeByte(0xf6);
return;
}
if (typeof value === "boolean") {
writer.writeByte(value ? 0xf5 : 0xf4);
return;
}
if (typeof value === "number") {
if (!Number.isFinite(value)) throw new CborError("CBOR numbers must be finite");
if (Number.isInteger(value) && !Object.is(value, -0)) {
if (!Number.isSafeInteger(value)) throw new CborError("CBOR integers must be safe JavaScript integers");
if (value >= 0) writeArgument(writer, 0, value);
else writeArgument(writer, 1, -1 - value);
} else {
writer.writeFloat64(value);
}
return;
}
if (typeof value === "string") {
encodeText(writer, value, options);
return;
}
if (value instanceof Uint8Array) {
if (value.byteLength > options.maxByteLength) {
throw new CborError(`CBOR byte string length exceeds configured limit of ${options.maxByteLength}`);
}
writeArgument(writer, 2, value.byteLength);
writer.writeBytes(value);
return;
}
if (Array.isArray(value)) {
if (ancestors.has(value)) throw new CborError("CBOR values must not contain cycles");
if (value.length > options.maxContainerLength) {
throw new CborError(`CBOR array length exceeds configured limit of ${options.maxContainerLength}`);
}
ancestors.add(value);
try {
writeArgument(writer, 4, value.length);
for (let index = 0; index < value.length; index++) {
if (!Object.hasOwn(value, index) || value[index] === undefined) {
throw new CborError("CBOR arrays must not contain holes or undefined values");
}
encodeValue(writer, value[index], options, depth + 1, ancestors);
}
} finally {
ancestors.delete(value);
}
return;
}
if (isPlainObject(value)) {
if (ancestors.has(value)) throw new CborError("CBOR values must not contain cycles");
for (const symbol of Object.getOwnPropertySymbols(value)) {
if (Object.prototype.propertyIsEnumerable.call(value, symbol)) {
throw new CborError("CBOR map keys must be strings");
}
}
const entries: Array<readonly [string, unknown]> = [];
for (const key of Object.keys(value)) {
const entryValue = value[key];
if (entryValue !== undefined) entries.push([key, entryValue]);
}
if (entries.length > options.maxContainerLength) {
throw new CborError(`CBOR map length exceeds configured limit of ${options.maxContainerLength}`);
}
ancestors.add(value);
try {
writeArgument(writer, 5, entries.length);
for (const [key, entryValue] of entries) {
encodeText(writer, key, options);
encodeValue(writer, entryValue, options, depth + 1, ancestors);
}
} finally {
ancestors.delete(value);
}
return;
}
throw new CborError(`Unsupported CBOR value type: ${typeof value}`);
}
/** Encodes the protocol's strict, definite-length RFC 8949 subset. */
export function encodeCbor(value: unknown, options?: CborOptions): Uint8Array {
const resolved = resolveOptions(options);
const writer = new CborWriter(resolved.maxByteLength);
encodeValue(writer, value, resolved, 0, new Set<object>());
return writer.finish();
}

View File

@@ -0,0 +1,9 @@
export { decodeCbor } from "./decoder.ts";
export { encodeCbor } from "./encoder.ts";
export {
CborError,
type CborOptions,
DEFAULT_MAX_CBOR_BYTE_LENGTH,
DEFAULT_MAX_CBOR_CONTAINER_LENGTH,
DEFAULT_MAX_CBOR_DEPTH,
} from "./options.ts";

View File

@@ -0,0 +1,52 @@
export const UINT32_BASE = 0x1_0000_0000;
export const MAX_UINT32 = 0xffff_ffff;
const MAX_CONFIGURED_DEPTH = 512;
/** Safe defaults for untrusted protocol payloads. */
export const DEFAULT_MAX_CBOR_BYTE_LENGTH = 16 * 1024 * 1024;
export const DEFAULT_MAX_CBOR_CONTAINER_LENGTH = 1_000_000;
export const DEFAULT_MAX_CBOR_DEPTH = 64;
export interface CborOptions {
/** Maximum encoded input/output bytes and maximum byte/text string length. */
maxByteLength?: number;
/** Maximum number of elements in an array or entries in a map. */
maxContainerLength?: number;
/** Maximum recursive item depth. */
maxDepth?: number;
}
export interface ResolvedCborOptions {
maxByteLength: number;
maxContainerLength: number;
maxDepth: number;
}
export class CborError extends Error {
constructor(message: string) {
super(message);
this.name = "CborError";
}
}
export const textEncoder = new TextEncoder();
export const textDecoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
function resolveLimit(name: string, value: number, maximum: number): number {
if (!Number.isSafeInteger(value) || value < 0 || value > maximum) {
throw new RangeError(`${name} must be an integer between 0 and ${maximum}`);
}
return value;
}
export function resolveOptions(options: CborOptions | undefined): ResolvedCborOptions {
return {
maxByteLength: resolveLimit("maxByteLength", options?.maxByteLength ?? DEFAULT_MAX_CBOR_BYTE_LENGTH, MAX_UINT32),
maxContainerLength: resolveLimit(
"maxContainerLength",
options?.maxContainerLength ?? DEFAULT_MAX_CBOR_CONTAINER_LENGTH,
MAX_UINT32,
),
maxDepth: resolveLimit("maxDepth", options?.maxDepth ?? DEFAULT_MAX_CBOR_DEPTH, MAX_CONFIGURED_DEPTH),
};
}

View File

@@ -0,0 +1,141 @@
import { isJsonValue } from "@earendil-works/chord";
import { Check } from "typebox/value";
import { decodeCbor, encodeCbor } from "./cbor/index.ts";
import { DEFAULT_MAX_FRAME_LENGTH, encodeFrame, FrameDecoder, type FrameDecoderOptions } from "./framing.ts";
import {
type ClientMessage,
ClientMessageSchema,
PROTOCOL_VERSION,
type ServerMessage,
ServerMessageSchema,
} from "./protocol.ts";
export class ProtocolValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "ProtocolValidationError";
}
}
export function parseClientMessage(value: unknown): ClientMessage {
if (!Check(ClientMessageSchema, value) || !isJsonValue(value)) {
throw new ProtocolValidationError("Invalid client protocol message");
}
return value;
}
export function parseServerMessage(value: unknown): ServerMessage {
if (!Check(ServerMessageSchema, value) || !isJsonValue(value)) {
throw new ProtocolValidationError("Invalid server protocol message");
}
return value;
}
function boundedErrorMessage(error: unknown): string {
if (!(error instanceof Error)) return "Unknown codec error";
return error.message.length <= 500 ? error.message : `${error.message.slice(0, 497)}...`;
}
function encodeProtocolMessage<T>(
value: T,
parse: (candidate: unknown) => T,
kind: string,
options?: FrameDecoderOptions,
): Uint8Array {
const validated = parse(value);
try {
const maxFrameLength = options?.maxFrameLength ?? DEFAULT_MAX_FRAME_LENGTH;
return encodeFrame(encodeCbor(validated, { maxByteLength: maxFrameLength }));
} catch (error) {
if (error instanceof ProtocolValidationError) throw error;
throw new ProtocolValidationError(`Unable to encode ${kind} protocol message: ${boundedErrorMessage(error)}`);
}
}
/** Validates and encodes one complete length-prefixed client message. */
export function encodeClientMessage(message: ClientMessage, options?: FrameDecoderOptions): Uint8Array {
return encodeProtocolMessage(message, parseClientMessage, "client", options);
}
/** Validates and encodes one complete length-prefixed server message. */
export function encodeServerMessage(message: ServerMessage, options?: FrameDecoderOptions): Uint8Array {
return encodeProtocolMessage(message, parseServerMessage, "server", options);
}
class ValidatedMessageDecoder<T> {
private failed = false;
private readonly frames: FrameDecoder;
private readonly kind: string;
private readonly maxFrameLength: number;
private readonly parse: (candidate: unknown) => T;
constructor(kind: string, parse: (candidate: unknown) => T, options?: FrameDecoderOptions) {
this.frames = new FrameDecoder(options);
this.kind = kind;
this.maxFrameLength = options?.maxFrameLength ?? DEFAULT_MAX_FRAME_LENGTH;
this.parse = parse;
}
push(chunk: Uint8Array): T[] {
if (this.failed) throw new ProtocolValidationError(`${this.kind} message decoder has failed`);
try {
const messages: T[] = [];
for (const frame of this.frames.push(chunk)) {
messages.push(this.parse(decodeCbor(frame, { maxByteLength: this.maxFrameLength })));
}
return messages;
} catch (error) {
this.failed = true;
if (error instanceof ProtocolValidationError) throw error;
throw new ProtocolValidationError(`Invalid ${this.kind} protocol frame: ${boundedErrorMessage(error)}`);
}
}
end(): void {
if (this.failed) throw new ProtocolValidationError(`${this.kind} message decoder has failed`);
try {
this.frames.end();
} catch (error) {
this.failed = true;
throw new ProtocolValidationError(`Invalid ${this.kind} protocol framing: ${boundedErrorMessage(error)}`);
}
}
}
/** Incrementally decodes and validates framed client messages. */
export class ClientMessageDecoder {
private readonly decoder: ValidatedMessageDecoder<ClientMessage>;
constructor(options?: FrameDecoderOptions) {
this.decoder = new ValidatedMessageDecoder("client", parseClientMessage, options);
}
push(chunk: Uint8Array): ClientMessage[] {
return this.decoder.push(chunk);
}
end(): void {
this.decoder.end();
}
}
/** Incrementally decodes and validates framed server messages. */
export class ServerMessageDecoder {
private readonly decoder: ValidatedMessageDecoder<ServerMessage>;
constructor(options?: FrameDecoderOptions) {
this.decoder = new ValidatedMessageDecoder("server", parseServerMessage, options);
}
push(chunk: Uint8Array): ServerMessage[] {
return this.decoder.push(chunk);
}
end(): void {
this.decoder.end();
}
}
export function isSupportedProtocolVersion(version: number): version is typeof PROTOCOL_VERSION {
return Number.isInteger(version) && version === PROTOCOL_VERSION;
}

View File

@@ -0,0 +1,151 @@
const FRAME_HEADER_LENGTH = 4;
const MAX_UINT32 = 0xffff_ffff;
const PAYLOAD_BLOCK_SIZE = 64 * 1024;
/** Default upper bound for one framed CBOR payload. */
export const DEFAULT_MAX_FRAME_LENGTH = 16 * 1024 * 1024;
export interface FrameDecoderOptions {
maxFrameLength?: number;
}
export class FrameError extends Error {
constructor(message: string) {
super(message);
this.name = "FrameError";
}
}
function resolveMaxFrameLength(options: FrameDecoderOptions | undefined): number {
const value = options?.maxFrameLength ?? DEFAULT_MAX_FRAME_LENGTH;
if (!Number.isSafeInteger(value) || value < 0 || value > MAX_UINT32) {
throw new RangeError(`maxFrameLength must be an integer between 0 and ${MAX_UINT32}`);
}
return value;
}
/** Prefixes a payload with its unsigned 32-bit big-endian byte length. */
export function encodeFrame(payload: Uint8Array): Uint8Array {
if (!(payload instanceof Uint8Array)) throw new TypeError("Frame payload must be a Uint8Array");
if (payload.byteLength > MAX_UINT32) throw new RangeError("Frame payload exceeds the unsigned 32-bit length limit");
const frame = new Uint8Array(FRAME_HEADER_LENGTH + payload.byteLength);
const length = payload.byteLength;
frame[0] = length >>> 24;
frame[1] = length >>> 16;
frame[2] = length >>> 8;
frame[3] = length;
frame.set(payload, FRAME_HEADER_LENGTH);
return frame;
}
type DecoderState = "open" | "ended" | "failed";
/** Incrementally splits arbitrary byte chunks into length-prefixed payloads. */
export class FrameDecoder {
private readonly header = new Uint8Array(FRAME_HEADER_LENGTH);
private headerLength = 0;
private readonly maxFrameLength: number;
private payloadBlocks: Uint8Array[] = [];
private currentPayloadBlock: Uint8Array | undefined;
private currentPayloadBlockLength = 0;
private expectedPayloadLength: number | undefined;
private payloadLength = 0;
private state: DecoderState = "open";
constructor(options?: FrameDecoderOptions) {
this.maxFrameLength = resolveMaxFrameLength(options);
}
push(chunk: Uint8Array): Uint8Array[] {
if (this.state === "ended") throw new FrameError("Frame decoder has ended");
if (this.state === "failed") throw new FrameError("Frame decoder has failed");
if (!(chunk instanceof Uint8Array)) throw new TypeError("Frame chunk must be a Uint8Array");
const frames: Uint8Array[] = [];
let chunkOffset = 0;
while (chunkOffset < chunk.byteLength) {
if (this.expectedPayloadLength === undefined) {
const headerBytes = Math.min(FRAME_HEADER_LENGTH - this.headerLength, chunk.byteLength - chunkOffset);
this.header.set(chunk.subarray(chunkOffset, chunkOffset + headerBytes), this.headerLength);
this.headerLength += headerBytes;
chunkOffset += headerBytes;
if (this.headerLength < FRAME_HEADER_LENGTH) continue;
const frameLength =
this.header[0]! * 0x1_000_000 + this.header[1]! * 0x1_0000 + this.header[2]! * 0x100 + this.header[3]!;
this.headerLength = 0;
if (frameLength > this.maxFrameLength) {
this.fail(`Frame length ${frameLength} exceeds configured limit of ${this.maxFrameLength}`);
}
if (frameLength === 0) {
frames.push(new Uint8Array());
continue;
}
this.expectedPayloadLength = frameLength;
this.payloadBlocks = [];
this.currentPayloadBlock = undefined;
this.currentPayloadBlockLength = 0;
this.payloadLength = 0;
}
const expectedPayloadLength = this.expectedPayloadLength;
if (expectedPayloadLength === undefined) continue;
while (chunkOffset < chunk.byteLength && this.payloadLength < expectedPayloadLength) {
let block = this.currentPayloadBlock;
if (!block || this.currentPayloadBlockLength === block.byteLength) {
block = new Uint8Array(Math.min(PAYLOAD_BLOCK_SIZE, expectedPayloadLength - this.payloadLength));
this.payloadBlocks.push(block);
this.currentPayloadBlock = block;
this.currentPayloadBlockLength = 0;
}
const payloadBytes = Math.min(
block.byteLength - this.currentPayloadBlockLength,
chunk.byteLength - chunkOffset,
);
block.set(chunk.subarray(chunkOffset, chunkOffset + payloadBytes), this.currentPayloadBlockLength);
this.currentPayloadBlockLength += payloadBytes;
this.payloadLength += payloadBytes;
chunkOffset += payloadBytes;
}
if (this.payloadLength === expectedPayloadLength) {
if (this.payloadBlocks.length === 1) {
frames.push(this.payloadBlocks[0]!);
} else {
const payload = new Uint8Array(expectedPayloadLength);
let offset = 0;
for (const payloadBlock of this.payloadBlocks) {
payload.set(payloadBlock, offset);
offset += payloadBlock.byteLength;
}
frames.push(payload);
}
this.payloadBlocks = [];
this.currentPayloadBlock = undefined;
this.currentPayloadBlockLength = 0;
this.expectedPayloadLength = undefined;
this.payloadLength = 0;
}
}
return frames;
}
end(): void {
if (this.state === "ended") throw new FrameError("Frame decoder has ended");
if (this.state === "failed") throw new FrameError("Frame decoder has failed");
if (this.headerLength !== 0 || this.expectedPayloadLength !== undefined) {
this.fail("Truncated frame at end of stream");
}
this.state = "ended";
}
private fail(message: string): never {
this.state = "failed";
this.headerLength = 0;
this.payloadBlocks = [];
this.currentPayloadBlock = undefined;
this.currentPayloadBlockLength = 0;
this.expectedPayloadLength = undefined;
this.payloadLength = 0;
throw new FrameError(message);
}
}

View File

@@ -0,0 +1,22 @@
export * from "./cbor/index.ts";
export * from "./codec.ts";
export * from "./framing.ts";
export {
type AttachmentEnvelope,
type CancelEnvelope,
type ClientHello,
type ClientMessage,
isServerId,
PROTOCOL_VERSION,
type ProtocolError,
type ProtocolErrorCode,
type RequestEnvelope,
type ResponseEnvelope,
type RpcTarget,
type ServerHello,
type ServerHelloError,
type ServerId,
type ServerMessage,
type ServiceEventEnvelope,
type SessionTarget,
} from "./protocol.ts";

View File

@@ -0,0 +1,110 @@
import type { JsonValue } from "@earendil-works/chord";
import Type, { type Static } from "typebox";
import { Check } from "typebox/value";
export const PROTOCOL_VERSION = 8 as const;
const IdSchema = Type.String({ minLength: 1 });
const OpaqueJsonValueSchema = Type.Unsafe<JsonValue>(Type.Unknown());
const StrictObject = <const T extends Parameters<typeof Type.Object>[0]>(properties: T) =>
Type.Object(properties, { additionalProperties: false });
const ServerIdSchema = Type.String({
pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
});
export type ServerId = Static<typeof ServerIdSchema>;
export function isServerId(value: unknown): value is ServerId {
return Check(ServerIdSchema, value);
}
const ProtocolErrorSchema = StrictObject({
code: IdSchema,
message: Type.String(),
});
export type ProtocolErrorCode = string;
export type ProtocolError = Static<typeof ProtocolErrorSchema>;
/** Must be the first frame sent by a client. */
const ClientHelloSchema = StrictObject({
type: Type.Literal("hello"),
version: Type.Integer({ minimum: 0 }),
});
export type ClientHello = Static<typeof ClientHelloSchema>;
/** A server-wide call, fenced to one logical server. */
const ServerTargetSchema = StrictObject({
serverId: ServerIdSchema,
});
/** A session call, fenced to one logical server, durable session, and live attachment. */
const SessionTargetSchema = StrictObject({
serverId: ServerIdSchema,
sessionId: IdSchema,
attachmentId: IdSchema,
});
export type SessionTarget = Static<typeof SessionTargetSchema>;
const RpcTargetSchema = Type.Union([ServerTargetSchema, SessionTargetSchema]);
export type RpcTarget = Static<typeof RpcTargetSchema>;
const RequestEnvelopeSchema = StrictObject({
type: Type.Literal("request"),
id: IdSchema,
target: RpcTargetSchema,
call: OpaqueJsonValueSchema,
});
const CancelEnvelopeSchema = StrictObject({
type: Type.Literal("cancel"),
id: IdSchema,
target: RpcTargetSchema,
});
export type RequestEnvelope = Static<typeof RequestEnvelopeSchema>;
export type CancelEnvelope = Static<typeof CancelEnvelopeSchema>;
export const ClientMessageSchema = Type.Union([ClientHelloSchema, RequestEnvelopeSchema, CancelEnvelopeSchema]);
export type ClientMessage = Static<typeof ClientMessageSchema>;
const ServerHelloSchema = StrictObject({
type: Type.Literal("hello"),
version: Type.Literal(PROTOCOL_VERSION),
serverId: ServerIdSchema,
});
const ServerHelloErrorSchema = StrictObject({
type: Type.Literal("hello_error"),
error: ProtocolErrorSchema,
});
const ResponseEnvelopeSchema = Type.Union([
StrictObject({
type: Type.Literal("response"),
id: IdSchema,
ok: Type.Literal(true),
result: Type.Optional(OpaqueJsonValueSchema),
}),
StrictObject({
type: Type.Literal("response"),
id: IdSchema,
ok: Type.Literal(false),
error: ProtocolErrorSchema,
}),
]);
const ServiceEventEnvelopeSchema = StrictObject({
type: Type.Literal("service_update"),
subscriptionId: IdSchema,
update: OpaqueJsonValueSchema,
});
/** Out-of-band update to this presentation's selected Session route. */
const AttachmentEnvelopeSchema = StrictObject({
type: Type.Literal("attachment"),
attachment: Type.Union([SessionTargetSchema, Type.Null()]),
});
export const ServerMessageSchema = Type.Union([
ServerHelloSchema,
ServerHelloErrorSchema,
ResponseEnvelopeSchema,
ServiceEventEnvelopeSchema,
AttachmentEnvelopeSchema,
]);
export type ServerHello = Static<typeof ServerHelloSchema>;
export type ServerHelloError = Static<typeof ServerHelloErrorSchema>;
export type ResponseEnvelope = Static<typeof ResponseEnvelopeSchema>;
export type ServiceEventEnvelope = Static<typeof ServiceEventEnvelopeSchema>;
export type AttachmentEnvelope = Static<typeof AttachmentEnvelopeSchema>;
export type ServerMessage = Static<typeof ServerMessageSchema>;

View File

@@ -0,0 +1,175 @@
import { describe, expect, test } from "vitest";
import {
CborError,
DEFAULT_MAX_CBOR_BYTE_LENGTH,
DEFAULT_MAX_CBOR_CONTAINER_LENGTH,
DEFAULT_MAX_CBOR_DEPTH,
decodeCbor,
encodeCbor,
} from "../../src/index.ts";
function fromHex(hex: string): Uint8Array {
if (hex.length % 2 !== 0) throw new Error("Hex fixture must contain whole bytes");
const bytes = new Uint8Array(hex.length / 2);
for (let index = 0; index < bytes.length; index++)
bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16);
return bytes;
}
function toHex(bytes: Uint8Array): string {
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
}
const knownVectors: ReadonlyArray<readonly [unknown, string]> = [
[null, "f6"],
[false, "f4"],
[true, "f5"],
[0, "00"],
[1, "01"],
[10, "0a"],
[23, "17"],
[24, "1818"],
[25, "1819"],
[100, "1864"],
[1000, "1903e8"],
[1_000_000, "1a000f4240"],
[1_000_000_000_000, "1b000000e8d4a51000"],
[Number.MAX_SAFE_INTEGER, "1b001fffffffffffff"],
[-1, "20"],
[-10, "29"],
[-24, "37"],
[-25, "3818"],
[-100, "3863"],
[-1000, "3903e7"],
[-1_000_000, "3a000f423f"],
[Number.MIN_SAFE_INTEGER, "3b001ffffffffffffe"],
[1.1, "fb3ff199999999999a"],
[-0, "fb8000000000000000"],
[new Uint8Array([1, 2, 3, 4]), "4401020304"],
["", "60"],
["IETF", "6449455446"],
["ü", "62c3bc"],
["水", "63e6b0b4"],
["𐅑", "64f0908591"],
[[], "80"],
[[1, 2, 3], "83010203"],
[[1, [2, 3], [4, 5]], "8301820203820405"],
[{ a: 1, b: [2, 3] }, "a26161016162820203"],
];
describe("CBOR codec", () => {
test.each(knownVectors)("encodes and decodes RFC 8949 vector %#", (value, wire) => {
const encoded = encodeCbor(value);
expect(toHex(encoded)).toBe(wire);
const decoded = decodeCbor(fromHex(wire));
if (typeof value === "number" && Object.is(value, -0)) expect(Object.is(decoded, -0)).toBe(true);
else expect(decoded).toEqual(value);
});
test("omits undefined object properties without omitting falsey values", () => {
const value = { omitted: undefined, zero: 0, empty: "", no: false, nil: null };
expect(decodeCbor(encodeCbor(value))).toEqual({ zero: 0, empty: "", no: false, nil: null });
});
test("preserves a leading Unicode BOM and treats __proto__ as data", () => {
expect(decodeCbor(fromHex("63efbbbf"))).toBe("\ufeff");
const value: Record<string, unknown> = {};
Object.defineProperty(value, "__proto__", { enumerable: true, value: "safe" });
const decoded = decodeCbor(encodeCbor(value));
expect(decoded).toEqual(value);
expect(Object.getPrototypeOf(decoded)).toBe(Object.prototype);
expect(Object.hasOwn(decoded as object, "__proto__")).toBe(true);
});
test.each([
["top-level undefined", undefined],
["undefined array element", [undefined]],
["array hole", new Array(1)],
["NaN", Number.NaN],
["positive infinity", Number.POSITIVE_INFINITY],
["negative infinity", Number.NEGATIVE_INFINITY],
["unsafe positive integer", Number.MAX_SAFE_INTEGER + 1],
["unsafe negative integer", Number.MIN_SAFE_INTEGER - 1],
["bigint", 1n],
["symbol", Symbol("unsupported")],
["function", () => undefined],
["Date", new Date(0)],
["Map", new Map<string, unknown>()],
] as const)("rejects unsupported encoder value: %s", (_label, value) => {
expect(() => encodeCbor(value)).toThrow(CborError);
});
test("rejects maps with enumerable symbol keys", () => {
const value = { valid: true };
Object.defineProperty(value, Symbol("key"), { enumerable: true, value: false });
expect(() => encodeCbor(value)).toThrow(CborError);
});
test("rejects lossy strings, cycles, and excessive encoder depth", () => {
expect(() => encodeCbor("\ud800")).toThrow(/Unicode/i);
const cyclic: unknown[] = [];
cyclic.push(cyclic);
expect(() => encodeCbor(cyclic)).toThrow(/cycles/i);
let tooDeep: unknown = null;
for (let depth = 0; depth <= DEFAULT_MAX_CBOR_DEPTH; depth++) tooDeep = [tooDeep];
expect(() => encodeCbor(tooDeep)).toThrow(/depth/i);
});
test.each([
["empty input", ""],
["truncated integer", "18"],
["reserved additional information", "1c"],
["indefinite byte string", "5f"],
["indefinite text string", "7f"],
["indefinite array", "9f"],
["indefinite map", "bf"],
["tag", "c000"],
["undefined", "f7"],
["unsupported simple value", "e0"],
["break outside an indefinite item", "ff"],
["float16", "f93c00"],
["float32", "fa3f800000"],
["positive infinity", "fb7ff0000000000000"],
["NaN", "fb7ff8000000000000"],
["truncated float64", "fb3ff00000"],
["truncated byte string", "44010203"],
["truncated text string", "636162"],
["truncated array", "8201"],
["truncated map", "a16161"],
["trailing data", "0000"],
["non-string map key", "a10102"],
["duplicate map key", "a2616101616102"],
["invalid UTF-8 byte", "61ff"],
["overlong UTF-8", "62c080"],
["UTF-8 surrogate", "63eda080"],
["unsafe positive integer", "1b0020000000000000"],
["unsafe negative integer", "3b001fffffffffffff"],
["unsafe integer encoded as float64", "fb4340000000000000"],
] as const)("rejects invalid decoder input: %s", (_label, wire) => {
expect(() => decodeCbor(fromHex(wire))).toThrow(CborError);
});
test("enforces depth and declared length limits before traversing values", () => {
const tooDeep = new Uint8Array(DEFAULT_MAX_CBOR_DEPTH + 2);
tooDeep.fill(0x81, 0, -1);
tooDeep[tooDeep.length - 1] = 0xf6;
expect(() => decodeCbor(tooDeep)).toThrow(/depth/i);
const oversizedBytes = fromHex(`5a${(DEFAULT_MAX_CBOR_BYTE_LENGTH + 1).toString(16).padStart(8, "0")}`);
const oversizedText = fromHex(`7a${(DEFAULT_MAX_CBOR_BYTE_LENGTH + 1).toString(16).padStart(8, "0")}`);
const oversizedArray = fromHex(`9a${(DEFAULT_MAX_CBOR_CONTAINER_LENGTH + 1).toString(16).padStart(8, "0")}`);
const oversizedMap = fromHex(`ba${(DEFAULT_MAX_CBOR_CONTAINER_LENGTH + 1).toString(16).padStart(8, "0")}`);
for (const wire of [oversizedBytes, oversizedText, oversizedArray, oversizedMap]) {
expect(() => decodeCbor(wire)).toThrow(/limit/i);
}
});
test("supports stricter caller-provided limits", () => {
expect(() => decodeCbor(fromHex("83010203"), { maxContainerLength: 2 })).toThrow(/limit/i);
expect(() => decodeCbor(fromHex("626162"), { maxByteLength: 2 })).toThrow(/limit/i);
expect(() => encodeCbor([1, 2, 3], { maxContainerLength: 2 })).toThrow(/limit/i);
expect(() => encodeCbor("ab", { maxByteLength: 2 })).toThrow(/limit/i);
});
});

View File

@@ -0,0 +1,110 @@
import { describe, expect, test } from "vitest";
import { DEFAULT_MAX_FRAME_LENGTH, encodeFrame, FrameDecoder, FrameError } from "../src/index.ts";
function concatenate(...chunks: Uint8Array[]): Uint8Array {
const result = new Uint8Array(chunks.reduce((length, chunk) => length + chunk.byteLength, 0));
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.byteLength;
}
return result;
}
describe("binary framing", () => {
test("prefixes payloads with a four-byte big-endian length", () => {
expect(encodeFrame(new Uint8Array([0xaa, 0xbb, 0xcc]))).toEqual(
new Uint8Array([0x00, 0x00, 0x00, 0x03, 0xaa, 0xbb, 0xcc]),
);
expect(encodeFrame(new Uint8Array())).toEqual(new Uint8Array([0, 0, 0, 0]));
});
test("decodes fragmented, coalesced, and empty frames in order", () => {
const wire = concatenate(
encodeFrame(new Uint8Array([1, 2, 3])),
encodeFrame(new Uint8Array()),
encodeFrame(new Uint8Array([4])),
);
const decoder = new FrameDecoder();
const frames: Uint8Array[] = [];
for (const byte of wire) frames.push(...decoder.push(new Uint8Array([byte])));
decoder.end();
expect(frames).toEqual([new Uint8Array([1, 2, 3]), new Uint8Array(), new Uint8Array([4])]);
const coalesced = new FrameDecoder();
expect(coalesced.push(wire)).toEqual(frames);
coalesced.end();
});
test("assembles payloads spanning multiple internal blocks", () => {
const payload = Uint8Array.from({ length: 70_000 }, (_, index) => index % 251);
const wire = encodeFrame(payload);
const decoder = new FrameDecoder();
const frames = [
...decoder.push(wire.subarray(0, 101)),
...decoder.push(wire.subarray(101, 65_541)),
...decoder.push(wire.subarray(65_541)),
];
decoder.end();
expect(frames).toEqual([payload]);
});
test("handles every split point across a frame", () => {
const wire = encodeFrame(new Uint8Array([10, 20, 30, 40]));
for (let split = 0; split <= wire.byteLength; split++) {
const decoder = new FrameDecoder();
const frames = [...decoder.push(wire.subarray(0, split)), ...decoder.push(wire.subarray(split))];
decoder.end();
expect(frames).toEqual([new Uint8Array([10, 20, 30, 40])]);
}
});
test("copies payload bytes instead of retaining or aliasing input chunks", () => {
const chunk = encodeFrame(new Uint8Array([1, 2, 3]));
const decoder = new FrameDecoder();
const frames = decoder.push(chunk);
chunk.fill(9);
expect(frames).toEqual([new Uint8Array([1, 2, 3])]);
});
test("accepts empty chunks and a clean empty stream", () => {
const decoder = new FrameDecoder();
expect(decoder.push(new Uint8Array())).toEqual([]);
expect(() => decoder.end()).not.toThrow();
});
test.each([
["partial header", new Uint8Array([0, 0, 0])],
["partial payload", new Uint8Array([0, 0, 0, 2, 1])],
] as const)("rejects a truncated stream at end: %s", (_label, wire) => {
const decoder = new FrameDecoder();
expect(decoder.push(wire)).toEqual([]);
expect(() => decoder.end()).toThrow(FrameError);
});
test("rejects an oversized declared length as soon as its header is complete", () => {
const decoder = new FrameDecoder({ maxFrameLength: 3 });
expect(() => decoder.push(new Uint8Array([0, 0, 0, 4]))).toThrow(/limit/i);
expect(() => decoder.push(new Uint8Array([1]))).toThrow(/failed/i);
});
test("accepts a frame exactly at the configured maximum", () => {
const decoder = new FrameDecoder({ maxFrameLength: 3 });
expect(decoder.push(encodeFrame(new Uint8Array([1, 2, 3])))).toEqual([new Uint8Array([1, 2, 3])]);
decoder.end();
});
test("cannot be pushed after end", () => {
const decoder = new FrameDecoder();
decoder.end();
expect(() => decoder.push(new Uint8Array())).toThrow(/ended/i);
expect(() => decoder.end()).toThrow(/ended/i);
});
test.each([-1, 1.5, Number.NaN, DEFAULT_MAX_FRAME_LENGTH * 1_000])(
"rejects invalid maximum frame length: %s",
(maxFrameLength) => {
expect(() => new FrameDecoder({ maxFrameLength })).toThrow(RangeError);
},
);
});

View File

@@ -0,0 +1,282 @@
import { describe, expect, test } from "vitest";
import {
type ClientHello,
type ClientMessage,
ClientMessageDecoder,
decodeCbor,
encodeCbor,
encodeClientMessage,
encodeFrame,
encodeServerMessage,
FrameDecoder,
isSupportedProtocolVersion,
PROTOCOL_VERSION,
ProtocolValidationError,
parseClientMessage,
parseServerMessage,
type ServerHello,
type ServerMessage,
ServerMessageDecoder,
} from "../src/index.ts";
const clientHello: ClientHello = { type: "hello", version: PROTOCOL_VERSION };
const serverHello: ServerHello = {
type: "hello",
version: PROTOCOL_VERSION,
serverId: "00000000-0000-4000-8000-000000000001",
};
describe("protocol validation", () => {
test("negotiates protocol version 8", () => {
expect(PROTOCOL_VERSION).toBe(8);
expect(isSupportedProtocolVersion(8)).toBe(true);
expect(isSupportedProtocolVersion(7)).toBe(false);
expect(isSupportedProtocolVersion(8.5)).toBe(false);
});
test.each([0, PROTOCOL_VERSION, PROTOCOL_VERSION + 1])(
"accepts integer client hello version %s for negotiation",
(version) => expect(parseClientMessage({ ...clientHello, version })).toEqual({ ...clientHello, version }),
);
test.each([
{ type: "hello", version: String(PROTOCOL_VERSION) },
{ type: "hello", version: PROTOCOL_VERSION + 0.5 },
{ type: "hello", version: PROTOCOL_VERSION, extra: true },
])("rejects an invalid client hello", (message) => {
expect(() => parseClientMessage(message)).toThrow(ProtocolValidationError);
});
test.each([
"",
"server-1",
"00000000-0000-7000-8000-000000000001",
"00000000-0000-4000-7000-000000000001",
"00000000-0000-4000-8000-00000000000A",
])("rejects non-canonical UUIDv4 server ID %j", (serverId) => {
expect(() =>
parseClientMessage({
type: "request",
id: "request-1",
target: { serverId },
call: { serviceId: "pi.models", member: "list", args: [] },
}),
).toThrow(ProtocolValidationError);
});
test("keeps routed request and event payloads opaque", () => {
const request: ClientMessage = {
type: "request",
id: "request-1",
target: {
serverId: "00000000-0000-4000-8000-000000000001",
sessionId: "session-1",
attachmentId: "attachment-1",
},
call: {
serviceId: "application.custom",
instance: { key: "instance-1", generation: 2 },
member: "invoke",
args: [{ arbitrary: true }, ["opaque"]],
},
};
expect(parseClientMessage(request)).toEqual(request);
expect(
parseClientMessage({
...request,
call: { arbitrary: "strict JSON whose service meaning belongs to Chord" },
}),
).toMatchObject({ call: { arbitrary: expect.any(String) } });
expect(
parseServerMessage({
type: "service_update",
subscriptionId: "subscription-1",
update: { applicationDefined: true },
}),
).toMatchObject({ update: { applicationDefined: true } });
});
test("rejects non-JSON opaque payloads", () => {
const request = {
type: "request",
id: "request-1",
target: { serverId: "00000000-0000-4000-8000-000000000001" },
call: { serviceId: "application.custom", member: "invoke", args: [] },
};
const cyclic: { self?: unknown } = {};
cyclic.self = cyclic;
for (const [label, value] of [
["byte array", new Uint8Array([1])],
["non-finite number", Number.NaN],
["undefined property", { value: undefined }],
["cycle", cyclic],
] as const) {
expect(() => parseClientMessage({ ...request, call: { ...request.call, args: [value] } }), label).toThrow(
ProtocolValidationError,
);
expect(
() => parseServerMessage({ type: "response", id: "request-1", ok: true, result: value }),
label,
).toThrow(ProtocolValidationError);
}
});
test("validates request cancellation envelopes", () => {
const cancel: ClientMessage = {
type: "cancel",
id: "request-1",
target: { serverId: "00000000-0000-4000-8000-000000000001" },
};
expect(parseClientMessage(cancel)).toEqual(cancel);
expect(() => parseClientMessage({ ...cancel, id: "" })).toThrow(ProtocolValidationError);
expect(() => parseClientMessage({ ...cancel, extra: true })).toThrow(ProtocolValidationError);
});
test("validates attachment route updates", () => {
const attached: ServerMessage = {
type: "attachment",
attachment: {
serverId: "00000000-0000-4000-8000-000000000001",
sessionId: "session-1",
attachmentId: "attachment-1",
},
};
const detached: ServerMessage = { type: "attachment", attachment: null };
expect(parseServerMessage(attached)).toEqual(attached);
expect(parseServerMessage(detached)).toEqual(detached);
expect(() => parseServerMessage({ ...attached, attachment: { sessionId: "session-1" } })).toThrow(
ProtocolValidationError,
);
});
test.each([
[
"empty request id",
{
type: "request",
id: "",
target: { serverId: "00000000-0000-4000-8000-000000000001" },
call: { serviceId: "pi.models", member: "list", args: [] },
},
],
[
"extra envelope field",
{
type: "request",
id: "request-1",
target: { serverId: "00000000-0000-4000-8000-000000000001" },
call: { serviceId: "pi.models", member: "list", args: [] },
extra: true,
},
],
] as const)("rejects malformed request boundaries: %s", (_label, message) => {
expect(() => parseClientMessage(message)).toThrow(ProtocolValidationError);
});
test("accepts a successful void response without a result field", () => {
expect(parseServerMessage({ type: "response", id: "request-1", ok: true })).toEqual({
type: "response",
id: "request-1",
ok: true,
});
});
test.each([
["invalid server id", { ...serverHello, serverId: "server-1" }],
["extra response field", { type: "response", id: "request-1", ok: true, result: [], extra: true }],
["empty error code", { type: "response", id: "request-1", ok: false, error: { code: "", message: "bad" } }],
] as const)("rejects malformed server boundaries: %s", (_label, message) => {
expect(() => parseServerMessage(message)).toThrow(ProtocolValidationError);
});
test.each(["wrong_server", "cancelled", "service_not_found", "application_error"] as const)(
"accepts the opaque %s error code",
(code) => {
const message: ServerMessage = {
type: "response",
id: "request-1",
ok: false,
error: { code, message: "safe" },
};
expect(parseServerMessage(message)).toEqual(message);
},
);
test("rejects unknown messages and fields", () => {
expect(() => parseServerMessage({ ...serverHello, snapshot: {} })).toThrow(ProtocolValidationError);
expect(() => parseServerMessage({ type: "unknown", event: {} })).toThrow(ProtocolValidationError);
});
test("does not parse JSON strings as messages", () => {
expect(() => parseClientMessage(JSON.stringify(clientHello))).toThrow(ProtocolValidationError);
expect(() => parseServerMessage(JSON.stringify(serverHello))).toThrow(ProtocolValidationError);
});
});
describe("validated framed protocol APIs", () => {
test("encodes complete client and server frames", () => {
const clientFrames = new FrameDecoder().push(encodeClientMessage(clientHello));
expect(parseClientMessage(decodeCbor(clientFrames[0]!))).toEqual(clientHello);
const serverFrames = new FrameDecoder().push(encodeServerMessage(serverHello));
expect(parseServerMessage(decodeCbor(serverFrames[0]!))).toEqual(serverHello);
});
test("enforces outbound frame limits", () => {
expect(() => encodeClientMessage(clientHello, { maxFrameLength: 8 })).toThrow(ProtocolValidationError);
expect(() => encodeServerMessage(serverHello, { maxFrameLength: 8 })).toThrow(ProtocolValidationError);
});
test("incrementally decodes fragmented and coalesced client messages", () => {
const request: ClientMessage = {
type: "request",
id: "request-1",
target: { serverId: "00000000-0000-4000-8000-000000000001" },
call: { serviceId: "pi.session-directory", member: "list", args: [] },
};
const first = encodeClientMessage(clientHello);
const second = encodeClientMessage(request);
const wire = new Uint8Array(first.byteLength + second.byteLength);
wire.set(first);
wire.set(second, first.byteLength);
for (let split = 0; split <= wire.byteLength; split++) {
const decoder = new ClientMessageDecoder();
const messages = [...decoder.push(wire.subarray(0, split)), ...decoder.push(wire.subarray(split))];
decoder.end();
expect(messages).toEqual([clientHello, request]);
}
});
test("incrementally decodes fragmented and coalesced server messages", () => {
const response: ServerMessage = { type: "response", id: "request-1", ok: true, result: [] };
const first = encodeServerMessage(serverHello);
const second = encodeServerMessage(response);
const wire = new Uint8Array(first.byteLength + second.byteLength);
wire.set(first);
wire.set(second, first.byteLength);
const split = first.byteLength + Math.floor(second.byteLength / 2);
const decoder = new ServerMessageDecoder();
expect(decoder.push(wire.subarray(0, split))).toEqual([serverHello]);
expect(decoder.push(wire.subarray(split))).toEqual([response]);
decoder.end();
});
test.each([
["empty CBOR payload", encodeFrame(new Uint8Array())],
["malformed CBOR", encodeFrame(new Uint8Array([0xff]))],
["schema-invalid CBOR", encodeFrame(encodeCbor({ type: "hello", version: 1, extra: true }))],
] as const)("rejects invalid framed input: %s", (_label, wire) => {
const decoder = new ClientMessageDecoder();
expect(() => decoder.push(wire)).toThrow(ProtocolValidationError);
expect(() => decoder.push(encodeClientMessage(clientHello))).toThrow(/failed/i);
});
test("rejects truncated and oversized framing", () => {
const truncated = new ServerMessageDecoder();
expect(truncated.push(new Uint8Array([0, 0, 0, 2, 1]))).toEqual([]);
expect(() => truncated.end()).toThrow(ProtocolValidationError);
const oversized = new ClientMessageDecoder({ maxFrameLength: 3 });
expect(() => oversized.push(new Uint8Array([0, 0, 0, 4]))).toThrow(ProtocolValidationError);
});
});

View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "**/*.d.ts", "src/**/*.d.ts"]
}

View File

@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"module": "NodeNext",
"moduleResolution": "NodeNext",
"types": ["node", "vitest"]
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}

View File

@@ -0,0 +1,11 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
reporters: process.env.GITHUB_ACTIONS ? ["dot", "github-actions"] : ["dot"],
},
resolve: { conditions: ["source"] },
ssr: { resolve: { conditions: ["source"] } },
});