chore(vendor): import pi 0.85.1
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
This commit is contained in:
168
third_party/pi/packages/protocol/src/cbor/decoder.ts
vendored
Normal file
168
third_party/pi/packages/protocol/src/cbor/decoder.ts
vendored
Normal 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();
|
||||
}
|
||||
216
third_party/pi/packages/protocol/src/cbor/encoder.ts
vendored
Normal file
216
third_party/pi/packages/protocol/src/cbor/encoder.ts
vendored
Normal 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();
|
||||
}
|
||||
9
third_party/pi/packages/protocol/src/cbor/index.ts
vendored
Normal file
9
third_party/pi/packages/protocol/src/cbor/index.ts
vendored
Normal 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";
|
||||
52
third_party/pi/packages/protocol/src/cbor/options.ts
vendored
Normal file
52
third_party/pi/packages/protocol/src/cbor/options.ts
vendored
Normal 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),
|
||||
};
|
||||
}
|
||||
141
third_party/pi/packages/protocol/src/codec.ts
vendored
Normal file
141
third_party/pi/packages/protocol/src/codec.ts
vendored
Normal 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;
|
||||
}
|
||||
151
third_party/pi/packages/protocol/src/framing.ts
vendored
Normal file
151
third_party/pi/packages/protocol/src/framing.ts
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
22
third_party/pi/packages/protocol/src/index.ts
vendored
Normal file
22
third_party/pi/packages/protocol/src/index.ts
vendored
Normal 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";
|
||||
110
third_party/pi/packages/protocol/src/protocol.ts
vendored
Normal file
110
third_party/pi/packages/protocol/src/protocol.ts
vendored
Normal 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>;
|
||||
Reference in New Issue
Block a user