feat(tauth): add tauth
refactor(ttp): now using ttp-core package from git
This commit is contained in:
parent
077d1ae864
commit
cbbadc9e67
26 changed files with 519 additions and 2916 deletions
|
|
@ -1,7 +1,7 @@
|
|||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import { log, toast } from "@tensamin/shared/log";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useTTP } from "@tensamin/ttp/context";
|
||||
import { useTTP } from "@tensamin/ttp";
|
||||
import z from "zod";
|
||||
import { ttp } from "@tensamin/shared/data";
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { Button } from "@tensamin/ui";
|
|||
|
||||
import { Plus, Laugh, Clapperboard } from "lucide-react";
|
||||
import { useChat } from "../context";
|
||||
import { useTTP } from "@tensamin/ttp/context";
|
||||
import { useTTP } from "@tensamin/ttp";
|
||||
import { useCrypto } from "@tensamin/crypto/context";
|
||||
import { log, toast } from "@tensamin/shared/log";
|
||||
import Message from "./message";
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import type { LiveMessage, RawMessage, RawMessages } from "./values";
|
|||
import { useCrypto } from "@tensamin/crypto/context";
|
||||
import { useUser } from "@tensamin/user/context";
|
||||
import { useStorage } from "@tensamin/storage/context";
|
||||
import { useTTP } from "@tensamin/ttp/context";
|
||||
import { useTTP } from "@tensamin/ttp";
|
||||
import { log } from "@tensamin/shared/log";
|
||||
|
||||
export const context = createContext<contextType | undefined>(undefined);
|
||||
|
|
@ -54,7 +54,7 @@ function updateMessageStateByTimestamp<
|
|||
* @param props Parameter props.
|
||||
* @returns unknown.
|
||||
*/
|
||||
export default function Provider(props: { children: ReactNode }) {
|
||||
export function Provider(props: { children: ReactNode }) {
|
||||
const { getSharedSecret } = useCrypto();
|
||||
const { get } = useUser();
|
||||
const { load } = useStorage();
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
"build": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tensamin/ui": "*",
|
||||
"lucide-react": "^0.564.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
|
|
|
|||
|
|
@ -155,6 +155,23 @@ export const ttp = {
|
|||
}),
|
||||
},
|
||||
|
||||
authenticate_app: {
|
||||
request: z.object({
|
||||
app_identifier: z.string(),
|
||||
}),
|
||||
response: z.object({
|
||||
challenge: z.base64(),
|
||||
}),
|
||||
},
|
||||
get_app: {
|
||||
request: z.object({
|
||||
app_identifier: z.string(),
|
||||
}),
|
||||
response: z.object({
|
||||
app_public_key: z.base64(),
|
||||
}),
|
||||
},
|
||||
|
||||
// Calls
|
||||
get_call_data: {
|
||||
request: z.object({
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { toast as sonnerToast } from "sonner";
|
||||
import { toast as sonnerToast } from "@tensamin/ui";
|
||||
import { Ban, Check, Info, TriangleAlert } from "lucide-react";
|
||||
|
||||
/**
|
||||
|
|
|
|||
29
packages/tauth/package.json
Normal file
29
packages/tauth/package.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"name": "@tensamin/tauth",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./context": "./src/context.tsx"
|
||||
},
|
||||
"scripts": {
|
||||
"format": "bunx prettier --write .",
|
||||
"lint": "eslint src",
|
||||
"build": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-router": "^1.0.0",
|
||||
"@tensamin/crypto": "workspace:*",
|
||||
"@tensamin/shared": "workspace:*",
|
||||
"@tensamin/storage": "workspace:*",
|
||||
"@tensamin/tauri": "workspace:*",
|
||||
"@tensamin/ttp": "workspace:*",
|
||||
"@tensamin/ui": "*",
|
||||
"@tensamin/user": "workspace:*",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"sonner": "^2.0.7",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
}
|
||||
160
packages/tauth/src/context.tsx
Normal file
160
packages/tauth/src/context.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useCrypto } from "@tensamin/crypto/context";
|
||||
import { useUser } from "@tensamin/user/context";
|
||||
import { useStorage } from "@tensamin/storage/context";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@tensamin/ui";
|
||||
import { useLocation } from "@tanstack/react-router";
|
||||
import { useDeeplinks } from "@tensamin/tauri/deeplinkHandler";
|
||||
import { useTTP } from "@tensamin/ttp";
|
||||
import { log, toast } from "@tensamin/shared/log";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export default function Wrapper({ children }: { children: ReactNode }) {
|
||||
const { getSharedSecret } = useCrypto();
|
||||
const { get } = useUser();
|
||||
const { load } = useStorage();
|
||||
const { send } = useTTP();
|
||||
const { decrypt } = useCrypto();
|
||||
const { searchStr } = useLocation();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [identifier, setIdentifier] = useState<string | null>(null);
|
||||
const [redirect, setRedirect] = useState<string | null>(null);
|
||||
|
||||
const { deeplinks } = useDeeplinks();
|
||||
|
||||
const authorizeApp = async () => {
|
||||
if (!identifier || !redirect) return;
|
||||
|
||||
try {
|
||||
// Get Data
|
||||
const user = await get(await load("user_id"));
|
||||
const app = await send("get_app", {
|
||||
app_identifier: identifier,
|
||||
});
|
||||
const sharedSecret = await getSharedSecret(
|
||||
await load("private_key"),
|
||||
user.public_key,
|
||||
app.data.app_public_key,
|
||||
);
|
||||
|
||||
// Get Challenge
|
||||
const res = await send("authenticate_app", {
|
||||
app_identifier: identifier,
|
||||
});
|
||||
const challenge = res.data.challenge;
|
||||
|
||||
// Solve Challenge
|
||||
const solvedChallenge = await decrypt(sharedSecret, challenge);
|
||||
|
||||
// Craft Redirect URL
|
||||
const finalUrl = new URL(redirect);
|
||||
finalUrl.searchParams.set("challenge", solvedChallenge);
|
||||
|
||||
const session = crypto.randomUUID();
|
||||
finalUrl.searchParams.set("session", session);
|
||||
|
||||
// Open Redirect URL
|
||||
window.open(finalUrl.toString(), "_blank");
|
||||
} catch (err) {
|
||||
toast("error", "Failed to authorize app");
|
||||
log(1, "tauth", "red", "Failed to authorize app", err);
|
||||
} finally {
|
||||
setDialogOpen(false);
|
||||
setLoading(false);
|
||||
setIdentifier(null);
|
||||
setRedirect(null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(searchStr);
|
||||
const identifier = params.get("identifier");
|
||||
const redirect = params.get("redirect");
|
||||
if (!identifier || !redirect) return;
|
||||
|
||||
// @eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
setIdentifier(identifier);
|
||||
setRedirect(redirect);
|
||||
setDialogOpen(true);
|
||||
}, [searchStr]);
|
||||
|
||||
useEffect(() => {
|
||||
deeplinks.forEach((link) => {
|
||||
if (link.startsWith("tensamin://authorize_app")) {
|
||||
const url = new URL(link);
|
||||
const identifier = url.searchParams.get("identifier");
|
||||
const redirect = url.searchParams.get("redirect");
|
||||
|
||||
if (identifier && redirect) {
|
||||
setIdentifier(identifier);
|
||||
setRedirect(redirect);
|
||||
setDialogOpen(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [deeplinks]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={(value) => {
|
||||
setDialogOpen(value);
|
||||
if (!value) {
|
||||
setIdentifier(null);
|
||||
setRedirect(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Allow this app to access your data?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This app is requesting access to your data. Please review the
|
||||
permissions and only grant access if you trust this app.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
setLoading(true);
|
||||
authorizeApp();
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" />
|
||||
Authorizing...
|
||||
</>
|
||||
) : (
|
||||
"Allow"
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setDialogOpen(false);
|
||||
setIdentifier(null);
|
||||
setRedirect(null);
|
||||
}}
|
||||
>
|
||||
Deny
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
12
packages/tauth/tsconfig.json
Normal file
12
packages/tauth/tsconfig.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
@ -4,18 +4,15 @@
|
|||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./core": "./src/core.ts",
|
||||
"./context": "./src/context.tsx",
|
||||
"./send": "./src/send.ts",
|
||||
"./values": "./src/values.ts"
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"format": "bunx prettier --write .",
|
||||
"lint": "eslint src --ext .ts,.tsx",
|
||||
"test": "bun test",
|
||||
"build": "bun run test && tsc -p tsconfig.json --noEmit"
|
||||
"build": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tensamin/ttp-core": "*",
|
||||
"@tanstack/react-router": "^1.0.0",
|
||||
"@tensamin/crypto": "workspace:*",
|
||||
"@tensamin/storage": "workspace:*",
|
||||
|
|
|
|||
11
packages/ttp/src/bun-test.d.ts
vendored
11
packages/ttp/src/bun-test.d.ts
vendored
|
|
@ -1,11 +0,0 @@
|
|||
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;
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -11,8 +11,8 @@ import {
|
|||
import { useCrypto } from "@tensamin/crypto/context";
|
||||
import { log } from "@tensamin/shared/log";
|
||||
import { useStorage } from "@tensamin/storage/context";
|
||||
import { createTransportClient, READY_STATE, type BoundSendFn } from "./core";
|
||||
import type { PushHandler } from "./core";
|
||||
import { createTransportClient, READY_STATE, type BoundSendFn } from "@tensamin/ttp-core";
|
||||
import type { PushHandler } from "@tensamin/ttp-core";
|
||||
import {
|
||||
PING_INTERVAL,
|
||||
RECONNECT_RESET,
|
||||
|
|
|
|||
|
|
@ -1,575 +0,0 @@
|
|||
import { describe, it, expect } from "bun:test";
|
||||
import { createTransportClient, type SchemaMap } from "./core";
|
||||
import {
|
||||
decodeCommunicationMessage,
|
||||
encodeCommunicationMessage,
|
||||
type TypedMessage,
|
||||
} from "./codec";
|
||||
import { z } from "zod";
|
||||
|
||||
type MockStreamWriter = {
|
||||
write: (chunk: Uint8Array) => Promise<void>;
|
||||
releaseLock: () => void;
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
|
||||
type MockStream = {
|
||||
getWriter: () => MockStreamWriter;
|
||||
};
|
||||
|
||||
type MockTransportInstance = {
|
||||
ready: Promise<void>;
|
||||
closed: Promise<void>;
|
||||
createUnidirectionalStream: () => Promise<MockStream>;
|
||||
incomingUnidirectionalStreams: ReadableStream<ReadableStream<Uint8Array>>;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
type MemoryStorage = {
|
||||
getItem: (key: string) => string | null;
|
||||
setItem: (key: string, value: string) => void;
|
||||
removeItem: (key: string) => void;
|
||||
clear: () => void;
|
||||
};
|
||||
|
||||
function ensureLocalStorage() {
|
||||
const globalWithStorage = globalThis as unknown as {
|
||||
localStorage?: MemoryStorage;
|
||||
};
|
||||
|
||||
if (globalWithStorage.localStorage) {
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
let incomingController: ReadableStreamDefaultController<
|
||||
ReadableStream<Uint8Array>
|
||||
> | null = null;
|
||||
const outgoingMessages: TypedMessage[] = [];
|
||||
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
readyResolve = resolve;
|
||||
});
|
||||
const closed = new Promise<void>((resolve) => {
|
||||
closedResolve = resolve;
|
||||
});
|
||||
|
||||
const writer: MockStreamWriter = {
|
||||
write: async () => {},
|
||||
releaseLock: () => {},
|
||||
close: async () => {},
|
||||
};
|
||||
|
||||
const stream: MockStream = {
|
||||
getWriter: () => writer,
|
||||
};
|
||||
|
||||
const incomingUnidirectionalStreams = new ReadableStream<
|
||||
ReadableStream<Uint8Array>
|
||||
>({
|
||||
start(controller) {
|
||||
incomingController = controller;
|
||||
},
|
||||
});
|
||||
|
||||
const transport: MockTransportInstance = {
|
||||
ready,
|
||||
closed,
|
||||
createUnidirectionalStream: async () => stream,
|
||||
incomingUnidirectionalStreams,
|
||||
close: () => {},
|
||||
};
|
||||
|
||||
class MockWebTransport implements MockTransportInstance {
|
||||
ready = transport.ready;
|
||||
closed = transport.closed;
|
||||
createUnidirectionalStream = transport.createUnidirectionalStream;
|
||||
incomingUnidirectionalStreams = transport.incomingUnidirectionalStreams;
|
||||
close = transport.close;
|
||||
}
|
||||
|
||||
const pushIncomingMessage = (message: TypedMessage) => {
|
||||
if (!incomingController) {
|
||||
throw new Error("Incoming stream controller is not ready");
|
||||
}
|
||||
|
||||
const frame = wrapForDecode(encodeCommunicationMessage(message));
|
||||
const incomingStream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(frame);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
incomingController.enqueue(incomingStream);
|
||||
};
|
||||
|
||||
writer.write = async (chunk: Uint8Array) => {
|
||||
outgoingMessages.push(decodeCommunicationMessage(chunk));
|
||||
};
|
||||
|
||||
return {
|
||||
readyResolve,
|
||||
closedResolve,
|
||||
MockWebTransport,
|
||||
outgoingMessages,
|
||||
pushIncomingMessage,
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function writeU32BigEndian(buffer: Uint8Array, offset: number, value: number) {
|
||||
new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).setUint32(
|
||||
offset,
|
||||
value,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
function wrapForDecode(body: Uint8Array) {
|
||||
const frame = new Uint8Array(body.byteLength + 4);
|
||||
writeU32BigEndian(frame, 0, body.byteLength);
|
||||
frame.set(body, 4);
|
||||
return frame;
|
||||
}
|
||||
|
||||
describe("Core Protocol", () => {
|
||||
describe("encodeCommunicationMessage", () => {
|
||||
it("encodes message with id", () => {
|
||||
const message: TypedMessage = {
|
||||
id: 123,
|
||||
type: "ping",
|
||||
data: {},
|
||||
};
|
||||
|
||||
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: {},
|
||||
};
|
||||
|
||||
const encoded = encodeCommunicationMessage(message);
|
||||
expect(encoded instanceof Uint8Array).toBe(true);
|
||||
});
|
||||
|
||||
it("encodes message with string data", () => {
|
||||
const message: TypedMessage = {
|
||||
id: 1,
|
||||
type: "message",
|
||||
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);
|
||||
});
|
||||
|
||||
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: {},
|
||||
};
|
||||
|
||||
const encoded = encodeCommunicationMessage(original);
|
||||
const decoded = decodeCommunicationMessage(wrapForDecode(encoded));
|
||||
|
||||
expect(decoded.type).toBe("error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTransportClient", () => {
|
||||
it("creates client with schemas", () => {
|
||||
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 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);
|
||||
});
|
||||
|
||||
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 client = createTransportClient(schemas);
|
||||
|
||||
await expectRejection(
|
||||
client.send("ping", {}),
|
||||
"Transport is not connected",
|
||||
);
|
||||
});
|
||||
|
||||
it("calls readyStateChange callback", async () => {
|
||||
const { readyResolve, MockWebTransport } = createMockWebTransport();
|
||||
const globalWithWebTransport = globalThis as unknown as {
|
||||
WebTransport?: new (url: string) => MockTransportInstance;
|
||||
};
|
||||
globalWithWebTransport.WebTransport = MockWebTransport;
|
||||
|
||||
const readyStateChanges: number[] = [];
|
||||
const client = createTransportClient(
|
||||
{},
|
||||
{
|
||||
onReadyStateChange: (state) => readyStateChanges.push(state),
|
||||
},
|
||||
);
|
||||
|
||||
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),
|
||||
},
|
||||
);
|
||||
|
||||
const connectPromise = client.connect("http://localhost:8000");
|
||||
readyResolve();
|
||||
await connectPromise;
|
||||
|
||||
const closePromise = client.close();
|
||||
closedResolve();
|
||||
await closePromise;
|
||||
|
||||
expect(closeEvents.length > 0).toBe(true);
|
||||
expect(closeEvents[closeEvents.length - 1].intentional).toBe(true);
|
||||
});
|
||||
|
||||
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 client = createTransportClient(schemas);
|
||||
const connectPromise = client.connect("http://localhost:8000");
|
||||
readyResolve();
|
||||
await connectPromise;
|
||||
|
||||
const sendPromise = client.send("ping", {});
|
||||
const closePromise = client.close();
|
||||
closedResolve();
|
||||
await closePromise;
|
||||
|
||||
await expectRejection(sendPromise);
|
||||
});
|
||||
|
||||
it("queues sends until the active request receives a response", async () => {
|
||||
const {
|
||||
readyResolve,
|
||||
pushIncomingMessage,
|
||||
outgoingMessages,
|
||||
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 client = createTransportClient(schemas);
|
||||
const connectPromise = client.connect("http://localhost:8000");
|
||||
readyResolve();
|
||||
await connectPromise;
|
||||
|
||||
const firstSend = client.send("ping", {});
|
||||
const secondSend = client.send("ping", {});
|
||||
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < 10 && outgoingMessages.length === 0;
|
||||
attempt += 1
|
||||
) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
expect(outgoingMessages.length).toBe(1);
|
||||
|
||||
pushIncomingMessage({
|
||||
id: outgoingMessages[0].id,
|
||||
type: "ping",
|
||||
data: {},
|
||||
});
|
||||
|
||||
await firstSend;
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < 10 && outgoingMessages.length === 1;
|
||||
attempt += 1
|
||||
) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
expect(outgoingMessages.length).toBe(2);
|
||||
|
||||
pushIncomingMessage({
|
||||
id: outgoingMessages[1].id,
|
||||
type: "ping",
|
||||
data: {},
|
||||
});
|
||||
|
||||
await secondSend;
|
||||
});
|
||||
});
|
||||
|
||||
describe("Push subscriptions", () => {
|
||||
it("subscribes and unsubscribes from push events", () => {
|
||||
const client = createTransportClient({});
|
||||
|
||||
const handler = () => {};
|
||||
const unsubscribe = client.subscribePush(handler);
|
||||
|
||||
expect(typeof unsubscribe).toBe("function");
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Data type encoding", () => {
|
||||
it("encodes boolean true", () => {
|
||||
const message: TypedMessage = {
|
||||
id: 1,
|
||||
type: "message",
|
||||
data: { signed: true },
|
||||
};
|
||||
|
||||
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 },
|
||||
};
|
||||
|
||||
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));
|
||||
|
||||
expect(decoded.data.user_id).toBe(42);
|
||||
expect(decoded.data.sender_id).toBe(100);
|
||||
});
|
||||
|
||||
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));
|
||||
|
||||
expect(decoded.data.status).toBe(null);
|
||||
});
|
||||
|
||||
it("encodes arrays of numbers", () => {
|
||||
const message: TypedMessage = {
|
||||
id: 1,
|
||||
type: "message",
|
||||
data: { user_ids: [1, 2, 3] },
|
||||
};
|
||||
|
||||
const encoded = encodeCommunicationMessage(message);
|
||||
const decoded = decodeCommunicationMessage(wrapForDecode(encoded));
|
||||
|
||||
expect(decoded.data.user_ids).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("encodes nested containers", () => {
|
||||
const message: TypedMessage = {
|
||||
id: 1,
|
||||
type: "message",
|
||||
data: { user: { username: "alice", display: "Alice" } },
|
||||
};
|
||||
|
||||
const encoded = encodeCommunicationMessage(message);
|
||||
const decoded = decodeCommunicationMessage(wrapForDecode(encoded));
|
||||
|
||||
expect(decoded.data.user).toEqual({
|
||||
username: "alice",
|
||||
display: "Alice",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
3
packages/ttp/src/index.ts
Normal file
3
packages/ttp/src/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from "@tensamin/ttp-core";
|
||||
export * from "./context";
|
||||
export * from "./values";
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import * as React from "react";
|
||||
import { useTTP } from "@tensamin/ttp/context";
|
||||
import { useTTP } from "@tensamin/ttp";
|
||||
|
||||
import { ttp as schemas } from "@tensamin/shared/data";
|
||||
import type z from "zod";
|
||||
|
|
|
|||
Loading…
Reference in a new issue