Fixed ttp frfr
This commit is contained in:
parent
24e969a19c
commit
ec5ee847e0
4 changed files with 588 additions and 633 deletions
|
|
@ -5,7 +5,8 @@ import { useStorage } from "@tensamin/storage/context";
|
|||
import { createTransportClient, READY_STATE, type BoundSendFn } from "./core";
|
||||
import {
|
||||
PING_INTERVAL,
|
||||
RETRY_COUNT,
|
||||
RECONNECT_RESET,
|
||||
RECONNECT_TRIES,
|
||||
RETRY_INTERVAL,
|
||||
TRANSPORT_URL,
|
||||
} from "./values";
|
||||
|
|
@ -224,6 +225,7 @@ export default function Provider(props: { children: React.ReactNode }) {
|
|||
React.useEffect(() => {
|
||||
let attempts = 0;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let reconnectResetTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let reconnectScheduled = false;
|
||||
let disposed = false;
|
||||
|
||||
|
|
@ -241,6 +243,31 @@ export default function Provider(props: { children: React.ReactNode }) {
|
|||
reconnectScheduled = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clears the stability timer that resets reconnect attempt counters.
|
||||
* @returns Void.
|
||||
*/
|
||||
const clearReconnectResetTimer = () => {
|
||||
if (!reconnectResetTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(reconnectResetTimer);
|
||||
reconnectResetTimer = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Starts the stability timer that resets reconnect attempts after uptime.
|
||||
* @returns Void.
|
||||
*/
|
||||
const scheduleReconnectReset = () => {
|
||||
clearReconnectResetTimer();
|
||||
reconnectResetTimer = setTimeout(() => {
|
||||
attempts = 0;
|
||||
reconnectResetTimer = null;
|
||||
}, RECONNECT_RESET * 1_000);
|
||||
};
|
||||
|
||||
/**
|
||||
* Schedules a delayed reconnect attempt unless retries are exhausted.
|
||||
* @param reason Optional reason for reconnect scheduling.
|
||||
|
|
@ -251,7 +278,7 @@ export default function Provider(props: { children: React.ReactNode }) {
|
|||
return;
|
||||
}
|
||||
|
||||
if (attempts >= RETRY_COUNT) {
|
||||
if (attempts >= RECONNECT_TRIES) {
|
||||
setError("Connection Failed");
|
||||
setErrorDescription(
|
||||
"Unable to connect to the server after multiple attempts. Please check your internet connection or try again later.",
|
||||
|
|
@ -275,8 +302,8 @@ export default function Provider(props: { children: React.ReactNode }) {
|
|||
setReadyState(state);
|
||||
|
||||
if (state === READY_STATE.OPEN) {
|
||||
attempts = 0;
|
||||
clearReconnectTimer();
|
||||
scheduleReconnectReset();
|
||||
identificationStartedRef.current = false;
|
||||
setConnected(true);
|
||||
setIdentified(false);
|
||||
|
|
@ -285,32 +312,24 @@ export default function Provider(props: { children: React.ReactNode }) {
|
|||
return;
|
||||
}
|
||||
|
||||
clearReconnectResetTimer();
|
||||
identificationStartedRef.current = false;
|
||||
setConnected(false);
|
||||
setIdentified(false);
|
||||
},
|
||||
onClose: ({ error: closeError, intentional }) => {
|
||||
if (isStopSendingError(closeError)) {
|
||||
clearReconnectTimer();
|
||||
clearReconnectResetTimer();
|
||||
setConnected(false);
|
||||
setIdentified(false);
|
||||
setIdentifying(false);
|
||||
setError("Connection closed");
|
||||
setErrorDescription(
|
||||
"The connection was forcefully closed by the Omikron.",
|
||||
);
|
||||
log(0, "Socket", "red", "Connection closed", closeError);
|
||||
return;
|
||||
}
|
||||
|
||||
setConnected(false);
|
||||
setIdentified(false);
|
||||
|
||||
if (disposed || intentional) {
|
||||
return;
|
||||
}
|
||||
|
||||
log(0, "Socket", "red", "Disconnected", closeError);
|
||||
log(0, "Socket", "red", "Disconnected", closeError, {
|
||||
stopSending: isStopSendingError(closeError),
|
||||
});
|
||||
scheduleReconnect(closeError);
|
||||
},
|
||||
});
|
||||
|
|
@ -333,15 +352,6 @@ export default function Provider(props: { children: React.ReactNode }) {
|
|||
return;
|
||||
}
|
||||
|
||||
if (isStopSendingError(connectError)) {
|
||||
clearReconnectTimer();
|
||||
setError("Connection closed");
|
||||
setErrorDescription(
|
||||
"The connection was forcefully closed by the Omikron.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
log(0, "Socket", "red", "Connection attempt failed", connectError);
|
||||
scheduleReconnect(connectError);
|
||||
}
|
||||
|
|
@ -352,6 +362,7 @@ export default function Provider(props: { children: React.ReactNode }) {
|
|||
return () => {
|
||||
disposed = true;
|
||||
clearReconnectTimer();
|
||||
clearReconnectResetTimer();
|
||||
|
||||
if (clientRef.current === transportClient) {
|
||||
clientRef.current = null;
|
||||
|
|
|
|||
|
|
@ -1,597 +1,493 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { socket } from "@tensamin/shared/data";
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import {
|
||||
createTransportClient,
|
||||
decodeCommunicationMessage,
|
||||
encodeCommunicationMessage,
|
||||
decodeCommunicationMessage,
|
||||
createTransportClient,
|
||||
type TypedMessage,
|
||||
type SchemaMap,
|
||||
} from "./core";
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* 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",
|
||||
},
|
||||
],
|
||||
},
|
||||
type MockStreamWriter = {
|
||||
write: (chunk: Uint8Array) => Promise<void>;
|
||||
releaseLock: () => void;
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
function installFakeWebTransport(streamChunks: Uint8Array[]) {
|
||||
const globalScope = globalThis as typeof globalThis & {
|
||||
WebTransport?: unknown;
|
||||
type MockStream = {
|
||||
getWriter: () => MockStreamWriter;
|
||||
};
|
||||
const originalWebTransport = globalScope.WebTransport;
|
||||
|
||||
class FakeWebTransport {
|
||||
readonly ready = Promise.resolve();
|
||||
type MockReader = {
|
||||
read: () => Promise<{ done: boolean; value?: Uint8Array }>;
|
||||
releaseLock: () => void;
|
||||
cancel: () => Promise<void>;
|
||||
};
|
||||
|
||||
readonly closed: Promise<void>;
|
||||
type MockTransportInstance = {
|
||||
ready: Promise<void>;
|
||||
closed: Promise<void>;
|
||||
createUnidirectionalStream: () => Promise<MockStream>;
|
||||
incomingUnidirectionalStreams: {
|
||||
getReader: () => MockReader;
|
||||
};
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
private resolveClosed!: () => void;
|
||||
type MemoryStorage = {
|
||||
getItem: (key: string) => string | null;
|
||||
setItem: (key: string, value: string) => void;
|
||||
removeItem: (key: string) => void;
|
||||
clear: () => void;
|
||||
};
|
||||
|
||||
readonly incomingUnidirectionalStreams: ReadableStream<
|
||||
ReadableStream<Uint8Array>
|
||||
>;
|
||||
function ensureLocalStorage() {
|
||||
const globalWithStorage = globalThis as unknown as {
|
||||
localStorage?: MemoryStorage;
|
||||
};
|
||||
|
||||
constructor() {
|
||||
this.closed = new Promise<void>((resolve) => {
|
||||
this.resolveClosed = resolve;
|
||||
});
|
||||
|
||||
const incomingStream = new ReadableStream<ReadableStream<Uint8Array>>({
|
||||
start: (controller) => {
|
||||
controller.enqueue(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(innerController) {
|
||||
const emitChunk = (index: number) => {
|
||||
if (index >= streamChunks.length) {
|
||||
innerController.close();
|
||||
if (globalWithStorage.localStorage) {
|
||||
return;
|
||||
}
|
||||
|
||||
innerController.enqueue(streamChunks[index]);
|
||||
setTimeout(() => emitChunk(index + 1), 10);
|
||||
const storage = new Map<string, string>();
|
||||
globalWithStorage.localStorage = {
|
||||
getItem: (key) => storage.get(key) ?? null,
|
||||
setItem: (key, value) => {
|
||||
storage.set(key, value);
|
||||
},
|
||||
removeItem: (key) => {
|
||||
storage.delete(key);
|
||||
},
|
||||
clear: () => {
|
||||
storage.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createMockWebTransport() {
|
||||
ensureLocalStorage();
|
||||
|
||||
let readyResolve!: () => void;
|
||||
let closedResolve!: () => void;
|
||||
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
readyResolve = resolve;
|
||||
});
|
||||
const closed = new Promise<void>((resolve) => {
|
||||
closedResolve = resolve;
|
||||
});
|
||||
|
||||
const writer: MockStreamWriter = {
|
||||
write: async () => {},
|
||||
releaseLock: () => {},
|
||||
close: async () => {},
|
||||
};
|
||||
|
||||
emitChunk(0);
|
||||
},
|
||||
const stream: MockStream = {
|
||||
getWriter: () => writer,
|
||||
};
|
||||
|
||||
const transport: MockTransportInstance = {
|
||||
ready,
|
||||
closed,
|
||||
createUnidirectionalStream: async () => stream,
|
||||
incomingUnidirectionalStreams: {
|
||||
getReader: () => ({
|
||||
read: async () => ({ done: true }),
|
||||
releaseLock: () => {},
|
||||
cancel: async () => {},
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
close: () => {},
|
||||
};
|
||||
|
||||
this.incomingUnidirectionalStreams = incomingStream;
|
||||
class MockWebTransport implements MockTransportInstance {
|
||||
ready = transport.ready;
|
||||
closed = transport.closed;
|
||||
createUnidirectionalStream = transport.createUnidirectionalStream;
|
||||
incomingUnidirectionalStreams = transport.incomingUnidirectionalStreams;
|
||||
close = transport.close;
|
||||
}
|
||||
|
||||
createUnidirectionalStream() {
|
||||
return new WritableStream<Uint8Array>({
|
||||
write() {
|
||||
return undefined;
|
||||
},
|
||||
close() {
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.resolveClosed();
|
||||
}
|
||||
}
|
||||
|
||||
globalScope.WebTransport = FakeWebTransport as never;
|
||||
|
||||
return () => {
|
||||
globalScope.WebTransport = originalWebTransport;
|
||||
return {
|
||||
readyResolve,
|
||||
closedResolve,
|
||||
MockWebTransport,
|
||||
};
|
||||
}
|
||||
|
||||
function concatBytes(chunks: Uint8Array[]) {
|
||||
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
|
||||
const buffer = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
|
||||
for (const chunk of chunks) {
|
||||
buffer.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
async function expectRejection(
|
||||
promise: Promise<unknown>,
|
||||
messageSubstring?: string,
|
||||
) {
|
||||
try {
|
||||
await promise;
|
||||
expect(false).toBe(true);
|
||||
} catch (error) {
|
||||
if (messageSubstring) {
|
||||
expect(getErrorMessage(error).includes(messageSubstring)).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return buffer;
|
||||
function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function chunkBytes(bytes: Uint8Array, sizes: number[]) {
|
||||
const chunks: Uint8Array[] = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const size of sizes) {
|
||||
if (offset >= bytes.byteLength) {
|
||||
break;
|
||||
function writeU32BigEndian(buffer: Uint8Array, offset: number, value: number) {
|
||||
new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).setUint32(
|
||||
offset,
|
||||
value,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
const end = Math.min(offset + size, bytes.byteLength);
|
||||
chunks.push(bytes.subarray(offset, end));
|
||||
offset = end;
|
||||
}
|
||||
|
||||
if (offset < bytes.byteLength) {
|
||||
chunks.push(bytes.subarray(offset));
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function frameBytes(payload: Uint8Array) {
|
||||
const frame = new Uint8Array(payload.byteLength + 4);
|
||||
const view = new DataView(frame.buffer);
|
||||
|
||||
view.setUint32(0, payload.byteLength, false);
|
||||
frame.set(payload, 4);
|
||||
|
||||
function wrapForDecode(body: Uint8Array) {
|
||||
const frame = new Uint8Array(body.byteLength + 4);
|
||||
writeU32BigEndian(frame, 0, body.byteLength);
|
||||
frame.set(body, 4);
|
||||
return frame;
|
||||
}
|
||||
|
||||
function installFakeLocalStorage() {
|
||||
const globalScope = globalThis as typeof globalThis & {
|
||||
localStorage?: Storage;
|
||||
};
|
||||
const originalLocalStorage = globalScope.localStorage;
|
||||
const store = new Map<string, string>();
|
||||
|
||||
globalScope.localStorage = {
|
||||
getItem(key: string) {
|
||||
return store.has(key) ? store.get(key)! : null;
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
store.set(key, value);
|
||||
},
|
||||
removeItem(key: string) {
|
||||
store.delete(key);
|
||||
},
|
||||
clear() {
|
||||
store.clear();
|
||||
},
|
||||
key(index: number) {
|
||||
return [...store.keys()][index] ?? null;
|
||||
},
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
} as Storage;
|
||||
|
||||
return () => {
|
||||
globalScope.localStorage = originalLocalStorage;
|
||||
};
|
||||
}
|
||||
|
||||
function installConsoleLogSpy() {
|
||||
const globalConsole = console as typeof console & {
|
||||
log: (...args: unknown[]) => void;
|
||||
};
|
||||
const originalLog = globalConsole.log;
|
||||
const calls: unknown[][] = [];
|
||||
|
||||
globalConsole.log = (...args: unknown[]) => {
|
||||
calls.push(args);
|
||||
describe("Core Protocol", () => {
|
||||
describe("encodeCommunicationMessage", () => {
|
||||
it("encodes message with id", () => {
|
||||
const message: TypedMessage = {
|
||||
id: 123,
|
||||
type: "ping",
|
||||
data: {},
|
||||
};
|
||||
|
||||
return {
|
||||
calls,
|
||||
restore() {
|
||||
globalConsole.log = originalLog;
|
||||
},
|
||||
const encoded = encodeCommunicationMessage(message);
|
||||
|
||||
expect(encoded instanceof Uint8Array).toBe(true);
|
||||
expect(encoded.byteLength > 0).toBe(true);
|
||||
});
|
||||
|
||||
it("encodes message without id", () => {
|
||||
const message: TypedMessage = {
|
||||
id: 0,
|
||||
type: "pong",
|
||||
data: {},
|
||||
};
|
||||
}
|
||||
|
||||
describe("TTP communication codec", () => {
|
||||
test("encodes and decodes a mixed payload message", () => {
|
||||
const input = createRoundTripMessage();
|
||||
|
||||
const encoded = encodeCommunicationMessage(input);
|
||||
const decoded = decodeCommunicationMessage(frameBytes(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",
|
||||
},
|
||||
],
|
||||
});
|
||||
const encoded = encodeCommunicationMessage(message);
|
||||
expect(encoded instanceof Uint8Array).toBe(true);
|
||||
});
|
||||
|
||||
test("decodes an empty error payload as an empty object", () => {
|
||||
const innerFrame = new Uint8Array(6);
|
||||
const view = new DataView(innerFrame.buffer);
|
||||
|
||||
view.setUint32(0, 2, false);
|
||||
innerFrame[4] = 0;
|
||||
innerFrame[5] = 0;
|
||||
|
||||
const decoded = decodeCommunicationMessage(frameBytes(innerFrame));
|
||||
|
||||
expect(decoded.id).toBe(0);
|
||||
expect(decoded.type).toBe("error");
|
||||
expect(decoded.data).toEqual({});
|
||||
});
|
||||
|
||||
test("keeps malformed error payloads visible as error messages", () => {
|
||||
const innerFrame = new Uint8Array(8);
|
||||
const view = new DataView(innerFrame.buffer);
|
||||
|
||||
view.setUint32(0, 4, false);
|
||||
innerFrame[4] = 0;
|
||||
innerFrame[5] = 0;
|
||||
innerFrame[6] = 0;
|
||||
innerFrame[7] = 1;
|
||||
|
||||
const decoded = decodeCommunicationMessage(frameBytes(innerFrame));
|
||||
|
||||
expect(decoded.id).toBe(0);
|
||||
expect(decoded.type).toBe("error");
|
||||
expect(decoded.data).toEqual({});
|
||||
});
|
||||
|
||||
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({
|
||||
it("encodes message with string data", () => {
|
||||
const message: TypedMessage = {
|
||||
id: 1,
|
||||
type: "message",
|
||||
data: { unknown_key: 1 },
|
||||
}),
|
||||
).toThrow("Unknown data type");
|
||||
data: { content: "hello", sender_id: 42 },
|
||||
};
|
||||
|
||||
const encoded = encodeCommunicationMessage(message);
|
||||
const decoded = decodeCommunicationMessage(wrapForDecode(encoded));
|
||||
|
||||
expect(decoded.type).toBe("message");
|
||||
expect(decoded.id).toBe(1);
|
||||
});
|
||||
|
||||
test("skips unknown container keys without failing decode", () => {
|
||||
const encoded = encodeCommunicationMessage({
|
||||
id: 9,
|
||||
it("throws on unknown message type", () => {
|
||||
const message: TypedMessage = {
|
||||
id: 1,
|
||||
type: "unknown_type",
|
||||
data: {},
|
||||
};
|
||||
|
||||
expect(() => encodeCommunicationMessage(message)).toThrow(
|
||||
"Unknown communication type",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeCommunicationMessage", () => {
|
||||
it("decodes encoded message", () => {
|
||||
const original: TypedMessage = {
|
||||
id: 456,
|
||||
type: "success",
|
||||
data: {},
|
||||
};
|
||||
|
||||
const encoded = encodeCommunicationMessage(original);
|
||||
const decoded = decodeCommunicationMessage(wrapForDecode(encoded));
|
||||
|
||||
expect(decoded.id).toBe(456);
|
||||
expect(decoded.type).toBe("success");
|
||||
});
|
||||
|
||||
it("throws on truncated frame", () => {
|
||||
const truncated = new Uint8Array([0x00, 0x00, 0x00]);
|
||||
expect(() => decodeCommunicationMessage(truncated)).toThrow();
|
||||
});
|
||||
|
||||
it("throws on frame length mismatch", () => {
|
||||
const buffer = new Uint8Array(10);
|
||||
buffer[0] = 0xff;
|
||||
buffer[1] = 0xff;
|
||||
buffer[2] = 0xff;
|
||||
buffer[3] = 0xff;
|
||||
|
||||
expect(() => decodeCommunicationMessage(buffer)).toThrow(
|
||||
"Communication frame length mismatch",
|
||||
);
|
||||
});
|
||||
|
||||
it("decodes error messages with empty data", () => {
|
||||
const original: TypedMessage = {
|
||||
id: 1,
|
||||
type: "error",
|
||||
data: {
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
data: {},
|
||||
};
|
||||
|
||||
const corrupted = encoded.slice();
|
||||
corrupted[corrupted.length - 1] = 215;
|
||||
const encoded = encodeCommunicationMessage(original);
|
||||
const decoded = decodeCommunicationMessage(wrapForDecode(encoded));
|
||||
|
||||
const decoded = decodeCommunicationMessage(frameBytes(corrupted));
|
||||
|
||||
expect(decoded.id).toBe(9);
|
||||
expect(decoded.type).toBe("error");
|
||||
expect(decoded.data).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
test("aligns live message schema with backend protocol name", () => {
|
||||
expect(socket.message_live !== undefined).toBe(true);
|
||||
expect((socket as Record<string, unknown>).live_message).toEqual(undefined);
|
||||
});
|
||||
describe("createTransportClient", () => {
|
||||
it("creates client with schemas", () => {
|
||||
const { MockWebTransport } = createMockWebTransport();
|
||||
const globalWithWebTransport = globalThis as unknown as {
|
||||
WebTransport?: new (url: string) => MockTransportInstance;
|
||||
};
|
||||
globalWithWebTransport.WebTransport = MockWebTransport;
|
||||
|
||||
test("does not log raw binary frames unless enabled", async () => {
|
||||
const ignoredFrame = encodeCommunicationMessage({
|
||||
id: 0,
|
||||
type: "error_internal",
|
||||
data: {
|
||||
error_type: "transport_noise",
|
||||
const schemas: SchemaMap = {
|
||||
ping: {
|
||||
request: z.object({}),
|
||||
response: z.object({}),
|
||||
},
|
||||
};
|
||||
|
||||
const client = createTransportClient(schemas);
|
||||
|
||||
expect(client !== undefined).toBe(true);
|
||||
expect(typeof client.readyState === "function").toBe(true);
|
||||
expect(typeof client.connect === "function").toBe(true);
|
||||
expect(typeof client.send === "function").toBe(true);
|
||||
expect(typeof client.close === "function").toBe(true);
|
||||
expect(typeof client.subscribePush === "function").toBe(true);
|
||||
});
|
||||
|
||||
const identificationFrame = encodeCommunicationMessage({
|
||||
id: 7,
|
||||
type: "identification",
|
||||
data: {
|
||||
challenge: "Zm9v",
|
||||
public_key: "Zm9v",
|
||||
it("returns CLOSED ready state initially", () => {
|
||||
const client = createTransportClient({});
|
||||
expect(client.readyState()).toBe(3); // CLOSED
|
||||
});
|
||||
|
||||
it("rejects send when not connected", async () => {
|
||||
const { MockWebTransport } = createMockWebTransport();
|
||||
const globalWithWebTransport = globalThis as unknown as {
|
||||
WebTransport?: new (url: string) => MockTransportInstance;
|
||||
};
|
||||
globalWithWebTransport.WebTransport = MockWebTransport;
|
||||
|
||||
const schemas: SchemaMap = {
|
||||
ping: {
|
||||
request: z.object({}),
|
||||
response: z.object({}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const streamBytes = concatBytes([
|
||||
frameBytes(ignoredFrame),
|
||||
frameBytes(identificationFrame),
|
||||
]);
|
||||
const restoreWebTransport = installFakeWebTransport(
|
||||
chunkBytes(streamBytes, [2, 5, 1, 7]),
|
||||
const client = createTransportClient(schemas);
|
||||
|
||||
await expectRejection(
|
||||
client.send("ping", {}),
|
||||
"Transport is not connected",
|
||||
);
|
||||
const restoreLocalStorage = installFakeLocalStorage();
|
||||
const consoleSpy = installConsoleLogSpy();
|
||||
|
||||
try {
|
||||
const client = createTransportClient(socket, {
|
||||
url: "https://example.test",
|
||||
});
|
||||
|
||||
await client.connect("https://example.test");
|
||||
it("calls readyStateChange callback", async () => {
|
||||
const { readyResolve, MockWebTransport } = createMockWebTransport();
|
||||
const globalWithWebTransport = globalThis as unknown as {
|
||||
WebTransport?: new (url: string) => MockTransportInstance;
|
||||
};
|
||||
globalWithWebTransport.WebTransport = MockWebTransport;
|
||||
|
||||
const response = client.send("identification", { user_id: 1 }, { id: 7 });
|
||||
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new Error("identification response timed out"));
|
||||
}, 250);
|
||||
});
|
||||
|
||||
const result = await Promise.race([response, timeout]);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 7,
|
||||
type: "identification",
|
||||
data: {
|
||||
challenge: "Zm9v",
|
||||
public_key: "Zm9v",
|
||||
const readyStateChanges: number[] = [];
|
||||
const client = createTransportClient(
|
||||
{},
|
||||
{
|
||||
onReadyStateChange: (state) => readyStateChanges.push(state),
|
||||
},
|
||||
});
|
||||
expect(consoleSpy.calls);
|
||||
|
||||
await client.close("test-complete");
|
||||
} finally {
|
||||
consoleSpy.restore();
|
||||
restoreLocalStorage();
|
||||
restoreWebTransport();
|
||||
}
|
||||
});
|
||||
|
||||
test("logs raw binary frames when enabled", async () => {
|
||||
const requestMessage = encodeCommunicationMessage({
|
||||
id: 7,
|
||||
type: "identification",
|
||||
data: {
|
||||
user_id: 1,
|
||||
},
|
||||
});
|
||||
const requestFrame = frameBytes(requestMessage);
|
||||
|
||||
const ignoredFrame = encodeCommunicationMessage({
|
||||
id: 0,
|
||||
type: "error_internal",
|
||||
data: {
|
||||
error_type: "transport_noise",
|
||||
},
|
||||
});
|
||||
|
||||
const identificationFrame = encodeCommunicationMessage({
|
||||
id: 7,
|
||||
type: "identification",
|
||||
data: {
|
||||
challenge: "Zm9v",
|
||||
public_key: "Zm9v",
|
||||
},
|
||||
});
|
||||
const ignoredTransportFrame = frameBytes(ignoredFrame);
|
||||
const identificationTransportFrame = frameBytes(identificationFrame);
|
||||
|
||||
const streamBytes = concatBytes([
|
||||
ignoredTransportFrame,
|
||||
identificationTransportFrame,
|
||||
]);
|
||||
const restoreWebTransport = installFakeWebTransport(
|
||||
chunkBytes(streamBytes, [2, 5, 1, 7]),
|
||||
);
|
||||
const restoreLocalStorage = installFakeLocalStorage();
|
||||
const consoleSpy = installConsoleLogSpy();
|
||||
|
||||
try {
|
||||
localStorage.setItem("ttp_logBinary", "true");
|
||||
|
||||
const client = createTransportClient(socket, {
|
||||
url: "https://example.test",
|
||||
});
|
||||
|
||||
await client.connect("https://example.test");
|
||||
|
||||
const response = client.send("identification", { user_id: 1 }, { id: 7 });
|
||||
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new Error("identification response timed out"));
|
||||
}, 250);
|
||||
});
|
||||
|
||||
const result = await Promise.race([response, timeout]);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 7,
|
||||
type: "identification",
|
||||
data: {
|
||||
challenge: "Zm9v",
|
||||
public_key: "Zm9v",
|
||||
},
|
||||
});
|
||||
|
||||
const logMessages = consoleSpy.calls
|
||||
.filter((entry) => typeof entry[0] === "string")
|
||||
.map((entry) => entry[0] as string);
|
||||
|
||||
expect(logMessages).toContain(
|
||||
`[Socket] Outgoing binary message (${requestFrame.byteLength} bytes)`,
|
||||
);
|
||||
expect(logMessages).toContain(
|
||||
`[Socket] Incoming binary message (${ignoredTransportFrame.byteLength} bytes)`,
|
||||
);
|
||||
expect(logMessages).toContain(
|
||||
`[Socket] Incoming binary message (${identificationTransportFrame.byteLength} bytes)`,
|
||||
);
|
||||
|
||||
const outgoingCall = consoleSpy.calls.find(
|
||||
(entry) =>
|
||||
entry[0] ===
|
||||
`[Socket] Outgoing binary message (${requestFrame.byteLength} bytes)`,
|
||||
const connectPromise = client.connect("http://localhost:8000");
|
||||
readyResolve();
|
||||
await connectPromise;
|
||||
|
||||
expect(readyStateChanges).toContain(0); // CONNECTING
|
||||
expect(readyStateChanges).toContain(1); // OPEN
|
||||
});
|
||||
|
||||
it("calls close callback on intentional close", async () => {
|
||||
const { readyResolve, closedResolve, MockWebTransport } =
|
||||
createMockWebTransport();
|
||||
const globalWithWebTransport = globalThis as unknown as {
|
||||
WebTransport?: new (url: string) => MockTransportInstance;
|
||||
};
|
||||
globalWithWebTransport.WebTransport = MockWebTransport;
|
||||
|
||||
const closeEvents: Array<{ intentional: boolean; error?: unknown }> = [];
|
||||
const client = createTransportClient(
|
||||
{},
|
||||
{
|
||||
onClose: (event) => closeEvents.push(event),
|
||||
},
|
||||
);
|
||||
expect(outgoingCall?.[1]).toEqual(requestFrame);
|
||||
|
||||
const incomingCall = consoleSpy.calls.find(
|
||||
(entry) =>
|
||||
entry[0] ===
|
||||
`[Socket] Incoming binary message (${identificationTransportFrame.byteLength} bytes)`,
|
||||
);
|
||||
expect(incomingCall?.[1]).toEqual(identificationTransportFrame);
|
||||
const connectPromise = client.connect("http://localhost:8000");
|
||||
readyResolve();
|
||||
await connectPromise;
|
||||
|
||||
await client.close("test-complete");
|
||||
} finally {
|
||||
consoleSpy.restore();
|
||||
restoreLocalStorage();
|
||||
restoreWebTransport();
|
||||
}
|
||||
const closePromise = client.close();
|
||||
closedResolve();
|
||||
await closePromise;
|
||||
|
||||
expect(closeEvents.length > 0).toBe(true);
|
||||
expect(closeEvents[closeEvents.length - 1].intentional).toBe(true);
|
||||
});
|
||||
|
||||
test("resolves an identification response before the stream closes", async () => {
|
||||
const ignoredFrame = encodeCommunicationMessage({
|
||||
id: 0,
|
||||
type: "error_internal",
|
||||
data: {
|
||||
error_type: "transport_noise",
|
||||
it("rejects pending requests on close", async () => {
|
||||
const { readyResolve, closedResolve, MockWebTransport } =
|
||||
createMockWebTransport();
|
||||
const globalWithWebTransport = globalThis as unknown as {
|
||||
WebTransport?: new (url: string) => MockTransportInstance;
|
||||
};
|
||||
globalWithWebTransport.WebTransport = MockWebTransport;
|
||||
|
||||
const schemas: SchemaMap = {
|
||||
ping: {
|
||||
request: z.object({}),
|
||||
response: z.object({}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const identificationFrame = encodeCommunicationMessage({
|
||||
id: 7,
|
||||
type: "identification",
|
||||
data: {
|
||||
challenge: "Zm9v",
|
||||
public_key: "Zm9v",
|
||||
},
|
||||
});
|
||||
const client = createTransportClient(schemas);
|
||||
const connectPromise = client.connect("http://localhost:8000");
|
||||
readyResolve();
|
||||
await connectPromise;
|
||||
|
||||
const streamBytes = concatBytes([
|
||||
frameBytes(ignoredFrame),
|
||||
frameBytes(identificationFrame),
|
||||
]);
|
||||
const restoreWebTransport = installFakeWebTransport(
|
||||
chunkBytes(streamBytes, [2, 5, 1, 7]),
|
||||
);
|
||||
const restoreLocalStorage = installFakeLocalStorage();
|
||||
const sendPromise = client.send("ping", {});
|
||||
const closePromise = client.close();
|
||||
closedResolve();
|
||||
await closePromise;
|
||||
|
||||
try {
|
||||
const client = createTransportClient(socket, {
|
||||
url: "https://example.test",
|
||||
await expectRejection(sendPromise);
|
||||
});
|
||||
});
|
||||
|
||||
await client.connect("https://example.test");
|
||||
describe("Push subscriptions", () => {
|
||||
it("subscribes and unsubscribes from push events", () => {
|
||||
const client = createTransportClient({});
|
||||
|
||||
const response = client.send("identification", { user_id: 1 }, { id: 7 });
|
||||
const handler = () => {};
|
||||
const unsubscribe = client.subscribePush(handler);
|
||||
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new Error("identification response timed out"));
|
||||
}, 250);
|
||||
expect(typeof unsubscribe).toBe("function");
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
|
||||
const result = await Promise.race([response, timeout]);
|
||||
describe("Data type encoding", () => {
|
||||
it("encodes boolean true", () => {
|
||||
const message: TypedMessage = {
|
||||
id: 1,
|
||||
type: "message",
|
||||
data: { signed: true },
|
||||
};
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 7,
|
||||
type: "identification",
|
||||
data: {
|
||||
challenge: "Zm9v",
|
||||
public_key: "Zm9v",
|
||||
},
|
||||
const encoded = encodeCommunicationMessage(message);
|
||||
const decoded = decodeCommunicationMessage(wrapForDecode(encoded));
|
||||
|
||||
expect(decoded.data.signed).toBe(true);
|
||||
});
|
||||
|
||||
it("encodes boolean false", () => {
|
||||
const message: TypedMessage = {
|
||||
id: 1,
|
||||
type: "message",
|
||||
data: { signed: false },
|
||||
};
|
||||
|
||||
await client.close("test-complete");
|
||||
} finally {
|
||||
restoreLocalStorage();
|
||||
restoreWebTransport();
|
||||
}
|
||||
const encoded = encodeCommunicationMessage(message);
|
||||
const decoded = decodeCommunicationMessage(wrapForDecode(encoded));
|
||||
|
||||
expect(decoded.data.signed).toBe(false);
|
||||
});
|
||||
|
||||
it("encodes numbers", () => {
|
||||
const message: TypedMessage = {
|
||||
id: 1,
|
||||
type: "message",
|
||||
data: { user_id: 42, sender_id: 100 },
|
||||
};
|
||||
|
||||
const encoded = encodeCommunicationMessage(message);
|
||||
const decoded = decodeCommunicationMessage(wrapForDecode(encoded));
|
||||
|
||||
test("resolves an identification response that arrives after another frame on the same stream", async () => {
|
||||
const ignoredFrame = encodeCommunicationMessage({
|
||||
id: 0,
|
||||
type: "error_internal",
|
||||
data: {
|
||||
error_type: "transport_noise",
|
||||
},
|
||||
expect(decoded.data.user_id).toBe(42);
|
||||
expect(decoded.data.sender_id).toBe(100);
|
||||
});
|
||||
|
||||
const identificationFrame = encodeCommunicationMessage({
|
||||
id: 7,
|
||||
type: "identification",
|
||||
data: {
|
||||
challenge: "Zm9v",
|
||||
public_key: "Zm9v",
|
||||
},
|
||||
it("encodes strings", () => {
|
||||
const message: TypedMessage = {
|
||||
id: 1,
|
||||
type: "message",
|
||||
data: { content: "test message", username: "alice" },
|
||||
};
|
||||
|
||||
const encoded = encodeCommunicationMessage(message);
|
||||
const decoded = decodeCommunicationMessage(wrapForDecode(encoded));
|
||||
|
||||
expect(decoded.data.content).toBe("test message");
|
||||
expect(decoded.data.username).toBe("alice");
|
||||
});
|
||||
|
||||
it("encodes null values", () => {
|
||||
const message: TypedMessage = {
|
||||
id: 1,
|
||||
type: "message",
|
||||
data: { status: null },
|
||||
};
|
||||
|
||||
const encoded = encodeCommunicationMessage(message);
|
||||
const decoded = decodeCommunicationMessage(wrapForDecode(encoded));
|
||||
|
||||
const streamBytes = concatBytes([
|
||||
frameBytes(ignoredFrame),
|
||||
frameBytes(identificationFrame),
|
||||
]);
|
||||
const restoreWebTransport = installFakeWebTransport(
|
||||
chunkBytes(streamBytes, [1, 4, 3, 9, 2]),
|
||||
);
|
||||
const restoreLocalStorage = installFakeLocalStorage();
|
||||
|
||||
try {
|
||||
const client = createTransportClient(socket, {
|
||||
url: "https://example.test",
|
||||
expect(decoded.data.status).toBe(null);
|
||||
});
|
||||
|
||||
await client.connect("https://example.test");
|
||||
it("encodes arrays of numbers", () => {
|
||||
const message: TypedMessage = {
|
||||
id: 1,
|
||||
type: "message",
|
||||
data: { user_ids: [1, 2, 3] },
|
||||
};
|
||||
|
||||
const response = client.send("identification", { user_id: 1 }, { id: 7 });
|
||||
const encoded = encodeCommunicationMessage(message);
|
||||
const decoded = decodeCommunicationMessage(wrapForDecode(encoded));
|
||||
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new Error("identification response timed out"));
|
||||
}, 250);
|
||||
expect(decoded.data.user_ids).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
const result = await Promise.race([response, timeout]);
|
||||
it("encodes nested containers", () => {
|
||||
const message: TypedMessage = {
|
||||
id: 1,
|
||||
type: "message",
|
||||
data: { user: { username: "alice", display: "Alice" } },
|
||||
};
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 7,
|
||||
type: "identification",
|
||||
data: {
|
||||
challenge: "Zm9v",
|
||||
public_key: "Zm9v",
|
||||
},
|
||||
const encoded = encodeCommunicationMessage(message);
|
||||
const decoded = decodeCommunicationMessage(wrapForDecode(encoded));
|
||||
|
||||
expect(decoded.data.user).toEqual({
|
||||
username: "alice",
|
||||
display: "Alice",
|
||||
});
|
||||
});
|
||||
|
||||
await client.close("test-complete");
|
||||
} finally {
|
||||
restoreLocalStorage();
|
||||
restoreWebTransport();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -61,6 +61,11 @@ type ActiveConnection = {
|
|||
streamReader: ReadableStreamDefaultReader<ReadableStream<Uint8Array>> | null;
|
||||
intentional: boolean;
|
||||
closeNotified: boolean;
|
||||
acceptLoopDone: Promise<void> | null;
|
||||
resolveAcceptLoopDone: (() => void) | null;
|
||||
activeIncomingTasks: Set<Promise<void>>;
|
||||
sendStream: WritableStream<Uint8Array> | null;
|
||||
sendWriter: WritableStreamDefaultWriter<Uint8Array> | null;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -532,32 +537,6 @@ 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,
|
||||
) => {
|
||||
if (!isStopSendingError(error)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
connection.transport.close({
|
||||
closeCode: APPLICATION_CLOSE_CODE,
|
||||
reason: "stop-sending",
|
||||
});
|
||||
} catch {
|
||||
// Ignore close failures while handling STOP_SENDING.
|
||||
}
|
||||
|
||||
handleConnectionFailure(connection, error);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles decoded incoming messages and resolves request promises or push listeners.
|
||||
* @param message Decoded incoming message.
|
||||
|
|
@ -680,40 +659,88 @@ export function createTransportClient<T extends SchemaMap>(
|
|||
const startIncomingLoop = (connection: ActiveConnection) => {
|
||||
connection.streamReader =
|
||||
connection.transport.incomingUnidirectionalStreams.getReader();
|
||||
connection.acceptLoopDone = new Promise<void>((resolve) => {
|
||||
connection.resolveAcceptLoopDone = resolve;
|
||||
});
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
while (currentConnection === connection && !connection.intentional) {
|
||||
const result = await connection.streamReader?.read();
|
||||
while (!connection.closeNotified) {
|
||||
const streamReader = connection.streamReader;
|
||||
if (!streamReader) {
|
||||
break;
|
||||
}
|
||||
|
||||
const readResult = await Promise.race([
|
||||
streamReader.read().then((result) => ({
|
||||
type: "stream" as const,
|
||||
result,
|
||||
})),
|
||||
connection.transport.closed
|
||||
.catch(() => undefined)
|
||||
.then(() => ({ type: "closed" as const })),
|
||||
]);
|
||||
|
||||
if (readResult.type !== "stream") {
|
||||
break;
|
||||
}
|
||||
|
||||
const result = readResult.result;
|
||||
if (!result || result.done) {
|
||||
break;
|
||||
}
|
||||
|
||||
const closedByPeer = await processIncomingStream(
|
||||
const shouldDiscardFrames =
|
||||
connection.intentional || currentConnection !== connection;
|
||||
|
||||
const task = (async () => {
|
||||
try {
|
||||
await processIncomingStream(
|
||||
result.value,
|
||||
connection,
|
||||
handleIncomingMessage,
|
||||
handleRecoverableDecodeFailure,
|
||||
handleConnectionFailure,
|
||||
shouldDiscardFrames,
|
||||
);
|
||||
|
||||
if (closedByPeer) {
|
||||
return;
|
||||
} catch (error) {
|
||||
log(
|
||||
0,
|
||||
"Socket",
|
||||
"red",
|
||||
"Incoming transport stream failed",
|
||||
error,
|
||||
);
|
||||
handleConnectionFailure(connection, error);
|
||||
}
|
||||
})();
|
||||
|
||||
connection.activeIncomingTasks.add(task);
|
||||
void task.finally(() => {
|
||||
connection.activeIncomingTasks.delete(task);
|
||||
});
|
||||
}
|
||||
|
||||
if (!connection.intentional) {
|
||||
if (
|
||||
!connection.intentional &&
|
||||
!connection.closeNotified &&
|
||||
currentConnection === connection
|
||||
) {
|
||||
handleConnectionFailure(
|
||||
connection,
|
||||
new Error("Transport stream closed"),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
log(0, "Socket", "red", "Incoming transport stream failed", error);
|
||||
log(0, "Socket", "red", "Incoming stream accept loop failed", error);
|
||||
handleConnectionFailure(connection, error);
|
||||
} finally {
|
||||
connection.streamReader?.releaseLock();
|
||||
connection.streamReader = null;
|
||||
|
||||
const resolveAcceptLoopDone = connection.resolveAcceptLoopDone;
|
||||
connection.resolveAcceptLoopDone = null;
|
||||
resolveAcceptLoopDone?.();
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
|
@ -756,6 +783,11 @@ export function createTransportClient<T extends SchemaMap>(
|
|||
streamReader: null,
|
||||
intentional: false,
|
||||
closeNotified: false,
|
||||
acceptLoopDone: null,
|
||||
resolveAcceptLoopDone: null,
|
||||
activeIncomingTasks: new Set(),
|
||||
sendStream: null,
|
||||
sendWriter: null,
|
||||
};
|
||||
|
||||
currentConnection = connection;
|
||||
|
|
@ -793,21 +825,23 @@ export function createTransportClient<T extends SchemaMap>(
|
|||
|
||||
connection.intentional = true;
|
||||
setReadyState(READY_STATE.CLOSING);
|
||||
const acceptLoopDone = connection.acceptLoopDone;
|
||||
|
||||
rejectPending(new Error("Transport closed"));
|
||||
|
||||
try {
|
||||
connection.sendWriter?.releaseLock();
|
||||
await connection.sendStream?.abort();
|
||||
} catch {
|
||||
// Ignore errors during stream abort
|
||||
}
|
||||
|
||||
try {
|
||||
await writeCloseFrame(connection.transport);
|
||||
} catch (error) {
|
||||
log(1, "Socket", "yellow", "Failed to send close sentinel", error);
|
||||
}
|
||||
|
||||
try {
|
||||
connection.streamReader?.cancel().catch(() => undefined);
|
||||
} catch {
|
||||
// Ignore reader cancellation failures during shutdown.
|
||||
}
|
||||
|
||||
try {
|
||||
connection.transport.close({
|
||||
closeCode: APPLICATION_CLOSE_CODE,
|
||||
|
|
@ -819,6 +853,10 @@ export function createTransportClient<T extends SchemaMap>(
|
|||
|
||||
try {
|
||||
await connection.transport.closed.catch(() => undefined);
|
||||
await acceptLoopDone;
|
||||
if (connection.activeIncomingTasks.size > 0) {
|
||||
await Promise.allSettled([...connection.activeIncomingTasks]);
|
||||
}
|
||||
} finally {
|
||||
notifyClosed(connection);
|
||||
}
|
||||
|
|
@ -898,9 +936,9 @@ export function createTransportClient<T extends SchemaMap>(
|
|||
});
|
||||
|
||||
if (!expectsResponse) {
|
||||
return writeMessage(connection.transport, messageBytes).catch(
|
||||
return writeMessageOnPersistentStream(connection, messageBytes).catch(
|
||||
(error) => {
|
||||
closeFromStopSending(connection, error);
|
||||
handleConnectionFailure(connection, error);
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
|
|
@ -923,12 +961,14 @@ export function createTransportClient<T extends SchemaMap>(
|
|||
timeoutId,
|
||||
});
|
||||
|
||||
void writeMessage(connection.transport, messageBytes).catch((error) => {
|
||||
closeFromStopSending(connection, error);
|
||||
void writeMessageOnPersistentStream(connection, messageBytes).catch(
|
||||
(error) => {
|
||||
handleConnectionFailure(connection, error);
|
||||
clearTimeout(timeoutId);
|
||||
pending.delete(requestId);
|
||||
reject(error);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
|
|
@ -1055,39 +1095,6 @@ function logBinaryMessage(
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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");
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes("STOP_SENDING")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const errorWithCause = error as Error & { cause?: unknown };
|
||||
if (errorWithCause.cause !== undefined) {
|
||||
return isStopSendingError(errorWithCause.cause);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof error === "object" && error !== null) {
|
||||
const maybeMessage = (error as { message?: unknown }).message;
|
||||
if (typeof maybeMessage === "string") {
|
||||
return maybeMessage.includes("STOP_SENDING");
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures outbound message payloads are plain object records.
|
||||
* @param value Candidate payload.
|
||||
|
|
@ -1170,30 +1177,54 @@ function validateRequestId(id: number, expectsResponse: boolean) {
|
|||
|
||||
/**
|
||||
* Writes a protocol message payload as a framed unidirectional transport stream.
|
||||
* @param transport Active transport instance.
|
||||
* Uses a persistent stream, and retries once if the stream was closed by the receiver.
|
||||
* @param connection Active connection instance.
|
||||
* @param payload Encoded message payload bytes.
|
||||
* @returns Promise that resolves when frame writing is complete.
|
||||
*/
|
||||
async function writeMessage(transport: WebTransportLike, payload: Uint8Array) {
|
||||
async function writeMessageOnPersistentStream(
|
||||
connection: ActiveConnection,
|
||||
payload: Uint8Array,
|
||||
) {
|
||||
if (payload.byteLength >= CLOSE_FRAME_LEN) {
|
||||
throw new Error("Message too large for transport frame");
|
||||
}
|
||||
|
||||
const stream = await transport.createUnidirectionalStream();
|
||||
const writer = stream.getWriter();
|
||||
|
||||
try {
|
||||
const frame = new Uint8Array(4 + payload.byteLength);
|
||||
writeU32(frame, 0, payload.byteLength);
|
||||
frame.set(payload, 4);
|
||||
|
||||
logBinaryMessage("Outgoing", frame);
|
||||
|
||||
await writer.write(frame);
|
||||
await writer.close();
|
||||
} finally {
|
||||
writer.releaseLock();
|
||||
const writeAndCatch = async (): Promise<boolean> => {
|
||||
try {
|
||||
if (!connection.sendStream || !connection.sendWriter) {
|
||||
connection.sendStream =
|
||||
await connection.transport.createUnidirectionalStream();
|
||||
connection.sendWriter = connection.sendStream.getWriter();
|
||||
}
|
||||
|
||||
logBinaryMessage("Outgoing", frame);
|
||||
await connection.sendWriter.write(frame);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const firstResult = await writeAndCatch();
|
||||
if (firstResult) return;
|
||||
|
||||
// Retry once
|
||||
connection.sendWriter?.releaseLock();
|
||||
connection.sendWriter = null;
|
||||
connection.sendStream = null;
|
||||
|
||||
const secondResult = await writeAndCatch();
|
||||
if (secondResult) return;
|
||||
|
||||
connection.sendWriter = null;
|
||||
connection.sendStream = null;
|
||||
|
||||
throw new Error("Transport stream closed during send");
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1221,6 +1252,7 @@ async function writeCloseFrame(transport: WebTransportLike) {
|
|||
* @param connection Active connection instance.
|
||||
* @param handleIncomingFrame Handler for decoded protocol messages.
|
||||
* @param handleDecodeFailure Handler for recoverable frame decode failures.
|
||||
* @param discardFrames Whether frames should be drained and discarded.
|
||||
* @returns True when the peer close sentinel was received.
|
||||
*/
|
||||
async function processIncomingStream(
|
||||
|
|
@ -1229,9 +1261,11 @@ async function processIncomingStream(
|
|||
handleIncomingFrame: (message: TypedMessage) => void,
|
||||
handleDecodeFailure: (error: RecoverableMessageDecodeError) => void,
|
||||
handleStreamFailure: (connection: ActiveConnection, error?: unknown) => void,
|
||||
discardFrames: boolean,
|
||||
) {
|
||||
const reader = stream.getReader();
|
||||
let bufferedBytes = new Uint8Array(0) as Uint8Array<ArrayBufferLike>;
|
||||
let peerCloseDetected = false;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
|
|
@ -1242,10 +1276,18 @@ async function processIncomingStream(
|
|||
|
||||
bufferedBytes = appendBytes(bufferedBytes, value);
|
||||
|
||||
if (discardFrames || peerCloseDetected) {
|
||||
bufferedBytes = new Uint8Array(0) as Uint8Array<ArrayBufferLike>;
|
||||
continue;
|
||||
}
|
||||
|
||||
while (bufferedBytes.byteLength >= 4) {
|
||||
const declaredLength = readU32(bufferedBytes, 0);
|
||||
|
||||
if (declaredLength === CLOSE_FRAME_LEN) {
|
||||
peerCloseDetected = true;
|
||||
bufferedBytes = new Uint8Array(0) as Uint8Array<ArrayBufferLike>;
|
||||
|
||||
try {
|
||||
connection.transport.close({
|
||||
closeCode: APPLICATION_CLOSE_CODE,
|
||||
|
|
@ -1259,7 +1301,7 @@ async function processIncomingStream(
|
|||
connection,
|
||||
new Error("Transport closed by peer"),
|
||||
);
|
||||
return true;
|
||||
break;
|
||||
}
|
||||
|
||||
const expectedLength = 4 + declaredLength;
|
||||
|
|
@ -1277,20 +1319,24 @@ async function processIncomingStream(
|
|||
} catch (error) {
|
||||
if (error instanceof RecoverableMessageDecodeError) {
|
||||
handleDecodeFailure(error);
|
||||
continue;
|
||||
}
|
||||
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Just like the backend, drop the stream after receiving exactly one incoming message!
|
||||
return peerCloseDetected;
|
||||
}
|
||||
}
|
||||
|
||||
if (bufferedBytes.byteLength > 0) {
|
||||
if (!discardFrames && !peerCloseDetected && bufferedBytes.byteLength > 0) {
|
||||
throw new Error("Received truncated transport frame");
|
||||
}
|
||||
|
||||
return false;
|
||||
return peerCloseDetected;
|
||||
} finally {
|
||||
// We cancel the reader to signal the stream is naturally dropped, matching Rust's receiver behavior.
|
||||
reader.cancel().catch(() => {});
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
export const RESPONSE_TIMEOUT = 15_000;
|
||||
export const RETRY_COUNT = 10;
|
||||
export const RETRY_INTERVAL = 3_000;
|
||||
export const PING_INTERVAL = 5_000;
|
||||
export const PING_INTERVAL = 3_000;
|
||||
export const TRANSPORT_URL = "https://methanium.net:959";
|
||||
export const RECONNECT_TRIES = 3;
|
||||
export const RECONNECT_RESET = 6;
|
||||
Loading…
Reference in a new issue