Updated some webtransport stuff

This commit is contained in:
Alois 2026-03-18 23:27:35 +01:00
commit f1ad6f915e
5 changed files with 224 additions and 52 deletions

View file

@ -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 />

View file

@ -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

@ -0,0 +1 @@
Subproject commit bafbf13f43a9f7092341ec102621e234438be163

View file

@ -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();
}
});
}); });

View file

@ -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;
} }
/** /**