Updated some webtransport stuff
This commit is contained in:
parent
4613f1585b
commit
f1ad6f915e
5 changed files with 224 additions and 52 deletions
|
|
@ -1,27 +1,9 @@
|
||||||
import { useStorage } from "@tensamin/storage/context";
|
import { useStorage } from "@tensamin/storage/context";
|
||||||
import Wrapper from "@tensamin/user/wrapper";
|
import Wrapper from "@tensamin/user/wrapper";
|
||||||
import { type User } from "@tensamin/user/context";
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Basic, Loading } from "./modals/basic";
|
import { Basic, Loading } from "./modals/basic";
|
||||||
import List from "@/features/conversation/list/body";
|
import List from "@/features/conversation/list/body";
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders sidebar user summary content for the current user.
|
|
||||||
* @param user Loaded user data.
|
|
||||||
* @returns Sidebar user card JSX.
|
|
||||||
*/
|
|
||||||
function renderSidebarUser(user: User): React.ReactNode {
|
|
||||||
return <Basic user={user} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders sidebar user summary content skeleton while loading user data.
|
|
||||||
* @returns Sidebar user card skeleton JSX.
|
|
||||||
*/
|
|
||||||
function renderSidebarUserLoading(): React.ReactNode {
|
|
||||||
return <Loading />;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the conversation sidebar with account summary and conversation list.
|
* Renders the conversation sidebar with account summary and conversation list.
|
||||||
* @returns Sidebar JSX.
|
* @returns Sidebar JSX.
|
||||||
|
|
@ -37,9 +19,9 @@ export default function Sidebar() {
|
||||||
return (
|
return (
|
||||||
<div className="w-75 h-full flex flex-col gap-3 p-2">
|
<div className="w-75 h-full flex flex-col gap-3 p-2">
|
||||||
<Wrapper
|
<Wrapper
|
||||||
loading={renderSidebarUserLoading()}
|
loading={<Loading />}
|
||||||
userId={userId}
|
userId={userId}
|
||||||
component={renderSidebarUser}
|
component={(user) => <Basic user={user} />}
|
||||||
/>
|
/>
|
||||||
<div className="h-full">
|
<div className="h-full">
|
||||||
<List />
|
<List />
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@ export const socket = {
|
||||||
communities: z.array(community),
|
communities: z.array(community),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
live_message: {
|
message_live: {
|
||||||
request: z.object({}),
|
request: z.object({}),
|
||||||
response: z.object({
|
response: z.object({
|
||||||
sender_id: z.number(),
|
sender_id: z.number(),
|
||||||
|
|
|
||||||
1
packages/ttp/backend
Submodule
1
packages/ttp/backend
Submodule
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit bafbf13f43a9f7092341ec102621e234438be163
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { socket } from "@tensamin/shared/data";
|
||||||
import {
|
import {
|
||||||
|
createTransportClient,
|
||||||
decodeCommunicationMessage,
|
decodeCommunicationMessage,
|
||||||
encodeCommunicationMessage,
|
encodeCommunicationMessage,
|
||||||
type TypedMessage,
|
type TypedMessage,
|
||||||
|
|
@ -35,6 +37,101 @@ function createRoundTripMessage(): TypedMessage<Record<string, unknown>> {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function installFakeWebTransport(frameBytes: Uint8Array) {
|
||||||
|
const globalScope = globalThis as typeof globalThis & {
|
||||||
|
WebTransport?: unknown;
|
||||||
|
};
|
||||||
|
const originalWebTransport = globalScope.WebTransport;
|
||||||
|
|
||||||
|
class FakeWebTransport {
|
||||||
|
readonly ready = Promise.resolve();
|
||||||
|
|
||||||
|
readonly closed: Promise<void>;
|
||||||
|
|
||||||
|
private resolveClosed!: () => void;
|
||||||
|
|
||||||
|
readonly incomingUnidirectionalStreams: ReadableStream<ReadableStream<Uint8Array>>;
|
||||||
|
|
||||||
|
constructor(_url: string) {
|
||||||
|
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 splitIndex = 3;
|
||||||
|
innerController.enqueue(frameBytes.subarray(0, splitIndex));
|
||||||
|
setTimeout(() => {
|
||||||
|
innerController.enqueue(frameBytes.subarray(splitIndex));
|
||||||
|
}, 10);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.incomingUnidirectionalStreams = incomingStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
createUnidirectionalStream() {
|
||||||
|
return new WritableStream<Uint8Array>({
|
||||||
|
write() {
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.resolveClosed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
globalScope.WebTransport = FakeWebTransport as never;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
globalScope.WebTransport = originalWebTransport;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("TTP communication codec", () => {
|
describe("TTP communication codec", () => {
|
||||||
test("encodes and decodes a mixed payload message", () => {
|
test("encodes and decodes a mixed payload message", () => {
|
||||||
const input = createRoundTripMessage();
|
const input = createRoundTripMessage();
|
||||||
|
|
@ -84,4 +181,76 @@ describe("TTP communication codec", () => {
|
||||||
}),
|
}),
|
||||||
).toThrow("Unknown data type");
|
).toThrow("Unknown data type");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("skips unknown container keys without failing decode", () => {
|
||||||
|
const encoded = encodeCommunicationMessage({
|
||||||
|
id: 9,
|
||||||
|
type: "error",
|
||||||
|
data: {
|
||||||
|
accepted: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const corrupted = encoded.slice();
|
||||||
|
corrupted[corrupted.length - 1] = 215;
|
||||||
|
|
||||||
|
const decoded = decodeCommunicationMessage(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);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolves an identification response before the stream closes", async () => {
|
||||||
|
const frame = encodeCommunicationMessage({
|
||||||
|
id: 7,
|
||||||
|
type: "identification",
|
||||||
|
data: {
|
||||||
|
challenge: "Zm9v",
|
||||||
|
public_key: "Zm9v",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const restoreWebTransport = installFakeWebTransport(frame);
|
||||||
|
const restoreLocalStorage = installFakeLocalStorage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.close("test-complete");
|
||||||
|
} finally {
|
||||||
|
restoreLocalStorage();
|
||||||
|
restoreWebTransport();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1205,32 +1205,6 @@ async function writeCloseFrame(transport: WebTransportLike) {
|
||||||
* @returns Decoded typed message or null for close sentinel frames.
|
* @returns Decoded typed message or null for close sentinel frames.
|
||||||
*/
|
*/
|
||||||
async function readFrame(stream: ReadableStream<Uint8Array>) {
|
async function readFrame(stream: ReadableStream<Uint8Array>) {
|
||||||
const payload = await readAll(stream);
|
|
||||||
if (payload.byteLength < 4) {
|
|
||||||
throw new Error("Received truncated transport frame");
|
|
||||||
}
|
|
||||||
|
|
||||||
const declaredLength = readU32(payload, 0);
|
|
||||||
if (declaredLength === CLOSE_FRAME_LEN) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const actualLength = payload.byteLength - 4;
|
|
||||||
if (actualLength !== declaredLength) {
|
|
||||||
throw new Error(
|
|
||||||
`Transport frame length mismatch: expected ${declaredLength}, received ${actualLength}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 reader = stream.getReader();
|
||||||
const chunks: Uint8Array[] = [];
|
const chunks: Uint8Array[] = [];
|
||||||
let totalLength = 0;
|
let totalLength = 0;
|
||||||
|
|
@ -1244,11 +1218,58 @@ async function readAll(stream: ReadableStream<Uint8Array>) {
|
||||||
|
|
||||||
chunks.push(value);
|
chunks.push(value);
|
||||||
totalLength += value.byteLength;
|
totalLength += value.byteLength;
|
||||||
|
|
||||||
|
if (totalLength >= 4) {
|
||||||
|
const payload = concatChunks(chunks, totalLength);
|
||||||
|
const declaredLength = readU32(payload, 0);
|
||||||
|
|
||||||
|
if (declaredLength === CLOSE_FRAME_LEN) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedLength = 4 + declaredLength;
|
||||||
|
if (totalLength >= expectedLength) {
|
||||||
|
if (totalLength !== expectedLength) {
|
||||||
|
throw new Error(
|
||||||
|
`Transport frame length mismatch: expected ${declaredLength}, received ${totalLength - 4}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return decodeCommunicationMessage(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
reader.releaseLock();
|
reader.releaseLock();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (totalLength < 4) {
|
||||||
|
throw new Error("Received truncated transport frame");
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = concatChunks(chunks, totalLength);
|
||||||
|
const declaredLength = readU32(payload, 0);
|
||||||
|
if (declaredLength === CLOSE_FRAME_LEN) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actualLength = payload.byteLength - 4;
|
||||||
|
if (actualLength !== declaredLength) {
|
||||||
|
throw new Error(
|
||||||
|
`Transport frame length mismatch: expected ${declaredLength}, received ${actualLength}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return decodeCommunicationMessage(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Concatenates stream chunks into a single byte array.
|
||||||
|
* @param chunks Stream chunks to concatenate.
|
||||||
|
* @param totalLength Total byte length of the concatenated chunks.
|
||||||
|
* @returns Concatenated stream bytes.
|
||||||
|
*/
|
||||||
|
function concatChunks(chunks: Uint8Array[], totalLength: number) {
|
||||||
const buffer = new Uint8Array(totalLength);
|
const buffer = new Uint8Array(totalLength);
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
|
|
||||||
|
|
@ -1729,11 +1750,15 @@ function decodeContainerPayload(reader: ByteReader) {
|
||||||
const marker = reader.readU8();
|
const marker = reader.readU8();
|
||||||
const payloadLength = isBoolKindMarker(marker) ? 0 : reader.readU16();
|
const payloadLength = isBoolKindMarker(marker) ? 0 : reader.readU16();
|
||||||
const keyIndex = reader.readU8();
|
const keyIndex = reader.readU8();
|
||||||
const key = getDataTypeNameByIndex(keyIndex);
|
|
||||||
const payload = isBoolKindMarker(marker)
|
const payload = isBoolKindMarker(marker)
|
||||||
? new Uint8Array(0)
|
? new Uint8Array(0)
|
||||||
: reader.readBytes(payloadLength);
|
: reader.readBytes(payloadLength);
|
||||||
|
|
||||||
|
const key = getDataTypeNameByIndex(keyIndex);
|
||||||
|
if (!key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const expectedKind = getExpectedKind(key);
|
const expectedKind = getExpectedKind(key);
|
||||||
if (!isMarkerCompatibleWithKey(marker, expectedKind, key)) {
|
if (!isMarkerCompatibleWithKey(marker, expectedKind, key)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
@ -1756,12 +1781,7 @@ function decodeContainerPayload(reader: ByteReader) {
|
||||||
* @returns Canonical protocol data key name.
|
* @returns Canonical protocol data key name.
|
||||||
*/
|
*/
|
||||||
function getDataTypeNameByIndex(index: number) {
|
function getDataTypeNameByIndex(index: number) {
|
||||||
const key = DATA_TYPES[index];
|
return DATA_TYPES[index];
|
||||||
if (!key) {
|
|
||||||
throw new Error(`Unknown data type index ${index}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return key;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue