Big Updated, added some tests, added comments for all functions, I forgot the rest
This commit is contained in:
parent
d77271e4b7
commit
90a1059cc8
59 changed files with 1816 additions and 203 deletions
|
|
@ -12,7 +12,8 @@
|
|||
"scripts": {
|
||||
"format": "bunx prettier --write .",
|
||||
"lint": "eslint src --ext .ts,.tsx",
|
||||
"build": "tsc -p tsconfig.json --noEmit"
|
||||
"test": "bun test",
|
||||
"build": "bun run test && tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-router": "^1.0.0",
|
||||
|
|
|
|||
11
packages/ttp/src/bun-test.d.ts
vendored
Normal file
11
packages/ttp/src/bun-test.d.ts
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
declare module "bun:test" {
|
||||
export const describe: (...args: unknown[]) => unknown;
|
||||
export const test: (...args: unknown[]) => unknown;
|
||||
export const it: (...args: unknown[]) => unknown;
|
||||
export const expect: (value: unknown) => {
|
||||
toBe: (expected: unknown) => void;
|
||||
toEqual: (expected: unknown) => void;
|
||||
toContain: (expected: unknown) => void;
|
||||
toThrow: (expected?: unknown) => void;
|
||||
};
|
||||
}
|
||||
|
|
@ -26,6 +26,11 @@ const FATAL_IDENTIFICATION_ERROR_TYPES = new Set([
|
|||
"error_not_authenticated",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Detects whether an error chain contains a STOP_SENDING transport signal.
|
||||
* @param error Unknown error value from transport operations.
|
||||
* @returns True when the error represents a STOP_SENDING condition.
|
||||
*/
|
||||
function isStopSendingError(error: unknown) {
|
||||
if (typeof error === "string") {
|
||||
return error.includes("STOP_SENDING");
|
||||
|
|
@ -54,6 +59,11 @@ function isStopSendingError(error: unknown) {
|
|||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifies identification errors that should be treated as terminal.
|
||||
* @param error Unknown error raised during identification.
|
||||
* @returns True when identification should fail without retry.
|
||||
*/
|
||||
function isFatalIdentificationError(error: unknown) {
|
||||
if (typeof error === "object" && error !== null && "type" in error) {
|
||||
const type = (error as { type?: unknown }).type;
|
||||
|
|
@ -90,9 +100,14 @@ type ContextType = {
|
|||
|
||||
const socketContext = React.createContext<ContextType | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Provides socket transport state and authenticated send operations to children.
|
||||
* @param props Component props with children.
|
||||
* @returns Loading, error, or provider-wrapped JSX.
|
||||
*/
|
||||
export default function Provider(props: { children: React.ReactNode }) {
|
||||
const { load } = useStorage();
|
||||
const { decrypt, get_shared_secret } = useCrypto();
|
||||
const { decrypt, getSharedSecret } = useCrypto();
|
||||
|
||||
const [readyState, setReadyState] = React.useState<number>(
|
||||
READY_STATE.CLOSED,
|
||||
|
|
@ -112,6 +127,13 @@ export default function Provider(props: { children: React.ReactNode }) {
|
|||
> | null>(null);
|
||||
const identificationStartedRef = React.useRef(false);
|
||||
|
||||
/**
|
||||
* Sends typed protocol messages through the active transport client.
|
||||
* @param type Protocol message type.
|
||||
* @param data Optional request payload.
|
||||
* @param options Optional request id and response mode.
|
||||
* @returns A promise for either void (no response) or typed message payload.
|
||||
*/
|
||||
const send = React.useCallback<BoundSendFn<Schemas>>(
|
||||
((
|
||||
type: string,
|
||||
|
|
@ -175,6 +197,10 @@ export default function Provider(props: { children: React.ReactNode }) {
|
|||
let reconnectScheduled = false;
|
||||
let disposed = false;
|
||||
|
||||
/**
|
||||
* Clears any scheduled reconnect timeout and resets scheduling flags.
|
||||
* @returns Void.
|
||||
*/
|
||||
const clearReconnectTimer = () => {
|
||||
if (!reconnectTimer) {
|
||||
return;
|
||||
|
|
@ -185,6 +211,11 @@ export default function Provider(props: { children: React.ReactNode }) {
|
|||
reconnectScheduled = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Schedules a delayed reconnect attempt unless retries are exhausted.
|
||||
* @param reason Optional reason for reconnect scheduling.
|
||||
* @returns Void.
|
||||
*/
|
||||
const scheduleReconnect = (reason?: unknown) => {
|
||||
if (disposed || reconnectScheduled) {
|
||||
return;
|
||||
|
|
@ -256,6 +287,10 @@ export default function Provider(props: { children: React.ReactNode }) {
|
|||
|
||||
clientRef.current = transportClient;
|
||||
|
||||
/**
|
||||
* Establishes the transport connection and schedules reconnect on failures.
|
||||
* @returns Promise that resolves after one connection attempt.
|
||||
*/
|
||||
async function connect() {
|
||||
if (disposed) {
|
||||
return;
|
||||
|
|
@ -318,6 +353,10 @@ export default function Provider(props: { children: React.ReactNode }) {
|
|||
setIdentifying(true);
|
||||
setIdentified(false);
|
||||
|
||||
/**
|
||||
* Executes the challenge-response identification handshake.
|
||||
* @returns Promise that resolves when identification flow completes.
|
||||
*/
|
||||
const identify = async () => {
|
||||
try {
|
||||
const userId = await load("user_id");
|
||||
|
|
@ -335,7 +374,7 @@ export default function Provider(props: { children: React.ReactNode }) {
|
|||
user_id: userId,
|
||||
});
|
||||
|
||||
const sharedSecret = await get_shared_secret(
|
||||
const sharedSecret = await getSharedSecret(
|
||||
privateKey,
|
||||
"",
|
||||
challengeEnvelope.data.public_key,
|
||||
|
|
@ -405,7 +444,7 @@ export default function Provider(props: { children: React.ReactNode }) {
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [connected, decrypt, get_shared_secret, load, send]);
|
||||
}, [connected, decrypt, getSharedSecret, load, send]);
|
||||
|
||||
const progress = React.useMemo(() => {
|
||||
if (readyState === READY_STATE.CONNECTING) return 30;
|
||||
|
|
@ -472,6 +511,10 @@ export default function Provider(props: { children: React.ReactNode }) {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the active socket context and enforces provider usage.
|
||||
* @returns Socket context API for transport operations and connection state.
|
||||
*/
|
||||
export function useSocket(): ContextType {
|
||||
const context = React.useContext(socketContext);
|
||||
if (!context) {
|
||||
|
|
|
|||
87
packages/ttp/src/core.test.ts
Normal file
87
packages/ttp/src/core.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
decodeCommunicationMessage,
|
||||
encodeCommunicationMessage,
|
||||
type TypedMessage,
|
||||
} from "./core";
|
||||
|
||||
/**
|
||||
* Creates a representative protocol message used for round-trip codec tests.
|
||||
* @returns Typed protocol message with mixed payload data kinds.
|
||||
*/
|
||||
function createRoundTripMessage(): TypedMessage<Record<string, unknown>> {
|
||||
return {
|
||||
id: 41,
|
||||
type: "message",
|
||||
data: {
|
||||
accepted: true,
|
||||
message: "hello",
|
||||
user_id: 77,
|
||||
iota_ids: [11, 12],
|
||||
ping_iota: 33,
|
||||
last_ping: 101,
|
||||
get_variant: null,
|
||||
user: {
|
||||
user_id: 1,
|
||||
username: "alice",
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
user_id: 2,
|
||||
message: "payload",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("TTP communication codec", () => {
|
||||
test("encodes and decodes a mixed payload message", () => {
|
||||
const input = createRoundTripMessage();
|
||||
|
||||
const encoded = encodeCommunicationMessage(input);
|
||||
const decoded = decodeCommunicationMessage(encoded);
|
||||
|
||||
expect(decoded.id).toBe(41);
|
||||
expect(decoded.type).toBe("message");
|
||||
expect(decoded.data).toEqual({
|
||||
accepted: true,
|
||||
message: "hello",
|
||||
user_id: 77,
|
||||
iota_ids: [11, 12],
|
||||
ping_iota: 33,
|
||||
last_ping: 101,
|
||||
get_variant: null,
|
||||
user: {
|
||||
user_id: 1,
|
||||
username: "alice",
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
user_id: 2,
|
||||
message: "payload",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("throws for unknown communication type", () => {
|
||||
expect(() =>
|
||||
encodeCommunicationMessage({
|
||||
id: 1,
|
||||
type: "unknown_type",
|
||||
data: { user_id: 1 },
|
||||
}),
|
||||
).toThrow("Unknown communication type");
|
||||
});
|
||||
|
||||
test("throws for unknown data key", () => {
|
||||
expect(() =>
|
||||
encodeCommunicationMessage({
|
||||
id: 1,
|
||||
type: "message",
|
||||
data: { unknown_key: 1 },
|
||||
}),
|
||||
).toThrow("Unknown data type");
|
||||
});
|
||||
});
|
||||
|
|
@ -62,6 +62,9 @@ type ActiveConnection = {
|
|||
closeNotified: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents non-fatal payload decoding failures for individual protocol messages.
|
||||
*/
|
||||
class RecoverableMessageDecodeError extends Error {
|
||||
readonly messageId: number;
|
||||
|
||||
|
|
@ -69,6 +72,12 @@ class RecoverableMessageDecodeError extends Error {
|
|||
|
||||
readonly cause: unknown;
|
||||
|
||||
/**
|
||||
* Creates a recoverable decode error associated with a specific message.
|
||||
* @param messageId Protocol message id that failed to decode.
|
||||
* @param messageType Protocol message type that failed to decode.
|
||||
* @param cause Original decode failure cause.
|
||||
*/
|
||||
constructor(messageId: number, messageType: string, cause: unknown) {
|
||||
super(
|
||||
`Failed to decode message payload for "${messageType}" (id=${messageId}): ${formatUnknownError(cause)}`,
|
||||
|
|
@ -439,6 +448,12 @@ export type TransportClient<T extends SchemaMap> = {
|
|||
subscribePush(handler: PushHandler): () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a typed transport client that validates request and response payloads.
|
||||
* @param schemas Protocol schema map for request/response validation.
|
||||
* @param options Optional transport lifecycle callbacks and default URL.
|
||||
* @returns Transport client API for connect, close, send, and push subscriptions.
|
||||
*/
|
||||
export function createTransportClient<T extends SchemaMap>(
|
||||
schemas: T,
|
||||
options: TransportClientOptions = {},
|
||||
|
|
@ -451,11 +466,21 @@ export function createTransportClient<T extends SchemaMap>(
|
|||
let nextRequestId = 1;
|
||||
let configuredUrl = options.url;
|
||||
|
||||
/**
|
||||
* Updates current ready state and emits lifecycle callbacks.
|
||||
* @param readyState New transport ready state value.
|
||||
* @returns Void.
|
||||
*/
|
||||
const setReadyState = (readyState: number) => {
|
||||
currentReadyState = readyState;
|
||||
options.onReadyStateChange?.(readyState);
|
||||
};
|
||||
|
||||
/**
|
||||
* Rejects all pending requests and clears timeout handles.
|
||||
* @param reason Rejection reason applied to all pending requests.
|
||||
* @returns Void.
|
||||
*/
|
||||
const rejectPending = (reason: unknown) => {
|
||||
for (const [id, request] of pending) {
|
||||
clearTimeout(request.timeoutId);
|
||||
|
|
@ -464,6 +489,12 @@ export function createTransportClient<T extends SchemaMap>(
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Finalizes closed state for a connection and notifies listeners.
|
||||
* @param connection Closed connection object.
|
||||
* @param error Optional close error.
|
||||
* @returns Void.
|
||||
*/
|
||||
const notifyClosed = (connection: ActiveConnection, error?: unknown) => {
|
||||
if (connection.closeNotified) {
|
||||
return;
|
||||
|
|
@ -483,6 +514,12 @@ export function createTransportClient<T extends SchemaMap>(
|
|||
options.onClose?.({ error, intentional: connection.intentional });
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles connection-level failures and routes them through close handling.
|
||||
* @param connection Connection that failed.
|
||||
* @param error Optional failure reason.
|
||||
* @returns Void.
|
||||
*/
|
||||
const handleConnectionFailure = (
|
||||
connection: ActiveConnection,
|
||||
error?: unknown,
|
||||
|
|
@ -494,6 +531,12 @@ export function createTransportClient<T extends SchemaMap>(
|
|||
notifyClosed(connection, error);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles STOP_SENDING failures by forcing close and notifying failure.
|
||||
* @param connection Active connection.
|
||||
* @param error Failure reason.
|
||||
* @returns Void.
|
||||
*/
|
||||
const closeFromStopSending = (
|
||||
connection: ActiveConnection,
|
||||
error: unknown,
|
||||
|
|
@ -514,6 +557,11 @@ export function createTransportClient<T extends SchemaMap>(
|
|||
handleConnectionFailure(connection, error);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles decoded incoming messages and resolves request promises or push listeners.
|
||||
* @param message Decoded incoming message.
|
||||
* @returns Void.
|
||||
*/
|
||||
const handleIncomingMessage = (message: TypedMessage) => {
|
||||
if (message.type !== "pong") {
|
||||
log(2, "Socket", "blue", "Received:", message.type, message.data, {
|
||||
|
|
@ -591,6 +639,11 @@ export function createTransportClient<T extends SchemaMap>(
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles recoverable decode failures by rejecting only the affected request.
|
||||
* @param error Recoverable decode error details.
|
||||
* @returns Void.
|
||||
*/
|
||||
const handleRecoverableDecodeFailure = (
|
||||
error: RecoverableMessageDecodeError,
|
||||
) => {
|
||||
|
|
@ -618,6 +671,11 @@ export function createTransportClient<T extends SchemaMap>(
|
|||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Starts the incoming stream loop for a newly-opened connection.
|
||||
* @param connection Active connection instance.
|
||||
* @returns Void.
|
||||
*/
|
||||
const startIncomingLoop = (connection: ActiveConnection) => {
|
||||
connection.streamReader =
|
||||
connection.transport.incomingUnidirectionalStreams.getReader();
|
||||
|
|
@ -678,6 +736,11 @@ export function createTransportClient<T extends SchemaMap>(
|
|||
})();
|
||||
};
|
||||
|
||||
/**
|
||||
* Awaits transport closed promise and forwards outcome to failure handling.
|
||||
* @param connection Active connection instance.
|
||||
* @returns Void.
|
||||
*/
|
||||
const awaitClosed = (connection: ActiveConnection) => {
|
||||
void connection.transport.closed
|
||||
.then(() => {
|
||||
|
|
@ -688,6 +751,11 @@ export function createTransportClient<T extends SchemaMap>(
|
|||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens a transport connection and starts incoming frame processing.
|
||||
* @param url Optional override transport URL.
|
||||
* @returns Promise that resolves when connection setup completes.
|
||||
*/
|
||||
const connect = async (url = configuredUrl) => {
|
||||
if (!url) {
|
||||
throw new Error("Transport URL is not configured");
|
||||
|
|
@ -729,6 +797,11 @@ export function createTransportClient<T extends SchemaMap>(
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Closes the current transport connection and sends a close sentinel frame.
|
||||
* @param reason Close reason sent to transport.
|
||||
* @returns Promise that resolves once close handling completes.
|
||||
*/
|
||||
const close = async (reason = APPLICATION_CLOSE_REASON) => {
|
||||
const connection = currentConnection;
|
||||
if (!connection) {
|
||||
|
|
@ -769,6 +842,13 @@ export function createTransportClient<T extends SchemaMap>(
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sends a typed protocol request over the current connection.
|
||||
* @param type Protocol message type.
|
||||
* @param input Optional request payload.
|
||||
* @param options Optional id and response behavior.
|
||||
* @returns Promise for response message or void when no response is expected.
|
||||
*/
|
||||
const send: BoundSendFn<T> = ((
|
||||
type: string,
|
||||
input?: Record<string, unknown>,
|
||||
|
|
@ -887,6 +967,11 @@ export function createTransportClient<T extends SchemaMap>(
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a normalized lookup map for protocol names by index.
|
||||
* @param values Ordered protocol names.
|
||||
* @returns Map from normalized name to array index.
|
||||
*/
|
||||
function createIndexMap(values: readonly string[]) {
|
||||
const map = new Map<string, number>();
|
||||
|
||||
|
|
@ -897,6 +982,12 @@ function createIndexMap(values: readonly string[]) {
|
|||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers expected data kinds for protocol data type names.
|
||||
* @param kind Expected scalar or array kind for the provided names.
|
||||
* @param names Data type names to register.
|
||||
* @returns Void.
|
||||
*/
|
||||
function registerDataKinds(kind: DataKind, names: readonly string[]) {
|
||||
for (const name of names) {
|
||||
if (dataKindByType.has(name)) {
|
||||
|
|
@ -907,6 +998,10 @@ function registerDataKinds(kind: DataKind, names: readonly string[]) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the WebTransport constructor from the current runtime.
|
||||
* @returns WebTransport constructor.
|
||||
*/
|
||||
function getWebTransportCtor() {
|
||||
const ctor = (globalThis as WebTransportGlobal).WebTransport;
|
||||
if (!ctor) {
|
||||
|
|
@ -916,10 +1011,20 @@ function getWebTransportCtor() {
|
|||
return ctor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes protocol names by lowercasing and removing underscores.
|
||||
* @param value Raw protocol name.
|
||||
* @returns Normalized protocol key.
|
||||
*/
|
||||
function normalizeName(value: string) {
|
||||
return value.toLowerCase().replaceAll("_", "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats unknown errors into a stable log string.
|
||||
* @param error Unknown error value.
|
||||
* @returns Human-readable error description.
|
||||
*/
|
||||
function formatUnknownError(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
|
|
@ -936,6 +1041,11 @@ function formatUnknownError(error: unknown) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects whether an error chain includes STOP_SENDING.
|
||||
* @param error Unknown transport error.
|
||||
* @returns True when STOP_SENDING appears in the error chain.
|
||||
*/
|
||||
function isStopSendingError(error: unknown) {
|
||||
if (typeof error === "string") {
|
||||
return error.includes("STOP_SENDING");
|
||||
|
|
@ -964,6 +1074,11 @@ function isStopSendingError(error: unknown) {
|
|||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures outbound message payloads are plain object records.
|
||||
* @param value Candidate payload.
|
||||
* @returns Payload as plain object record.
|
||||
*/
|
||||
function coercePayload(value: unknown): Record<string, unknown> {
|
||||
if (!isPlainObject(value)) {
|
||||
throw new Error("Protocol payload must be a plain object");
|
||||
|
|
@ -972,10 +1087,23 @@ function coercePayload(value: unknown): Record<string, unknown> {
|
|||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a value is a non-null, non-array object.
|
||||
* @param value Candidate value.
|
||||
* @returns True when the value is a plain object.
|
||||
*/
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a unique request id for transport messages.
|
||||
* @param requestedId Optional caller-provided request id.
|
||||
* @param expectsResponse Whether the request expects a response.
|
||||
* @param pending Map of currently pending requests.
|
||||
* @param nextId Function that returns the next candidate id.
|
||||
* @returns A request id valid for the current pending set.
|
||||
*/
|
||||
function resolveRequestId(
|
||||
requestedId: number | undefined,
|
||||
expectsResponse: boolean,
|
||||
|
|
@ -1010,6 +1138,12 @@ function resolveRequestId(
|
|||
return candidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates request id bounds and response semantics.
|
||||
* @param id Request id to validate.
|
||||
* @param expectsResponse Whether a response is expected for this request.
|
||||
* @returns Void.
|
||||
*/
|
||||
function validateRequestId(id: number, expectsResponse: boolean) {
|
||||
if (!Number.isInteger(id) || id < 0 || id > MAX_REQUEST_ID) {
|
||||
throw new Error(`Request id must be a u32 between 0 and ${MAX_REQUEST_ID}`);
|
||||
|
|
@ -1020,6 +1154,12 @@ function validateRequestId(id: number, expectsResponse: boolean) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a protocol message payload as a framed unidirectional transport stream.
|
||||
* @param transport Active transport instance.
|
||||
* @param payload Encoded message payload bytes.
|
||||
* @returns Promise that resolves when frame writing is complete.
|
||||
*/
|
||||
async function writeMessage(transport: WebTransportLike, payload: Uint8Array) {
|
||||
if (payload.byteLength >= CLOSE_FRAME_LEN) {
|
||||
throw new Error("Message too large for transport frame");
|
||||
|
|
@ -1040,6 +1180,11 @@ async function writeMessage(transport: WebTransportLike, payload: Uint8Array) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a close sentinel frame to the transport.
|
||||
* @param transport Active transport instance.
|
||||
* @returns Promise that resolves when the close frame is written.
|
||||
*/
|
||||
async function writeCloseFrame(transport: WebTransportLike) {
|
||||
const stream = await transport.createUnidirectionalStream();
|
||||
const writer = stream.getWriter();
|
||||
|
|
@ -1054,6 +1199,11 @@ async function writeCloseFrame(transport: WebTransportLike) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and validates a full transport frame from a stream.
|
||||
* @param stream Incoming stream for one framed message.
|
||||
* @returns Decoded typed message or null for close sentinel frames.
|
||||
*/
|
||||
async function readFrame(stream: ReadableStream<Uint8Array>) {
|
||||
const payload = await readAll(stream);
|
||||
if (payload.byteLength < 4) {
|
||||
|
|
@ -1075,6 +1225,11 @@ async function readFrame(stream: ReadableStream<Uint8Array>) {
|
|||
return decodeCommunicationMessage(payload.subarray(4));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads all chunks from a stream into a contiguous byte array.
|
||||
* @param stream Stream providing Uint8Array chunks.
|
||||
* @returns Concatenated stream bytes.
|
||||
*/
|
||||
async function readAll(stream: ReadableStream<Uint8Array>) {
|
||||
const reader = stream.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
|
|
@ -1105,7 +1260,12 @@ async function readAll(stream: ReadableStream<Uint8Array>) {
|
|||
return buffer;
|
||||
}
|
||||
|
||||
function encodeCommunicationMessage(
|
||||
/**
|
||||
* Encodes a typed protocol message into the wire communication format.
|
||||
* @param message Typed message with id, type, and payload data.
|
||||
* @returns Encoded communication frame bytes.
|
||||
*/
|
||||
export function encodeCommunicationMessage(
|
||||
message: TypedMessage<Record<string, unknown>>,
|
||||
) {
|
||||
const typeIndex = parseCommunicationType(message.type);
|
||||
|
|
@ -1129,7 +1289,12 @@ function encodeCommunicationMessage(
|
|||
return buffer;
|
||||
}
|
||||
|
||||
function decodeCommunicationMessage(frame: Uint8Array): TypedMessage {
|
||||
/**
|
||||
* Decodes communication frame bytes into a typed protocol message.
|
||||
* @param frame Encoded communication frame bytes.
|
||||
* @returns Decoded typed protocol message.
|
||||
*/
|
||||
export function decodeCommunicationMessage(frame: Uint8Array): TypedMessage {
|
||||
const reader = new ByteReader(frame);
|
||||
const payloadLength = reader.readU32();
|
||||
if (payloadLength !== frame.byteLength - 4) {
|
||||
|
|
@ -1189,6 +1354,11 @@ function decodeCommunicationMessage(frame: Uint8Array): TypedMessage {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a communication type string to its protocol index.
|
||||
* @param type Protocol message type string.
|
||||
* @returns Numeric protocol type index.
|
||||
*/
|
||||
function parseCommunicationType(type: string) {
|
||||
const index = COMMUNICATION_TYPE_BY_NAME.get(normalizeName(type));
|
||||
if (index === undefined) {
|
||||
|
|
@ -1198,6 +1368,11 @@ function parseCommunicationType(type: string) {
|
|||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a protocol data key into its index and canonical name.
|
||||
* @param type Raw protocol data key.
|
||||
* @returns Canonical data key metadata with index and normalized name.
|
||||
*/
|
||||
function parseDataType(type: string) {
|
||||
const index = DATA_TYPE_BY_NAME.get(normalizeName(type));
|
||||
if (index === undefined) {
|
||||
|
|
@ -1210,6 +1385,11 @@ function parseDataType(type: string) {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the expected value kind for a protocol data key.
|
||||
* @param type Canonical data key name.
|
||||
* @returns Expected data kind definition.
|
||||
*/
|
||||
function getExpectedKind(type: string) {
|
||||
const kind = dataKindByType.get(type);
|
||||
if (!kind) {
|
||||
|
|
@ -1224,6 +1404,13 @@ type EncodedDataValue = {
|
|||
payload: Uint8Array;
|
||||
};
|
||||
|
||||
/**
|
||||
* Encodes a value according to the expected protocol kind.
|
||||
* @param kind Expected protocol kind.
|
||||
* @param value Candidate value to encode.
|
||||
* @param path Payload path used in validation errors.
|
||||
* @returns Encoded value marker and payload bytes.
|
||||
*/
|
||||
function encodeDataValueForKind(
|
||||
kind: DataKind,
|
||||
value: unknown,
|
||||
|
|
@ -1285,6 +1472,12 @@ function encodeDataValueForKind(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes a numeric payload as signed 64-bit big-endian bytes.
|
||||
* @param value Value expected to be a safe integer number.
|
||||
* @param path Payload path used in validation errors.
|
||||
* @returns Encoded i64 byte array.
|
||||
*/
|
||||
function encodeNumberPayload(value: unknown, path: string) {
|
||||
if (
|
||||
typeof value !== "number" ||
|
||||
|
|
@ -1299,6 +1492,13 @@ function encodeNumberPayload(value: unknown, path: string) {
|
|||
return buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes an array payload where each item is tagged with a data marker.
|
||||
* @param innerKind Expected kind for array entries.
|
||||
* @param value Candidate array payload.
|
||||
* @param path Payload path used in validation errors.
|
||||
* @returns Encoded array payload bytes.
|
||||
*/
|
||||
function encodeArrayPayload(
|
||||
innerKind: PrimitiveDataKind | "container" | "null",
|
||||
value: unknown,
|
||||
|
|
@ -1352,6 +1552,12 @@ function encodeArrayPayload(
|
|||
return buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes a keyed container payload into protocol key-index/value entries.
|
||||
* @param value Object payload to encode.
|
||||
* @param path Payload path used in validation errors.
|
||||
* @returns Encoded container payload bytes.
|
||||
*/
|
||||
function encodeContainerPayload(value: Record<string, unknown>, path: string) {
|
||||
const normalizedEntries = new Map<
|
||||
string,
|
||||
|
|
@ -1433,6 +1639,12 @@ function encodeContainerPayload(value: Record<string, unknown>, path: string) {
|
|||
return buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a value marker and payload bytes into a JavaScript value.
|
||||
* @param marker Protocol value marker.
|
||||
* @param payload Encoded payload bytes for the marker.
|
||||
* @returns Decoded JavaScript value.
|
||||
*/
|
||||
function decodeValuePayload(marker: number, payload: Uint8Array): unknown {
|
||||
const reader = new ByteReader(payload);
|
||||
|
||||
|
|
@ -1479,6 +1691,11 @@ function decodeValuePayload(marker: number, payload: Uint8Array): unknown {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes an encoded array payload from a byte reader.
|
||||
* @param reader Byte reader positioned at array payload start.
|
||||
* @returns Decoded array values.
|
||||
*/
|
||||
function decodeArrayPayload(reader: ByteReader) {
|
||||
const itemCount = reader.readU16();
|
||||
const values: unknown[] = [];
|
||||
|
|
@ -1499,6 +1716,11 @@ function decodeArrayPayload(reader: ByteReader) {
|
|||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes an encoded keyed container payload from a byte reader.
|
||||
* @param reader Byte reader positioned at container payload start.
|
||||
* @returns Decoded object payload.
|
||||
*/
|
||||
function decodeContainerPayload(reader: ByteReader) {
|
||||
const entryCount = reader.readU16();
|
||||
const value: Record<string, unknown> = {};
|
||||
|
|
@ -1528,6 +1750,11 @@ function decodeContainerPayload(reader: ByteReader) {
|
|||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a canonical data key by protocol index.
|
||||
* @param index Protocol key index.
|
||||
* @returns Canonical protocol data key name.
|
||||
*/
|
||||
function getDataTypeNameByIndex(index: number) {
|
||||
const key = DATA_TYPES[index];
|
||||
if (!key) {
|
||||
|
|
@ -1537,6 +1764,11 @@ function getDataTypeNameByIndex(index: number) {
|
|||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a marker represents a boolean payload.
|
||||
* @param marker Protocol value marker.
|
||||
* @returns True when marker is boolean true or false.
|
||||
*/
|
||||
function isBoolKindMarker(marker: number) {
|
||||
return (
|
||||
marker === DATA_VALUE_KIND_BOOL_TRUE ||
|
||||
|
|
@ -1544,6 +1776,12 @@ function isBoolKindMarker(marker: number) {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a marker is compatible with an expected data kind.
|
||||
* @param marker Protocol value marker.
|
||||
* @param kind Expected protocol data kind.
|
||||
* @returns True when marker matches the expected kind.
|
||||
*/
|
||||
function isMarkerCompatibleWithKind(marker: number, kind: DataKind) {
|
||||
if (typeof kind === "object") {
|
||||
return marker === DATA_VALUE_KIND_ARRAY;
|
||||
|
|
@ -1563,6 +1801,13 @@ function isMarkerCompatibleWithKind(marker: number, kind: DataKind) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates marker compatibility for a specific key with scalar-array fallback support.
|
||||
* @param marker Protocol value marker.
|
||||
* @param kind Expected protocol data kind.
|
||||
* @param key Canonical protocol key name.
|
||||
* @returns True when marker is compatible for the key.
|
||||
*/
|
||||
function isMarkerCompatibleWithKey(
|
||||
marker: number,
|
||||
kind: DataKind,
|
||||
|
|
@ -1578,6 +1823,12 @@ function isMarkerCompatibleWithKey(
|
|||
return isMarkerCompatibleWithKind(marker, kind);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes outbound values for special scalar-array compatibility keys.
|
||||
* @param type Canonical protocol key name.
|
||||
* @param value Outbound value.
|
||||
* @returns Normalized outbound value.
|
||||
*/
|
||||
function normalizeOutgoingValue(type: string, value: unknown) {
|
||||
if (SCALAR_NUMBER_ARRAY_DATA_TYPES.has(type) && typeof value === "number") {
|
||||
return [value];
|
||||
|
|
@ -1586,6 +1837,12 @@ function normalizeOutgoingValue(type: string, value: unknown) {
|
|||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes inbound values for scalar-array compatibility keys.
|
||||
* @param type Canonical protocol key name.
|
||||
* @param value Inbound decoded value.
|
||||
* @returns Normalized inbound value.
|
||||
*/
|
||||
function normalizeIncomingValue(type: string, value: unknown) {
|
||||
if (
|
||||
SCALAR_NUMBER_ARRAY_DATA_TYPES.has(type) &&
|
||||
|
|
@ -1599,6 +1856,13 @@ function normalizeIncomingValue(type: string, value: unknown) {
|
|||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a big-endian unsigned 16-bit integer to a byte buffer.
|
||||
* @param buffer Destination byte buffer.
|
||||
* @param offset Byte offset to write at.
|
||||
* @param value Unsigned 16-bit integer value.
|
||||
* @returns Void.
|
||||
*/
|
||||
function writeU16(buffer: Uint8Array, offset: number, value: number) {
|
||||
new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).setUint16(
|
||||
offset,
|
||||
|
|
@ -1607,6 +1871,13 @@ function writeU16(buffer: Uint8Array, offset: number, value: number) {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a big-endian unsigned 32-bit integer to a byte buffer.
|
||||
* @param buffer Destination byte buffer.
|
||||
* @param offset Byte offset to write at.
|
||||
* @param value Unsigned 32-bit integer value.
|
||||
* @returns Void.
|
||||
*/
|
||||
function writeU32(buffer: Uint8Array, offset: number, value: number) {
|
||||
new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).setUint32(
|
||||
offset,
|
||||
|
|
@ -1615,6 +1886,13 @@ function writeU32(buffer: Uint8Array, offset: number, value: number) {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a big-endian signed 64-bit integer to a byte buffer.
|
||||
* @param buffer Destination byte buffer.
|
||||
* @param offset Byte offset to write at.
|
||||
* @param value Signed integer value.
|
||||
* @returns Void.
|
||||
*/
|
||||
function writeI64(buffer: Uint8Array, offset: number, value: number) {
|
||||
new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).setBigInt64(
|
||||
offset,
|
||||
|
|
@ -1623,6 +1901,12 @@ function writeI64(buffer: Uint8Array, offset: number, value: number) {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a big-endian unsigned 32-bit integer from a byte buffer.
|
||||
* @param buffer Source byte buffer.
|
||||
* @param offset Byte offset to read from.
|
||||
* @returns Unsigned 32-bit integer value.
|
||||
*/
|
||||
function readU32(buffer: Uint8Array, offset: number) {
|
||||
return new DataView(
|
||||
buffer.buffer,
|
||||
|
|
@ -1631,6 +1915,9 @@ function readU32(buffer: Uint8Array, offset: number) {
|
|||
).getUint32(offset, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides sequential big-endian reads over protocol byte buffers.
|
||||
*/
|
||||
class ByteReader {
|
||||
private readonly view: DataView;
|
||||
|
||||
|
|
@ -1638,11 +1925,19 @@ class ByteReader {
|
|||
|
||||
private readonly bytes: Uint8Array;
|
||||
|
||||
/**
|
||||
* Creates a byte reader over an immutable Uint8Array view.
|
||||
* @param bytes Source bytes to read from.
|
||||
*/
|
||||
constructor(bytes: Uint8Array) {
|
||||
this.bytes = bytes;
|
||||
this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads one unsigned byte.
|
||||
* @returns Unsigned 8-bit integer.
|
||||
*/
|
||||
readU8() {
|
||||
this.ensureAvailable(1);
|
||||
const value = this.view.getUint8(this.offset);
|
||||
|
|
@ -1650,6 +1945,10 @@ class ByteReader {
|
|||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads two bytes as big-endian unsigned 16-bit integer.
|
||||
* @returns Unsigned 16-bit integer.
|
||||
*/
|
||||
readU16() {
|
||||
this.ensureAvailable(2);
|
||||
const value = this.view.getUint16(this.offset, false);
|
||||
|
|
@ -1657,6 +1956,10 @@ class ByteReader {
|
|||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads four bytes as big-endian unsigned 32-bit integer.
|
||||
* @returns Unsigned 32-bit integer.
|
||||
*/
|
||||
readU32() {
|
||||
this.ensureAvailable(4);
|
||||
const value = this.view.getUint32(this.offset, false);
|
||||
|
|
@ -1664,6 +1967,10 @@ class ByteReader {
|
|||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads eight bytes as big-endian signed 64-bit integer.
|
||||
* @returns Safe integer representation of the decoded value.
|
||||
*/
|
||||
readI64() {
|
||||
this.ensureAvailable(8);
|
||||
const value = this.view.getBigInt64(this.offset, false);
|
||||
|
|
@ -1679,6 +1986,10 @@ class ByteReader {
|
|||
return numberValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads six bytes as a big-endian unsigned 48-bit integer.
|
||||
* @returns Unsigned 48-bit integer represented as number.
|
||||
*/
|
||||
readU48() {
|
||||
this.ensureAvailable(6);
|
||||
const upper = this.view.getUint16(this.offset, false);
|
||||
|
|
@ -1687,6 +1998,11 @@ class ByteReader {
|
|||
return upper * 2 ** 32 + lower;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a byte slice of the requested length.
|
||||
* @param length Number of bytes to read.
|
||||
* @returns View over the requested bytes.
|
||||
*/
|
||||
readBytes(length: number) {
|
||||
this.ensureAvailable(length);
|
||||
const value = this.bytes.subarray(this.offset, this.offset + length);
|
||||
|
|
@ -1694,10 +2010,19 @@ class ByteReader {
|
|||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether all bytes have been consumed.
|
||||
* @returns True when reader offset is at buffer end.
|
||||
*/
|
||||
isAtEnd() {
|
||||
return this.offset === this.bytes.byteLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that at least a specific number of bytes can still be read.
|
||||
* @param length Required available byte count.
|
||||
* @returns Void.
|
||||
*/
|
||||
private ensureAvailable(length: number) {
|
||||
if (this.offset + length > this.bytes.byteLength) {
|
||||
throw new Error("Unexpected end of protocol buffer");
|
||||
|
|
|
|||
Loading…
Reference in a new issue