From cbbadc9e67a5b419838a0fd7158096fc4abc034c Mon Sep 17 00:00:00 2001 From: Alois Date: Mon, 13 Apr 2026 17:26:15 +0200 Subject: [PATCH] feat(tauth): add tauth refactor(ttp): now using ttp-core package from git --- apps/web/package.json | 1 + apps/web/src/components/navbar.tsx | 2 +- apps/web/src/components/sidebar.tsx | 2 +- .../src/features/conversation/list/body.tsx | 2 +- apps/web/src/index.tsx | 93 +- apps/web/src/routes/app/home.tsx | 2 +- apps/web/src/routes/layout.tsx | 86 -- bun.lock | 194 ++- package.json | 7 +- packages/call/src/context.tsx | 2 +- packages/chat/src/components/input.tsx | 2 +- packages/chat/src/context.tsx | 4 +- packages/shared/package.json | 1 + packages/shared/src/data.ts | 17 + packages/shared/src/log.tsx | 2 +- packages/tauth/package.json | 29 + packages/tauth/src/context.tsx | 160 +++ packages/tauth/tsconfig.json | 12 + packages/ttp/package.json | 9 +- packages/ttp/src/bun-test.d.ts | 11 - packages/ttp/src/codec.ts | 1213 ----------------- packages/ttp/src/context.tsx | 4 +- packages/ttp/src/core.test.ts | 575 -------- packages/ttp/src/core.ts | 1000 -------------- packages/ttp/src/index.ts | 3 + packages/user/src/context.tsx | 2 +- 26 files changed, 519 insertions(+), 2916 deletions(-) delete mode 100644 apps/web/src/routes/layout.tsx create mode 100644 packages/tauth/package.json create mode 100644 packages/tauth/src/context.tsx create mode 100644 packages/tauth/tsconfig.json delete mode 100644 packages/ttp/src/bun-test.d.ts delete mode 100644 packages/ttp/src/codec.ts delete mode 100644 packages/ttp/src/core.test.ts delete mode 100644 packages/ttp/src/core.ts create mode 100644 packages/ttp/src/index.ts diff --git a/apps/web/package.json b/apps/web/package.json index bc29665..d3ae33a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -25,6 +25,7 @@ "@tensamin/storage": "workspace:*", "@tensamin/tauri": "workspace:*", "@tensamin/ttp": "workspace:*", + "@tensamin/tauth": "workspace:*", "@tensamin/ui": "*", "@tensamin/user": "workspace:*", "class-variance-authority": "^0.7.1", diff --git a/apps/web/src/components/navbar.tsx b/apps/web/src/components/navbar.tsx index d97b3b1..b59e9b4 100644 --- a/apps/web/src/components/navbar.tsx +++ b/apps/web/src/components/navbar.tsx @@ -13,7 +13,7 @@ import { import { displayCallId } from "@tensamin/call/utils"; import { useState } from "react"; import { SidebarTrigger, useSidebar } from "@tensamin/ui"; -import { useTTP } from "@tensamin/ttp/context"; +import { useTTP } from "@tensamin/ttp"; import { WindowControls as Controls } from "@tensamin/ui"; export default function Navbar({ forMobile }: { forMobile: boolean }) { diff --git a/apps/web/src/components/sidebar.tsx b/apps/web/src/components/sidebar.tsx index 636424c..ce9bb6b 100644 --- a/apps/web/src/components/sidebar.tsx +++ b/apps/web/src/components/sidebar.tsx @@ -23,7 +23,7 @@ export default function Sidebar() { const showMobileNavbar = useShowMobileNavbar(); return ( - + { location.reload(); }; +function LoginWrapper({ children }: { children: ReactNode }) { + const [loggedIn, setLoggedIn] = useState(null); + + const { load } = useStorage(); + + const navigate = useNavigate(); + const location = useLocation(); + + useEffect(() => { + let active = true; + + load("user_id").then((userId) => { + if (!active) { + return; + } + + if (userId !== 0) { + setLoggedIn(true); + return; + } + + setLoggedIn(false); + navigate({ + to: "/login", + }); + }); + + return () => { + active = false; + }; + }, [load, navigate]); + + if (loggedIn !== true && location.pathname !== "/login") { + return null; + } + + return children; +} + function RootShell() { + const isMobile = useIsMobile(); + return (
- - - + + + + + + + + + + + + + +
@@ -68,9 +143,11 @@ function AppShell() { - - - + + + + + diff --git a/apps/web/src/routes/app/home.tsx b/apps/web/src/routes/app/home.tsx index c7641b3..a677a78 100644 --- a/apps/web/src/routes/app/home.tsx +++ b/apps/web/src/routes/app/home.tsx @@ -11,7 +11,7 @@ import { } from "@tensamin/ui"; import z from "zod"; import { toast } from "@tensamin/shared/log"; -import { useTTP } from "@tensamin/ttp/context"; +import { useTTP } from "@tensamin/ttp"; import { useState } from "react"; import { Loader2 } from "lucide-react"; import { isTauri } from "@tauri-apps/api/core"; diff --git a/apps/web/src/routes/layout.tsx b/apps/web/src/routes/layout.tsx deleted file mode 100644 index 5ba7bb9..0000000 --- a/apps/web/src/routes/layout.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { useEffect, useState, type ReactNode } from "react"; - -import Storage from "@tensamin/storage/context"; -import Crypto from "@tensamin/crypto/context"; -import Mobile from "@tensamin/tauri/context"; - -import LegalWrapper from "@/features/legal/screen"; - -import { useStorage } from "@tensamin/storage/context"; -import { useLocation, useNavigate } from "@tanstack/react-router"; -import { useIsMobile, Toaster, TooltipProvider } from "@tensamin/ui"; -import { isTauri } from "@tauri-apps/api/core"; - -/** - * Executes Layout. - * @param props Parameter props. - * @returns unknown. - */ -export default function Layout(props: { children: ReactNode }) { - const isMobile = useIsMobile(); - - return ( - <> - - - - - - - {props.children} - - - - - - - ); -} - -function LoginWrapper({ children }: { children: ReactNode }) { - const [loggedIn, setLoggedIn] = useState(null); - - const { load } = useStorage(); - - const navigate = useNavigate(); - const location = useLocation(); - - useEffect(() => { - let active = true; - - load("user_id").then((userId) => { - if (!active) { - return; - } - - if (userId !== 0) { - setLoggedIn(true); - return; - } - - setLoggedIn(false); - navigate({ - to: "/login", - }); - }); - - return () => { - active = false; - }; - }, [load, navigate]); - - if (loggedIn !== true && location.pathname !== "/login") { - return null; - } - - return children; -} diff --git a/bun.lock b/bun.lock index 7b237e3..2f835b8 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "tensamin", "dependencies": { + "@tensamin/ttp-core": "*", "@tensamin/ui": "*", }, "devDependencies": { @@ -55,6 +56,7 @@ "@tensamin/shared": "workspace:*", "@tensamin/storage": "workspace:*", "@tensamin/tauri": "workspace:*", + "@tensamin/tauth": "workspace:*", "@tensamin/ttp": "workspace:*", "@tensamin/ui": "*", "@tensamin/user": "workspace:*", @@ -167,6 +169,7 @@ "name": "@tensamin/shared", "version": "0.0.0", "dependencies": { + "@tensamin/ui": "*", "lucide-react": "^0.564.0", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -184,6 +187,25 @@ "react-dom": "^19.2.0", }, }, + "packages/tauth": { + "name": "@tensamin/tauth", + "version": "0.0.0", + "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", + }, + }, "packages/ttp": { "name": "@tensamin/ttp", "version": "0.0.0", @@ -192,6 +214,7 @@ "@tensamin/crypto": "workspace:*", "@tensamin/shared": "workspace:*", "@tensamin/storage": "workspace:*", + "@tensamin/ttp-core": "*", "@tensamin/ui": "*", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -215,7 +238,8 @@ }, }, "overrides": { - "@tensamin/ui": "https://git.methanium.net/tensamin/ui/archive/0.0.20.tar.gz", + "@tensamin/ttp-core": "https://git.methanium.net/tensamin/ttp/archive/0.0.3.tar.gz", + "@tensamin/ui": "https://git.methanium.net/tensamin/ui/archive/0.0.25.tar.gz", }, "packages": { "@antfu/ni": ["@antfu/ni@25.0.0", "", { "dependencies": { "ansis": "^4.0.0", "fzf": "^0.5.2", "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" }, "bin": { "na": "bin/na.mjs", "ni": "bin/ni.mjs", "nr": "bin/nr.mjs", "nci": "bin/nci.mjs", "nlx": "bin/nlx.mjs", "nun": "bin/nun.mjs", "nup": "bin/nup.mjs" } }, "sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA=="], @@ -308,6 +332,8 @@ "@codemirror/view": ["@codemirror/view@6.41.0", "", { "dependencies": { "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-6H/qadXsVuDY219Yljhohglve8xf4B8xJkVOEWfA5uiYKiTFppjqsvsfR5iPA0RbvRBoOyTZpbLIxe9+0UR8xA=="], + "@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="], + "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.61.0", "", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.2", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-utL3cpZoFzflyqUkjYbxYujI6STBTmO5LFn4bbin/NZnRWN6wQ7eErhr3/Vpa5h/jicPFC6kTa42r940mQftJQ=="], "@ecies/ciphers": ["@ecies/ciphers@0.2.6", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g=="], @@ -466,6 +492,42 @@ "@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="], + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + + "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="], + + "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], + + "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="], + + "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], + + "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], + + "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], + + "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], + + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], + + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], + + "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], + + "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], + + "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="], + + "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + + "@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="], @@ -522,6 +584,12 @@ "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], + + "@tabby_ai/hijri-converter": ["@tabby_ai/hijri-converter@1.0.5", "", {}, "sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ=="], + "@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="], @@ -618,9 +686,13 @@ "@tensamin/tauri": ["@tensamin/tauri@workspace:apps/tauri"], + "@tensamin/tauth": ["@tensamin/tauth@workspace:packages/tauth"], + "@tensamin/ttp": ["@tensamin/ttp@workspace:packages/ttp"], - "@tensamin/ui": ["@tensamin/ui@https://git.methanium.net/tensamin/ui/archive/0.0.20.tar.gz", { "dependencies": { "@base-ui/react": "^1.3.0", "@fontsource-variable/inter": "^5.2.6", "@tauri-apps/api": "^2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.8.0", "next-themes": "^0.4.6", "shadcn": "^3.5.0", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.3.0" }, "peerDependencies": { "react": "^19.2.0", "react-dom": "^19.2.0" } }, "sha512-Ys0xJcKw191mE6waQ0rgGrnYN93BKXd4rAoM/CC3wbu3wYXhVLJeCkEfGWW2qNJd0MjrmqAEVgO0PBkFjD5FEQ=="], + "@tensamin/ttp-core": ["@tensamin/ttp-core@https://git.methanium.net/tensamin/ttp/archive/0.0.3.tar.gz", { "dependencies": { "@eslint/js": "^10.0.1", "@typescript-eslint/parser": "^8.58.1", "globals": "^17.5.0", "typescript": "^6.0.2", "typescript-eslint": "^8.58.1", "zod": "^4.3.6" } }, "sha512-fqpT3W/QeTULDYaZI2uXMHQ+6NUnNa4XTVbbl1k84vnwdzyQgdhezQUzjgXL1XrjEImb5CWosFNvcrzuIfwlfQ=="], + + "@tensamin/ui": ["@tensamin/ui@https://git.methanium.net/tensamin/ui/archive/0.0.25.tar.gz", { "dependencies": { "@base-ui/react": "^1.3.0", "@fontsource-variable/inter": "^5.2.6", "@tauri-apps/api": "^2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", "input-otp": "^1.4.2", "lucide-react": "^1.8.0", "next-themes": "^0.4.6", "react-day-picker": "^9.14.0", "react-resizable-panels": "^4.10.0", "recharts": "3.8.0", "shadcn": "^3.5.0", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.3.0", "vaul": "^1.1.2" }, "peerDependencies": { "react": "^19.2.0", "react-dom": "^19.2.0" } }, "sha512-pYkl4quhAhV/l/j1Yy5m4DDV5fh42H4G8v+FE6qEMCcJIOeA1LU7brIG7X9zURe7F4gyn1LQxMf2yABuV/NsMQ=="], "@tensamin/user": ["@tensamin/user@workspace:packages/user"], @@ -636,6 +708,24 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], + + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + + "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + + "@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="], + + "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + + "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + "@types/dom-mediacapture-record": ["@types/dom-mediacapture-record@1.0.22", "", {}, "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw=="], "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], @@ -654,6 +744,8 @@ "@types/statuses": ["@types/statuses@2.0.6", "", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="], + "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], + "@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="], "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.58.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/type-utils": "8.58.1", "@typescript-eslint/utils": "8.58.1", "@typescript-eslint/visitor-keys": "8.58.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.58.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ=="], @@ -698,6 +790,8 @@ "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], @@ -740,6 +834,8 @@ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="], + "code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="], "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], @@ -774,12 +870,40 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + + "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], + + "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + + "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], + + "date-fns-jalali": ["date-fns-jalali@4.1.0-0", "", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="], + "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], + "dedent": ["dedent@1.7.2", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA=="], "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], @@ -796,6 +920,8 @@ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], + "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], "dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="], @@ -810,6 +936,12 @@ "electron-to-chromium": ["electron-to-chromium@1.5.335", "", {}, "sha512-q9n5T4BR4Xwa2cwbrwcsDJtHD/enpQ5S1xF1IAtdqf5AAgqDFmR/aakqH3ChFdqd/QXJhS3rnnXFtexU7rax6Q=="], + "embla-carousel": ["embla-carousel@8.6.0", "", {}, "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA=="], + + "embla-carousel-react": ["embla-carousel-react@8.6.0", "", { "dependencies": { "embla-carousel": "8.6.0", "embla-carousel-reactive-utils": "8.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA=="], + + "embla-carousel-reactive-utils": ["embla-carousel-reactive-utils@8.6.0", "", { "peerDependencies": { "embla-carousel": "8.6.0" } }, "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A=="], + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], @@ -826,6 +958,8 @@ "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + "es-toolkit": ["es-toolkit@1.45.1", "", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="], + "esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -856,6 +990,8 @@ "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], @@ -924,6 +1060,8 @@ "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], + "get-own-enumerable-keys": ["get-own-enumerable-keys@1.0.0", "", {}, "sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA=="], "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], @@ -932,7 +1070,7 @@ "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], - "globals": ["globals@17.4.0", "", {}, "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw=="], + "globals": ["globals@17.5.0", "", {}, "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g=="], "globrex": ["globrex@0.1.2", "", {}, "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="], @@ -964,12 +1102,18 @@ "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="], + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "input-otp": ["input-otp@1.4.2", "", { "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA=="], + + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], @@ -1216,12 +1360,32 @@ "react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="], + "react-day-picker": ["react-day-picker@9.14.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "@tabby_ai/hijri-converter": "1.0.5", "date-fns": "^4.1.0", "date-fns-jalali": "4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA=="], + "react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="], + "react-is": ["react-is@19.2.5", "", {}, "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ=="], + + "react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="], + "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], + "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], + + "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], + + "react-resizable-panels": ["react-resizable-panels@4.10.0", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-frjewRQt7TCv/vCH1pJfjZ7RxAhr5pKuqVQtVgzFq/vherxBFOWyC3xMbryx5Ti2wylViGUFc93Etg4rB3E0UA=="], + + "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], + "recharts": ["recharts@3.8.0", "", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ=="], + + "redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="], + + "redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -1378,6 +1542,10 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], + + "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], "usehooks-ts": ["usehooks-ts@3.1.1", "", { "dependencies": { "lodash.debounce": "^4.0.8" }, "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA=="], @@ -1388,6 +1556,10 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="], + + "victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="], + "vite": ["vite@7.3.2", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg=="], "vite-tsconfig-paths": ["vite-tsconfig-paths@6.1.1", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" } }, "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg=="], @@ -1452,6 +1624,18 @@ "@modelcontextprotocol/sdk/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + "@radix-ui/react-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-dismissable-layer/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-focus-scope/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-portal/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], + + "@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], @@ -1468,6 +1652,10 @@ "@tensamin/tauri/lucide-react": ["lucide-react@1.8.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw=="], + "@tensamin/tauth/lucide-react": ["lucide-react@1.8.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw=="], + + "@tensamin/tauth/sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], + "@tensamin/ui/lucide-react": ["lucide-react@1.8.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw=="], "@tensamin/ui/shadcn": ["shadcn@3.8.5", "", { "dependencies": { "@antfu/ni": "^25.0.0", "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "https-proxy-agent": "^7.0.6", "kleur": "^4.1.5", "msw": "^2.10.4", "node-fetch": "^3.3.2", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-jPRx44e+eyeV7xwY3BLJXcfrks00+M0h5BGB9l6DdcBW4BpAj4x3lVmVy0TXPEs2iHEisxejr62sZAAw6B1EVA=="], diff --git a/package.json b/package.json index eda5245..00389ac 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "build:packages": "bun scripts/build-packages.ts", "dev:web": "cd apps/web && bun dev", "build:web": "cd apps/web && bun run build", + "preview:web": "cd apps/web && bun run preview", "dev:mobile": "cd apps/tauri && bun dev:mobile", "build:mobile": "cd apps/tauri && bun run build:mobile", "dev:desktop": "cd apps/tauri && bun dev:desktop", @@ -33,9 +34,11 @@ "typescript-eslint": "^8.58.1" }, "overrides": { - "@tensamin/ui": "https://git.methanium.net/tensamin/ui/archive/0.0.20.tar.gz" + "@tensamin/ui": "https://git.methanium.net/tensamin/ui/archive/0.0.25.tar.gz", + "@tensamin/ttp-core": "https://git.methanium.net/tensamin/ttp/archive/0.0.3.tar.gz" }, "dependencies": { - "@tensamin/ui": "*" + "@tensamin/ui": "*", + "@tensamin/ttp-core": "*" } } diff --git a/packages/call/src/context.tsx b/packages/call/src/context.tsx index d77029e..fd49e61 100644 --- a/packages/call/src/context.tsx +++ b/packages/call/src/context.tsx @@ -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"; diff --git a/packages/chat/src/components/input.tsx b/packages/chat/src/components/input.tsx index 7f45884..774d575 100644 --- a/packages/chat/src/components/input.tsx +++ b/packages/chat/src/components/input.tsx @@ -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"; diff --git a/packages/chat/src/context.tsx b/packages/chat/src/context.tsx index 13a3a42..2002191 100644 --- a/packages/chat/src/context.tsx +++ b/packages/chat/src/context.tsx @@ -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(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(); diff --git a/packages/shared/package.json b/packages/shared/package.json index baeaa0d..67404fa 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -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", diff --git a/packages/shared/src/data.ts b/packages/shared/src/data.ts index 7de68ce..e32dca8 100644 --- a/packages/shared/src/data.ts +++ b/packages/shared/src/data.ts @@ -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({ diff --git a/packages/shared/src/log.tsx b/packages/shared/src/log.tsx index 3109c95..ebea635 100644 --- a/packages/shared/src/log.tsx +++ b/packages/shared/src/log.tsx @@ -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"; /** diff --git a/packages/tauth/package.json b/packages/tauth/package.json new file mode 100644 index 0000000..83fe33d --- /dev/null +++ b/packages/tauth/package.json @@ -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" + } +} diff --git a/packages/tauth/src/context.tsx b/packages/tauth/src/context.tsx new file mode 100644 index 0000000..e6ac17d --- /dev/null +++ b/packages/tauth/src/context.tsx @@ -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(null); + const [redirect, setRedirect] = useState(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 ( + <> + { + setDialogOpen(value); + if (!value) { + setIdentifier(null); + setRedirect(null); + } + }} + > + + + Allow this app to access your data? + + This app is requesting access to your data. Please review the + permissions and only grant access if you trust this app. + + + + + + + + + {children} + + ); +} diff --git a/packages/tauth/tsconfig.json b/packages/tauth/tsconfig.json new file mode 100644 index 0000000..9714625 --- /dev/null +++ b/packages/tauth/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src"] +} diff --git a/packages/ttp/package.json b/packages/ttp/package.json index 46ede0c..667efa0 100644 --- a/packages/ttp/package.json +++ b/packages/ttp/package.json @@ -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:*", diff --git a/packages/ttp/src/bun-test.d.ts b/packages/ttp/src/bun-test.d.ts deleted file mode 100644 index 4ac4faa..0000000 --- a/packages/ttp/src/bun-test.d.ts +++ /dev/null @@ -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; - }; -} diff --git a/packages/ttp/src/codec.ts b/packages/ttp/src/codec.ts deleted file mode 100644 index b42d566..0000000 --- a/packages/ttp/src/codec.ts +++ /dev/null @@ -1,1213 +0,0 @@ -const DATA_VALUE_KIND_BOOL_TRUE = 0x01; -const DATA_VALUE_KIND_BOOL_FALSE = 0x02; -const DATA_VALUE_KIND_NUMBER = 0x03; -const DATA_VALUE_KIND_STRING = 0x04; -const DATA_VALUE_KIND_ARRAY = 0x05; -const DATA_VALUE_KIND_CONTAINER = 0x06; -const DATA_VALUE_KIND_NULL = 0x07; - -const textEncoder = new TextEncoder(); -const textDecoder = new TextDecoder(); - -type PrimitiveDataKind = "bool" | "number" | "string" | "container" | "null"; -type DataKind = - | PrimitiveDataKind - | { array: PrimitiveDataKind | "container" | "null" }; - -const COMMUNICATION_TYPES = [ - "error", // 0 - "error_protocol", // 1 - "error_anonymous", // 2 - "error_internal", // 3 - "error_invalid_data", // 4 - "error_invalid_user_id", // 5 - "error_invalid_omikron_id", // 6 - "error_not_found", // 7 - "error_not_authenticated", // 8 - "error_no_iota", // 9 - "error_invalid_challenge", // 10 - "error_invalid_secret", // 11 - "error_invalid_private_key", // 12 - "error_invalid_public_key", // 13 - "error_no_user_id", // 14 - "error_no_call_id", // 15 - "error_invalid_call_id", // 16 - "success", // 17 - "shorten_link", // 18 - "settings_save", // 19 - "settings_load", // 20 - "settings_list", // 21 - "message", // 22 - "message_state", // 23 - "message_send", // 24 - "message_live", // 25 - "message_other_iota", // 26 - "message_chunk", // 27 - "messages_get", // 28 - "push_notification", // 29 - "read_notification", // 30 - "get_notifications", // 31 - "change_confirm", // 32 - "confirm_receive", // 33 - "confirm_read", // 34 - "get_conversations", // 35 - "get_states", // 36 - "add_community", // 37 - "remove_community", // 38 - "get_communities", // 39 - "challenge", // 40 - "challenge_response", // 41 - "register", // 42 - "register_response", // 43 - "identification", // 44 - "identification_response", // 45 - "register_iota", // 46 - "register_iota_success", // 47 - "ping", // 48 - "pong", // 49 - "add_conversation", // 50 - "send_chat", // 51 - "client_changed", // 52 - "client_connected", // 53 - "client_disconnected", // 54 - "client_closed", // 55 - "public_key", // 56 - "private_key", // 57 - "webrtc_sdp", // 58 - "webrtc_ice", // 59 - "start_stream", // 60 - "end_stream", // 61 - "watch_stream", // 62 - "call_token", // 63 - "call_invite", // 64 - "call_disconnect_user", // 65 - "call_timeout_user", // 66 - "call_set_anonymous_joining", // 67 - "end_call", // 68 - "function", // 69 - "update", // 70 - "create_user", // 71 - "rho_update", // 72 - "user_connected", // 73 - "user_disconnected", // 74 - "iota_connected", // 75 - "iota_disconnected", // 76 - "sync_client_iota_status", // 77 - "get_user_data", // 78 - "get_iota_data", // 79 - "iota_user_data", // 80 - "change_user_data", // 81 - "change_iota_data", // 82 - "get_register", // 83 - "complete_register_user", // 84 - "complete_register_iota", // 85 - "delete_user", // 86 - "delete_iota", // 87 - "start_register", // 88 - "complete_register", // 89 -] as const; - -const DATA_TYPES = [ - "error_type", // 0 - "error_protocol", // 1 - "accepted_ids", // 2 - "uuid", // 3 - "register_id", // 4 - "link", // 5 - "settings", // 6 - "settings_name", // 7 - "chat_partner_id", // 8 - "chat_partner_name", // 9 - "iota_id", // 10 - "user_id", // 11 - "user_ids", // 12 - "iota_ids", // 13 - "user_state", // 14 - "user_states", // 15 - "user_pings", // 16 - "call_state", // 17 - "screen_share", // 18 - "private_key_hash", // 19 - "accepted", // 20 - "accepted_profiles", // 21 - "denied_profiles", // 22 - "content", // 23 - "messages", // 24 - "notifications", // 25 - "timestamp", // 26 - "get_time", // 27 - "get_variant", // 28 - "shared_secret_own", // 29 - "shared_secret_other", // 30 - "shared_secret_sign", // 31 - "shared_secret", // 32 - "call_id", // 33 - "call_token", // 34 - "untill", // 35 - "enabled", // 36 - "start_date", // 37 - "end_date", // 38 - "receiver_id", // 39 - "sender_id", // 40 - "signature", // 41 - "signed", // 42 - "message", // 43 - "message_state", // 44 - "last_ping", // 45 - "ping_iota", // 46 - "ping_clients", // 47 - "matches", // 48 - "omikron", // 49 - "offset", // 50 - "amount", // 51 - "position", // 52 - "name", // 53 - "path", // 54 - "codec", // 55 - "function", // 56 - "payload", // 57 - "result", // 58 - "interactables", // 59 - "want_to_watch", // 60 - "watcher", // 61 - "created_at", // 62 - "username", // 63 - "display", // 64 - "avatar", // 65 - "about", // 66 - "status", // 67 - "public_key", // 68 - "sub_level", // 69 - "sub_end", // 70 - "community_address", // 71 - "challenge", // 72 - "community_title", // 73 - "communities", // 74 - "rho_connections", // 75 - "user", // 76 - "online_status", // 77 - "omikron_id", // 78 - "omikron_connections", // 79 - "reset_token", // 80 - "new_token", // 81 - "call_invited", // 82 - "call_members", // 83 - "calls", // 84 - "timeout", // 85 - "has_admin", // 86 - "last_message_at", // 87 - "height", // 88 - "sent_by_self", // 89 - "session_id", // 90 - "contacts", // 91 - "last_message", // 92 - "version", // 93 -] as const; - -const COMMUNICATION_TYPE_BY_NAME = createIndexMap(COMMUNICATION_TYPES); -const DATA_TYPE_BY_NAME = createIndexMap(DATA_TYPES); -const SCALAR_NUMBER_ARRAY_DATA_TYPES = new Set(["last_ping", "ping_iota"]); - -const dataKindByType = new Map(); - -registerDataKinds("number", [ - "user_id", - "sender_id", - "register_id", - "receiver_id", - "call_id", - "amount", - "position", - "offset", - "timeout", - "iota_id", - "chat_partner_id", - "untill", - "start_date", - "end_date", - "omikron_id", - "timestamp", - "sub_level", - "sub_end", - "last_message_at", - "height", - "session_id", -]); - -registerDataKinds("string", [ - "error_type", - "username", - "display", - "avatar", - "about", - "public_key", - "message", - "content", - "path", - "codec", - "function", - "uuid", - "link", - "settings_name", - "chat_partner_name", - "user_state", - "call_state", - "private_key_hash", - "name", - "shared_secret_own", - "shared_secret_other", - "shared_secret_sign", - "shared_secret", - "message_state", - "signature", - "reset_token", - "new_token", - "call_token", - "challenge", - "online_status", - "version", -]); - -registerDataKinds({ array: "container" }, [ - "messages", - "communities", - "rho_connections", - "matches", - "get_conversations", - "get_communities", - "contacts", -]); - -registerDataKinds({ array: "number" }, [ - "notifications", - "iota_ids", - "user_ids", - "accepted_ids", - "last_ping", - "ping_iota", - "get_time", - "omikron_connections", -]); - -registerDataKinds("container", [ - "settings", - "user", - "payload", - "result", - "ping_clients", - "user_pings", - "last_message", -]); - -registerDataKinds("bool", [ - "enabled", - "signed", - "accepted", - "has_admin", - "screen_share", - "sent_by_self", -]); - -registerDataKinds({ array: "string" }, ["user_states"]); - -registerDataKinds("null", [ - "error_protocol", - "accepted_profiles", - "denied_profiles", - "get_variant", - "omikron", - "interactables", - "want_to_watch", - "watcher", - "created_at", - "status", - "community_address", - "community_title", - "call_invited", - "call_members", - "calls", -]); - -for (const dataType of DATA_TYPES) { - if (!dataKindByType.has(dataType)) { - throw new Error(`Missing data kind mapping for "${dataType}"`); - } -} - -export type TypedMessage> = { - id: number; - type: string; - data: TData; -}; - -/** - * Represents non-fatal payload decoding failures for individual protocol messages. - */ -export class RecoverableMessageDecodeError extends Error { - readonly messageId: number; - - readonly messageType: string; - - readonly cause: unknown; - - /** - * Creates a recoverable decode error associated with a specific message. - * @param messageId Protocol message id that failed to decode. - * @param messageType Protocol message type that failed to decode. - * @param cause Original decode failure cause. - */ - constructor(messageId: number, messageType: string, cause: unknown) { - super( - `Failed to decode message payload for "${messageType}" (id=${messageId}): ${formatUnknownError(cause)}`, - ); - this.name = "RecoverableMessageDecodeError"; - this.messageId = messageId; - this.messageType = messageType; - this.cause = cause; - } -} - -/** - * Formats unknown errors into a stable log string. - * @param error Unknown error value. - * @returns Human-readable error description. - */ -function formatUnknownError(error: unknown) { - if (error instanceof Error) { - return error.message; - } - - if (typeof error === "string") { - return error; - } - - try { - return JSON.stringify(error); - } catch { - return String(error); - } -} - -/** - * Checks whether a value is a non-null, non-array object. - * @param value Candidate value. - * @returns True when the value is a plain object. - */ -function isPlainObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -/** - * Encodes a typed protocol message into the wire communication format. - * @param message Typed message with id, type, and payload data. - * @returns Encoded communication frame bytes. - */ -export function encodeCommunicationMessage( - message: TypedMessage>, -) { - const typeIndex = parseCommunicationType(message.type); - const hasId = message.id !== 0; - const dataBuffer = encodeContainerPayload(message.data, "payload"); - const payloadLength = 2 + (hasId ? 4 : 0) + dataBuffer.byteLength; - const buffer = new Uint8Array(4 + payloadLength); - - writeU32(buffer, 0, payloadLength); - buffer[4] = typeIndex; - buffer[5] = hasId ? 0b0000_0100 : 0; - - let offset = 6; - if (hasId) { - writeU32(buffer, offset, message.id); - offset += 4; - } - - buffer.set(dataBuffer, offset); - - return buffer; -} - -/** - * Decodes communication frame bytes into a typed protocol message. - * @param frame Encoded communication frame bytes. - * @returns Decoded typed protocol message. - */ -export function decodeCommunicationMessage(frame: Uint8Array): TypedMessage { - const reader = new ByteReader(frame); - const frameLength = reader.readU32(); - if (frameLength !== frame.byteLength - 4) { - throw new Error( - `Communication frame length mismatch: expected ${frameLength}, received ${frame.byteLength - 4}`, - ); - } - - const payloadLength = reader.readU32(); - if (payloadLength !== frameLength - 4) { - throw new Error( - `Communication payload length mismatch: expected ${payloadLength}, received ${frameLength - 4}`, - ); - } - - const typeIndex = reader.readU8(); - const flags = reader.readU8(); - const hasSender = (flags & 0b0000_0001) !== 0; - const hasReceiver = (flags & 0b0000_0010) !== 0; - const hasId = (flags & 0b0000_0100) !== 0; - - const id = hasId ? reader.readU32() : 0; - if (hasSender) { - reader.readU48(); - } - if (hasReceiver) { - reader.readU48(); - } - - const consumedHeaderBytes = - 2 + (hasId ? 4 : 0) + (hasSender ? 6 : 0) + (hasReceiver ? 6 : 0); - if (consumedHeaderBytes > payloadLength) { - throw new Error("Communication header exceeds payload length"); - } - - const messageType = COMMUNICATION_TYPES[typeIndex] ?? "error_protocol"; - - const dataLength = payloadLength - consumedHeaderBytes; - let decodedData: Record; - - if (dataLength === 0) { - decodedData = {}; - } else { - const dataReader = new ByteReader(reader.readBytes(dataLength)); - - try { - decodedData = decodeContainerPayload(dataReader); - } catch (error) { - if (messageType.startsWith("error")) { - return { - id, - type: messageType, - data: {}, - }; - } else { - throw new RecoverableMessageDecodeError(id, messageType, error); - } - } - - if (!dataReader.isAtEnd()) { - throw new RecoverableMessageDecodeError( - id, - messageType, - new Error("Trailing bytes found after communication data payload"), - ); - } - } - - if (!reader.isAtEnd()) { - throw new Error("Trailing bytes found after communication payload"); - } - - return { - id, - type: messageType, - data: decodedData, - }; -} - -/** - * Builds a normalized lookup map for protocol names by index. - * @param values Ordered protocol names. - * @returns Map from normalized name to array index. - */ -function createIndexMap(values: readonly string[]) { - const map = new Map(); - - values.forEach((value, index) => { - map.set(normalizeName(value), index); - }); - - return map; -} - -/** - * Registers expected data kinds for protocol data type names. - * @param kind Expected scalar or array kind for the provided names. - * @param names Data type names to register. - * @returns Void. - */ -function registerDataKinds(kind: DataKind, names: readonly string[]) { - for (const name of names) { - if (dataKindByType.has(name)) { - throw new Error(`Duplicate data kind registration for "${name}"`); - } - - dataKindByType.set(name, kind); - } -} - -/** - * Normalizes protocol names by lowercasing and removing underscores. - * @param value Raw protocol name. - * @returns Normalized protocol key. - */ -function normalizeName(value: string) { - return value.toLowerCase().replaceAll("_", ""); -} - -/** - * Parses a protocol data key into its index and canonical name. - * @param type Raw protocol data key. - * @returns Canonical data key metadata with index and normalized name. - */ -function parseDataType(type: string) { - const index = DATA_TYPE_BY_NAME.get(normalizeName(type)); - if (index === undefined) { - throw new Error(`Unknown data type "${type}"`); - } - - return { - index, - name: DATA_TYPES[index], - }; -} - -/** - * Resolves a communication type string to its protocol index. - * @param type Protocol message type string. - * @returns Numeric protocol type index. - */ -function parseCommunicationType(type: string) { - const index = COMMUNICATION_TYPE_BY_NAME.get(normalizeName(type)); - if (index === undefined) { - throw new Error(`Unknown communication type "${type}"`); - } - - return index; -} - -/** - * Returns the expected value kind for a protocol data key. - * @param type Canonical data key name. - * @returns Expected data kind definition. - */ -function getExpectedKind(type: string) { - const kind = dataKindByType.get(type); - if (!kind) { - throw new Error(`No data kind registered for "${type}"`); - } - - return kind; -} - -type EncodedDataValue = { - kind: number; - payload: Uint8Array; -}; - -/** - * Encodes a value according to the expected protocol kind. - * @param kind Expected protocol kind. - * @param value Candidate value to encode. - * @param path Payload path used in validation errors. - * @returns Encoded value marker and payload bytes. - */ -function encodeDataValueForKind( - kind: DataKind, - value: unknown, - path: string, -): EncodedDataValue { - if (typeof kind === "object") { - return { - kind: DATA_VALUE_KIND_ARRAY, - payload: encodeArrayPayload(kind.array, value, path), - }; - } - - switch (kind) { - case "bool": - if (typeof value !== "boolean") { - throw new Error(`Expected boolean at "${path}"`); - } - - return { - kind: value ? DATA_VALUE_KIND_BOOL_TRUE : DATA_VALUE_KIND_BOOL_FALSE, - payload: new Uint8Array(0), - }; - - case "number": - return { - kind: DATA_VALUE_KIND_NUMBER, - payload: encodeNumberPayload(value, path), - }; - - case "string": - if (typeof value !== "string") { - throw new Error(`Expected string at "${path}"`); - } - - return { - kind: DATA_VALUE_KIND_STRING, - payload: textEncoder.encode(value), - }; - - case "container": - if (!isPlainObject(value)) { - throw new Error(`Expected object at "${path}"`); - } - - return { - kind: DATA_VALUE_KIND_CONTAINER, - payload: encodeContainerPayload(value, path), - }; - - case "null": - if (value !== null && value !== undefined) { - throw new Error(`Expected null at "${path}"`); - } - - return { - kind: DATA_VALUE_KIND_NULL, - payload: new Uint8Array(0), - }; - } -} - -/** - * Encodes a numeric payload as signed 64-bit big-endian bytes. - * @param value Value expected to be a safe integer number. - * @param path Payload path used in validation errors. - * @returns Encoded i64 byte array. - */ -function encodeNumberPayload(value: unknown, path: string) { - if ( - typeof value !== "number" || - !Number.isFinite(value) || - !Number.isSafeInteger(value) - ) { - throw new Error(`Expected safe integer at "${path}"`); - } - - const buffer = new Uint8Array(8); - writeI64(buffer, 0, value); - return buffer; -} - -/** - * Encodes an array payload where each item is tagged with a data marker. - * @param innerKind Expected kind for array entries. - * @param value Candidate array payload. - * @param path Payload path used in validation errors. - * @returns Encoded array payload bytes. - */ -function encodeArrayPayload( - innerKind: PrimitiveDataKind | "container" | "null", - value: unknown, - path: string, -) { - if (!Array.isArray(value)) { - throw new Error(`Expected array at "${path}"`); - } - - if (value.length > 0xffff) { - throw new Error(`Array at "${path}" is too large for protocol encoding`); - } - - const encodedItems = value.map((entry, index) => - encodeDataValueForKind(innerKind, entry, `${path}[${index}]`), - ); - - let totalLength = 2; - for (const encodedItem of encodedItems) { - totalLength += 1; - - if (!isBoolKindMarker(encodedItem.kind)) { - if (encodedItem.payload.byteLength > 0xffff) { - throw new Error( - `Array item at "${path}" is too large for protocol encoding`, - ); - } - - totalLength += 2 + encodedItem.payload.byteLength; - } - } - - const buffer = new Uint8Array(totalLength); - writeU16(buffer, 0, value.length); - - let offset = 2; - for (const item of encodedItems) { - buffer[offset] = item.kind; - offset += 1; - - if (isBoolKindMarker(item.kind)) { - continue; - } - - writeU16(buffer, offset, item.payload.byteLength); - offset += 2; - buffer.set(item.payload, offset); - offset += item.payload.byteLength; - } - - return buffer; -} - -/** - * Encodes a keyed container payload into protocol key-index/value entries. - * @param value Object payload to encode. - * @param path Payload path used in validation errors. - * @returns Encoded container payload bytes. - */ -function encodeContainerPayload(value: Record, path: string) { - const normalizedEntries = new Map< - string, - { index: number; value: unknown } - >(); - - for (const [rawKey, rawValue] of Object.entries(value)) { - const parsedType = parseDataType(rawKey); - normalizedEntries.set(parsedType.name, { - index: parsedType.index, - value: normalizeOutgoingValue(parsedType.name, rawValue), - }); - } - - const entries = [...normalizedEntries.entries()].sort( - ([, left], [, right]) => left.index - right.index, - ); - - if (entries.length > 0xffff) { - throw new Error( - `Container at "${path}" has too many entries for protocol encoding`, - ); - } - - const encodedEntries: Array<{ keyIndex: number; value: EncodedDataValue }> = - []; - let totalLength = 2; - - for (const [name, entry] of entries) { - const expectedKind = getExpectedKind(name); - const pathForEntry = `${path}.${name}`; - - const encodedValue = encodeDataValueForKind( - expectedKind, - entry.value, - pathForEntry, - ); - - if (isBoolKindMarker(encodedValue.kind)) { - totalLength += 2; - } else { - if (encodedValue.payload.byteLength > 0xffff) { - throw new Error( - `Container entry "${pathForEntry}" is too large for protocol encoding`, - ); - } - - totalLength += 4 + encodedValue.payload.byteLength; - } - - encodedEntries.push({ - keyIndex: entry.index, - value: encodedValue, - }); - } - - const buffer = new Uint8Array(totalLength); - writeU16(buffer, 0, entries.length); - - let offset = 2; - for (const entry of encodedEntries) { - buffer[offset] = entry.value.kind; - offset += 1; - - if (isBoolKindMarker(entry.value.kind)) { - buffer[offset] = entry.keyIndex; - offset += 1; - continue; - } - - writeU16(buffer, offset, entry.value.payload.byteLength); - offset += 2; - buffer[offset] = entry.keyIndex; - offset += 1; - buffer.set(entry.value.payload, offset); - offset += entry.value.payload.byteLength; - } - - return buffer; -} - -/** - * Decodes a value marker and payload bytes into a JavaScript value. - * @param marker Protocol value marker. - * @param payload Encoded payload bytes for the marker. - * @returns Decoded JavaScript value. - */ -function decodeValuePayload(marker: number, payload: Uint8Array): unknown { - const reader = new ByteReader(payload); - - switch (marker) { - case DATA_VALUE_KIND_BOOL_TRUE: - if (!reader.isAtEnd()) { - throw new Error("Unexpected payload for boolean true value"); - } - - return true; - - case DATA_VALUE_KIND_BOOL_FALSE: - if (!reader.isAtEnd()) { - throw new Error("Unexpected payload for boolean false value"); - } - - return false; - - case DATA_VALUE_KIND_NUMBER: - if (payload.byteLength !== 8) { - throw new Error(`Invalid number payload length ${payload.byteLength}`); - } - - return reader.readI64(); - - case DATA_VALUE_KIND_STRING: - return textDecoder.decode(payload); - - case DATA_VALUE_KIND_ARRAY: - return decodeArrayPayload(reader); - - case DATA_VALUE_KIND_CONTAINER: - return decodeContainerPayload(reader); - - case DATA_VALUE_KIND_NULL: - if (!reader.isAtEnd()) { - throw new Error("Unexpected payload for null value"); - } - - return null; - - default: - throw new Error(`Unknown data value marker 0x${marker.toString(16)}`); - } -} - -/** - * Decodes an encoded array payload from a byte reader. - * @param reader Byte reader positioned at array payload start. - * @returns Decoded array values. - */ -function decodeArrayPayload(reader: ByteReader) { - const itemCount = reader.readU16(); - const values: unknown[] = []; - - for (let index = 0; index < itemCount; index += 1) { - const marker = reader.readU8(); - - if (isBoolKindMarker(marker)) { - values.push(marker === DATA_VALUE_KIND_BOOL_TRUE); - continue; - } - - const payloadLength = reader.readU16(); - const payload = reader.readBytes(payloadLength); - values.push(decodeValuePayload(marker, payload)); - } - - return values; -} - -/** - * Decodes an encoded keyed container payload from a byte reader. - * @param reader Byte reader positioned at container payload start. - * @returns Decoded object payload. - */ -function decodeContainerPayload(reader: ByteReader) { - const entryCount = reader.readU16(); - const value: Record = {}; - - for (let index = 0; index < entryCount; index += 1) { - const marker = reader.readU8(); - const payloadLength = isBoolKindMarker(marker) ? 0 : reader.readU16(); - const keyIndex = reader.readU8(); - const payload = isBoolKindMarker(marker) - ? new Uint8Array(0) - : reader.readBytes(payloadLength); - - const key = getDataTypeNameByIndex(keyIndex); - if (!key) { - continue; - } - - const expectedKind = getExpectedKind(key); - if (!isMarkerCompatibleWithKey(marker, expectedKind, key)) { - throw new Error( - `Unexpected marker 0x${marker.toString(16)} for data type "${key}"`, - ); - } - - value[key] = normalizeIncomingValue( - key, - decodeValuePayload(marker, payload), - ); - } - - return value; -} - -/** - * Resolves a canonical data key by protocol index. - * @param index Protocol key index. - * @returns Canonical protocol data key name. - */ -function getDataTypeNameByIndex(index: number) { - return DATA_TYPES[index]; -} - -/** - * Checks whether a marker represents a boolean payload. - * @param marker Protocol value marker. - * @returns True when marker is boolean true or false. - */ -function isBoolKindMarker(marker: number) { - return ( - marker === DATA_VALUE_KIND_BOOL_TRUE || - marker === DATA_VALUE_KIND_BOOL_FALSE - ); -} - -/** - * Validates that a marker is compatible with an expected data kind. - * @param marker Protocol value marker. - * @param kind Expected protocol data kind. - * @returns True when marker matches the expected kind. - */ -function isMarkerCompatibleWithKind(marker: number, kind: DataKind) { - if (typeof kind === "object") { - return marker === DATA_VALUE_KIND_ARRAY; - } - - switch (kind) { - case "bool": - return isBoolKindMarker(marker); - case "number": - return marker === DATA_VALUE_KIND_NUMBER; - case "string": - return marker === DATA_VALUE_KIND_STRING; - case "container": - return marker === DATA_VALUE_KIND_CONTAINER; - case "null": - return marker === DATA_VALUE_KIND_NULL; - } -} - -/** - * Validates marker compatibility for a specific key with scalar-array fallback support. - * @param marker Protocol value marker. - * @param kind Expected protocol data kind. - * @param key Canonical protocol key name. - * @returns True when marker is compatible for the key. - */ -function isMarkerCompatibleWithKey( - marker: number, - kind: DataKind, - key: string, -) { - if ( - SCALAR_NUMBER_ARRAY_DATA_TYPES.has(key) && - marker === DATA_VALUE_KIND_NUMBER - ) { - return true; - } - - return isMarkerCompatibleWithKind(marker, kind); -} - -/** - * Normalizes outbound values for special scalar-array compatibility keys. - * @param type Canonical protocol key name. - * @param value Outbound value. - * @returns Normalized outbound value. - */ -function normalizeOutgoingValue(type: string, value: unknown) { - if (SCALAR_NUMBER_ARRAY_DATA_TYPES.has(type) && typeof value === "number") { - return [value]; - } - - return value; -} - -/** - * Normalizes inbound values for scalar-array compatibility keys. - * @param type Canonical protocol key name. - * @param value Inbound decoded value. - * @returns Normalized inbound value. - */ -function normalizeIncomingValue(type: string, value: unknown) { - if ( - SCALAR_NUMBER_ARRAY_DATA_TYPES.has(type) && - Array.isArray(value) && - value.length === 1 && - typeof value[0] === "number" - ) { - return value[0]; - } - - return value; -} - -/** - * Writes a big-endian unsigned 16-bit integer to a byte buffer. - * @param buffer Destination byte buffer. - * @param offset Byte offset to write at. - * @param value Unsigned 16-bit integer value. - * @returns Void. - */ -function writeU16(buffer: Uint8Array, offset: number, value: number) { - new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).setUint16( - offset, - value, - false, - ); -} - -/** - * Writes a big-endian unsigned 32-bit integer to a byte buffer. - * @param buffer Destination byte buffer. - * @param offset Byte offset to write at. - * @param value Unsigned 32-bit integer value. - * @returns Void. - */ -function writeU32(buffer: Uint8Array, offset: number, value: number) { - new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).setUint32( - offset, - value, - false, - ); -} - -/** - * Writes a big-endian signed 64-bit integer to a byte buffer. - * @param buffer Destination byte buffer. - * @param offset Byte offset to write at. - * @param value Signed integer value. - * @returns Void. - */ -function writeI64(buffer: Uint8Array, offset: number, value: number) { - new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).setBigInt64( - offset, - BigInt(value), - false, - ); -} - -/** - * Provides sequential big-endian reads over protocol byte buffers. - */ -class ByteReader { - private readonly view: DataView; - - private offset = 0; - - private readonly bytes: Uint8Array; - - /** - * Creates a byte reader over an immutable Uint8Array view. - * @param bytes Source bytes to read from. - */ - constructor(bytes: Uint8Array) { - this.bytes = bytes; - this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - } - - /** - * Reads one unsigned byte. - * @returns Unsigned 8-bit integer. - */ - readU8() { - this.ensureAvailable(1); - const value = this.view.getUint8(this.offset); - this.offset += 1; - return value; - } - - /** - * Reads two bytes as big-endian unsigned 16-bit integer. - * @returns Unsigned 16-bit integer. - */ - readU16() { - this.ensureAvailable(2); - const value = this.view.getUint16(this.offset, false); - this.offset += 2; - return value; - } - - /** - * Reads four bytes as big-endian unsigned 32-bit integer. - * @returns Unsigned 32-bit integer. - */ - readU32() { - this.ensureAvailable(4); - const value = this.view.getUint32(this.offset, false); - this.offset += 4; - return value; - } - - /** - * Reads eight bytes as big-endian signed 64-bit integer. - * @returns Safe integer representation of the decoded value. - */ - readI64() { - this.ensureAvailable(8); - const value = this.view.getBigInt64(this.offset, false); - this.offset += 8; - - const numberValue = Number(value); - if (!Number.isSafeInteger(numberValue)) { - throw new Error( - `Decoded number ${value.toString()} exceeds JS safe integer range`, - ); - } - - return numberValue; - } - - /** - * Reads six bytes as a big-endian unsigned 48-bit integer. - * @returns Unsigned 48-bit integer represented as number. - */ - readU48() { - this.ensureAvailable(6); - const upper = this.view.getUint16(this.offset, false); - const lower = this.view.getUint32(this.offset + 2, false); - this.offset += 6; - return upper * 2 ** 32 + lower; - } - - /** - * Reads a byte slice of the requested length. - * @param length Number of bytes to read. - * @returns View over the requested bytes. - */ - readBytes(length: number) { - this.ensureAvailable(length); - const value = this.bytes.subarray(this.offset, this.offset + length); - this.offset += length; - return value; - } - - /** - * Indicates whether all bytes have been consumed. - * @returns True when reader offset is at buffer end. - */ - isAtEnd() { - return this.offset === this.bytes.byteLength; - } - - /** - * Ensures that at least a specific number of bytes can still be read. - * @param length Required available byte count. - * @returns Void. - */ - private ensureAvailable(length: number) { - if (this.offset + length > this.bytes.byteLength) { - throw new Error("Unexpected end of protocol buffer"); - } - } -} diff --git a/packages/ttp/src/context.tsx b/packages/ttp/src/context.tsx index 4273f7f..13a13ef 100644 --- a/packages/ttp/src/context.tsx +++ b/packages/ttp/src/context.tsx @@ -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, diff --git a/packages/ttp/src/core.test.ts b/packages/ttp/src/core.test.ts deleted file mode 100644 index 6b5e63b..0000000 --- a/packages/ttp/src/core.test.ts +++ /dev/null @@ -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; - releaseLock: () => void; - close: () => Promise; -}; - -type MockStream = { - getWriter: () => MockStreamWriter; -}; - -type MockTransportInstance = { - ready: Promise; - closed: Promise; - createUnidirectionalStream: () => Promise; - incomingUnidirectionalStreams: ReadableStream>; - 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(); - 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 - > | null = null; - const outgoingMessages: TypedMessage[] = []; - - const ready = new Promise((resolve) => { - readyResolve = resolve; - }); - const closed = new Promise((resolve) => { - closedResolve = resolve; - }); - - const writer: MockStreamWriter = { - write: async () => {}, - releaseLock: () => {}, - close: async () => {}, - }; - - const stream: MockStream = { - getWriter: () => writer, - }; - - const incomingUnidirectionalStreams = new ReadableStream< - ReadableStream - >({ - 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({ - 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, - 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", - }); - }); - }); -}); diff --git a/packages/ttp/src/core.ts b/packages/ttp/src/core.ts deleted file mode 100644 index 588e409..0000000 --- a/packages/ttp/src/core.ts +++ /dev/null @@ -1,1000 +0,0 @@ -import { log } from "@tensamin/shared/log"; -import { z } from "zod"; - -import { - decodeCommunicationMessage, - encodeCommunicationMessage, - RecoverableMessageDecodeError, - type TypedMessage, -} from "./codec"; -import { RESPONSE_TIMEOUT } from "./values"; - -export const READY_STATE = { - CONNECTING: 0, - OPEN: 1, - CLOSING: 2, - CLOSED: 3, -} as const; - -const CLOSE_FRAME_LEN = 0xffff_ffff; -const APPLICATION_CLOSE_CODE = 0; -const APPLICATION_CLOSE_REASON = "ttp-close"; -const MAX_REQUEST_ID = 0xffff_fffe; -const BINARY_LOG_STORAGE_KEY = "ttp_logBinary"; - -type WebTransportCloseOptions = { - closeCode?: number; - reason?: string; -}; - -type WebTransportLike = { - ready: Promise; - closed: Promise; - createUnidirectionalStream(): Promise>; - incomingUnidirectionalStreams: ReadableStream>; - close(options?: WebTransportCloseOptions): void; -}; - -type WebTransportGlobal = typeof globalThis & { - WebTransport?: new (url: string) => WebTransportLike; -}; - -type PendingRequest = { - requestType: string; - resolve: (value: TypedMessage) => void; - reject: (reason: unknown) => void; - timeoutId: ReturnType; -}; - -type ActiveConnection = { - transport: WebTransportLike; - streamReader: ReadableStreamDefaultReader> | null; - intentional: boolean; - closeNotified: boolean; - acceptLoopDone: Promise | null; - resolveAcceptLoopDone: (() => void) | null; - activeIncomingTasks: Set>; - sendStream: WritableStream | null; - sendWriter: WritableStreamDefaultWriter | null; -}; - -export type { TypedMessage } from "./codec"; - -export type Message = TypedMessage; - -export type SchemaMap = Record< - string, - { request: z.ZodType; response: z.ZodType } ->; - -type SendOptions = { - id?: number; -}; - -export type BoundSendFn = { - ( - type: K, - data: z.input, - options?: { id?: number }, - ): Promise>>; -}; - -export type PushHandler> = ( - message: TypedMessage, -) => void; - -export type TransportCloseEvent = { - error?: unknown; - intentional: boolean; -}; - -type TransportClientOptions = { - url?: string; - onReadyStateChange?: (readyState: number) => void; - onClose?: (event: TransportCloseEvent) => void; -}; - -export type TransportClient = { - connect(url?: string): Promise; - close(reason?: string): Promise; - send: BoundSendFn; - readyState(): number; - subscribePush(handler: PushHandler): () => void; -}; - -/** - * Creates a typed transport client that validates request and response payloads. - * @param schemas Protocol schema map for request/response validation. - * @param options Optional transport lifecycle callbacks and default URL. - * @returns Transport client API for connect, close, send, and push subscriptions. - */ -export function createTransportClient( - schemas: T, - options: TransportClientOptions = {}, -): TransportClient { - const pending = new Map(); - const pushHandlers = new Set(); - - let currentConnection: ActiveConnection | null = null; - let currentReadyState: number = READY_STATE.CLOSED; - let nextRequestId = 1; - let sendQueueTail: Promise = Promise.resolve(); - let configuredUrl = options.url; - - /** - * Updates current ready state and emits lifecycle callbacks. - * @param readyState New transport ready state value. - * @returns Void. - */ - const setReadyState = (readyState: number) => { - currentReadyState = readyState; - options.onReadyStateChange?.(readyState); - }; - - /** - * Rejects all pending requests and clears timeout handles. - * @param reason Rejection reason applied to all pending requests. - * @returns Void. - */ - const rejectPending = (reason: unknown) => { - for (const [id, request] of pending) { - clearTimeout(request.timeoutId); - request.reject(reason); - pending.delete(id); - } - }; - - /** - * Finalizes closed state for a connection and notifies listeners. - * @param connection Closed connection object. - * @param error Optional close error. - * @returns Void. - */ - const notifyClosed = (connection: ActiveConnection, error?: unknown) => { - if (connection.closeNotified) { - return; - } - - connection.closeNotified = true; - - if (currentConnection === connection) { - currentConnection = null; - } - - if (currentReadyState !== READY_STATE.CLOSED) { - setReadyState(READY_STATE.CLOSED); - } - - rejectPending(error ?? new Error("Transport closed")); - options.onClose?.({ error, intentional: connection.intentional }); - }; - - /** - * Handles connection-level failures and routes them through close handling. - * @param connection Connection that failed. - * @param error Optional failure reason. - * @returns Void. - */ - const handleConnectionFailure = ( - connection: ActiveConnection, - error?: unknown, - ) => { - if (currentConnection !== connection && connection.closeNotified) { - return; - } - - notifyClosed(connection, error); - }; - - /** - * Serializes outbound send work so only one request is active at a time. - * @param task Request task to run in queue order. - * @returns Promise for the task result. - */ - const enqueueSend = (task: () => Promise) => { - const queuedTask = sendQueueTail.then(task, task); - sendQueueTail = queuedTask.then( - () => undefined, - () => undefined, - ); - return queuedTask; - }; - - /** - * Handles decoded incoming messages and resolves request promises or push listeners. - * @param message Decoded incoming message. - * @returns Void. - */ - const handleIncomingMessage = (message: TypedMessage) => { - if (message.type !== "pong") { - log(2, "ttp", "cyan", "Received: " + message.type, message.data, { - id: message.id, - }); - } - - if (message.id !== 0) { - const pendingRequest = pending.get(message.id); - - if (pendingRequest) { - clearTimeout(pendingRequest.timeoutId); - pending.delete(message.id); - - if (message.type.startsWith("error")) { - pendingRequest.reject(message); - return; - } - - const schema = schemas[pendingRequest.requestType]; - if (!schema) { - pendingRequest.resolve(message); - return; - } - - const result = schema.response.safeParse(message.data); - if (result.success) { - pendingRequest.resolve({ - ...message, - data: result.data as Record, - }); - return; - } - - log( - 0, - "ttp", - "red", - `Response validation failed for "${message.type}"`, - result.error, - message.data, - ); - pendingRequest.reject( - new Error( - `Response validation failed for "${message.type}": ${result.error.message}`, - ), - ); - return; - } - } - - const schema = schemas[message.type]; - if (schema) { - const result = schema.response.safeParse(message.data); - if (!result.success) { - log( - 0, - "ttp", - "red", - `Push-event validation failed for "${message.type}"`, - result.error, - message.data, - ); - return; - } - - message = { - ...message, - data: result.data as Record, - }; - } - - for (const handler of pushHandlers) { - handler(message); - } - }; - - /** - * Handles recoverable decode failures by rejecting only the affected request. - * @param error Recoverable decode error details. - * @returns Void. - */ - const handleRecoverableDecodeFailure = ( - error: RecoverableMessageDecodeError, - ) => { - log(1, "ttp", "yellow", "Recoverable message decode failure", { - id: error.messageId, - type: error.messageType, - error: error.message, - }); - - if (error.messageId === 0) { - return; - } - - const pendingRequest = pending.get(error.messageId); - if (!pendingRequest) { - return; - } - - clearTimeout(pendingRequest.timeoutId); - pending.delete(error.messageId); - pendingRequest.reject( - new Error( - `Failed to decode response for "${pendingRequest.requestType}": ${formatUnknownError(error.cause)}`, - ), - ); - }; - - /** - * Starts the incoming stream loop for a newly-opened connection. - * @param connection Active connection instance. - * @returns Void. - */ - const startIncomingLoop = (connection: ActiveConnection) => { - connection.streamReader = - connection.transport.incomingUnidirectionalStreams.getReader(); - connection.acceptLoopDone = new Promise((resolve) => { - connection.resolveAcceptLoopDone = resolve; - }); - - void (async () => { - try { - 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 shouldDiscardFrames = - connection.intentional || currentConnection !== connection; - - const task = (async () => { - try { - await processIncomingStream( - result.value, - connection, - handleIncomingMessage, - handleRecoverableDecodeFailure, - handleConnectionFailure, - shouldDiscardFrames, - ); - } catch (error) { - log(0, "ttp", "red", "Incoming transport stream failed", error); - handleConnectionFailure(connection, error); - } - })(); - - connection.activeIncomingTasks.add(task); - void task.finally(() => { - connection.activeIncomingTasks.delete(task); - }); - } - - if ( - !connection.intentional && - !connection.closeNotified && - currentConnection === connection - ) { - handleConnectionFailure( - connection, - new Error("Transport stream closed"), - ); - } - } catch (error) { - log(0, "ttp", "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?.(); - } - })(); - }; - - /** - * Awaits transport closed promise and forwards outcome to failure handling. - * @param connection Active connection instance. - * @returns Void. - */ - const awaitClosed = (connection: ActiveConnection) => { - void connection.transport.closed - .then(() => { - handleConnectionFailure(connection); - }) - .catch((error) => { - handleConnectionFailure(connection, error); - }); - }; - - /** - * Opens a transport connection and starts incoming frame processing. - * @param url Optional override transport URL. - * @returns Promise that resolves when connection setup completes. - */ - const connect = async (url = configuredUrl) => { - if (!url) { - throw new Error("Transport URL is not configured"); - } - - configuredUrl = url; - - if (currentConnection) { - await close("reconnect"); - } - - const WebTransportCtor = getWebTransportCtor(); - const transport = new WebTransportCtor(url); - const connection: ActiveConnection = { - transport, - streamReader: null, - intentional: false, - closeNotified: false, - acceptLoopDone: null, - resolveAcceptLoopDone: null, - activeIncomingTasks: new Set(), - sendStream: null, - sendWriter: null, - }; - - currentConnection = connection; - setReadyState(READY_STATE.CONNECTING); - awaitClosed(connection); - - try { - await transport.ready; - - if (currentConnection !== connection) { - return; - } - - log(1, "ttp", "green", "Connected"); - setReadyState(READY_STATE.OPEN); - startIncomingLoop(connection); - } catch (error) { - log(0, "ttp", "red", "WebTransport connection failed", error); - handleConnectionFailure(connection, error); - throw error; - } - }; - - /** - * Closes the current transport connection and sends a close sentinel frame. - * @param reason Close reason sent to transport. - * @returns Promise that resolves once close handling completes. - */ - const close = async (reason = APPLICATION_CLOSE_REASON) => { - const connection = currentConnection; - if (!connection) { - setReadyState(READY_STATE.CLOSED); - return; - } - - 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, "ttp", "yellow", "Failed to send close sentinel", error); - } - - try { - connection.transport.close({ - closeCode: APPLICATION_CLOSE_CODE, - reason, - }); - } catch { - // Ignore close errors during shutdown. - } - - try { - await connection.transport.closed.catch(() => undefined); - await acceptLoopDone; - if (connection.activeIncomingTasks.size > 0) { - await Promise.allSettled([...connection.activeIncomingTasks]); - } - } finally { - notifyClosed(connection); - } - }; - - /** - * Sends a typed protocol request over the current connection. - * @param type Protocol message type. - * @param input Optional request payload. - * @param options Optional request id. - * @returns Promise for the typed response message. - */ - const send: BoundSendFn = (( - type: string, - input?: Record, - options?: SendOptions, - ): Promise => { - if (!currentConnection || currentReadyState !== READY_STATE.OPEN) { - return Promise.reject(new Error("Transport is not connected")); - } - - try { - const schema = schemas[type]; - let payload: Record; - - if (schema) { - const result = schema.request.safeParse(input ?? {}); - if (!result.success) { - log( - 0, - "ttp", - "red", - `Request validation failed for "${type}"`, - result.error, - ); - return Promise.reject( - new Error( - `Request validation failed for "${type}": ${result.error.message}`, - ), - ); - } - - payload = coercePayload(result.data); - } else { - payload = coercePayload(input ?? {}); - } - - const requestOptions = options ? { ...options } : {}; - const enqueuedConnection = currentConnection; - - return enqueueSend(() => { - if ( - !enqueuedConnection || - currentConnection !== enqueuedConnection || - currentReadyState !== READY_STATE.OPEN || - enqueuedConnection.closeNotified - ) { - return Promise.reject(new Error("Transport is not connected")); - } - - const requestId = resolveRequestId( - requestOptions.id, - true, - pending, - () => { - const current = nextRequestId; - nextRequestId = current >= MAX_REQUEST_ID ? 1 : current + 1; - return current; - }, - ); - - if (type !== "ping") { - log(2, "ttp", "gray", "Sent: " + type, payload, { id: requestId }); - } - - const messageBytes = encodeCommunicationMessage({ - id: requestId, - type, - data: payload, - }); - - return new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { - pending.delete(requestId); - reject( - new Error( - `Request "${type}" timed out after ${RESPONSE_TIMEOUT}ms`, - ), - ); - }, RESPONSE_TIMEOUT); - - pending.set(requestId, { - requestType: type, - resolve, - reject, - timeoutId, - }); - - void writeMessageOnPersistentStream( - enqueuedConnection, - messageBytes, - ).catch((error) => { - handleConnectionFailure(enqueuedConnection, error); - clearTimeout(timeoutId); - pending.delete(requestId); - reject(error); - }); - }); - }); - } catch (error) { - return Promise.reject(error); - } - }) as BoundSendFn; - - return { - connect, - close, - send, - readyState: () => currentReadyState, - subscribePush(handler: PushHandler) { - pushHandlers.add(handler); - return () => { - pushHandlers.delete(handler); - }; - }, - }; -} - -/** - * Returns the WebTransport constructor from the current runtime. - * @returns WebTransport constructor. - */ -function getWebTransportCtor() { - const ctor = (globalThis as WebTransportGlobal).WebTransport; - if (!ctor) { - throw new Error("WebTransport is not available in this environment"); - } - - return ctor; -} - -/** - * Formats unknown errors into a stable log string. - * @param error Unknown error value. - * @returns Human-readable error description. - */ -function formatUnknownError(error: unknown) { - if (error instanceof Error) { - return error.message; - } - - if (typeof error === "string") { - return error; - } - - try { - return JSON.stringify(error); - } catch { - return String(error); - } -} - -/** - * Returns whether raw transport binary logging is enabled in local storage. - * @returns True when binary transport logs should be emitted. - */ -function isBinaryMessageLoggingEnabled() { - try { - return localStorage.getItem(BINARY_LOG_STORAGE_KEY) === "true"; - } catch { - return false; - } -} - -/** - * Logs raw transport bytes when binary logging is enabled. - * @param direction Message direction label. - * @param payload Raw binary payload to log. - * @returns Void. - */ -function logBinaryMessage( - direction: "Incoming" | "Outgoing", - payload: Uint8Array, -) { - if (!isBinaryMessageLoggingEnabled()) { - return; - } - - log(3, "ttp", "yellow", `${direction} ${payload.byteLength} bytes:`, payload); -} - -/** - * Ensures outbound message payloads are plain object records. - * @param value Candidate payload. - * @returns Payload as plain object record. - */ -function coercePayload(value: unknown): Record { - if (!isPlainObject(value)) { - throw new Error("Protocol payload must be a plain object"); - } - - return value; -} - -/** - * Checks whether a value is a non-null, non-array object. - * @param value Candidate value. - * @returns True when the value is a plain object. - */ -function isPlainObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -/** - * Resolves a unique request id for transport messages. - * @param requestedId Optional caller-provided request id. - * @param expectsResponse Whether the request expects a response. - * @param pending Map of currently pending requests. - * @param nextId Function that returns the next candidate id. - * @returns A request id valid for the current pending set. - */ -function resolveRequestId( - requestedId: number | undefined, - expectsResponse: boolean, - pending: Map, - nextId: () => number, -) { - if (requestedId !== undefined) { - validateRequestId(requestedId, expectsResponse); - if (expectsResponse && pending.has(requestedId)) { - throw new Error(`Request id ${requestedId} is already pending`); - } - - return requestedId; - } - - if (!expectsResponse) { - return 0; - } - - let attempts = 0; - let candidate = nextId(); - - while (candidate === 0 || pending.has(candidate)) { - candidate = nextId(); - attempts += 1; - - if (attempts > MAX_REQUEST_ID) { - throw new Error("Unable to allocate a free request id"); - } - } - - return candidate; -} - -/** - * Validates request id bounds and response semantics. - * @param id Request id to validate. - * @param expectsResponse Whether a response is expected for this request. - * @returns Void. - */ -function validateRequestId(id: number, expectsResponse: boolean) { - if (!Number.isInteger(id) || id < 0 || id > MAX_REQUEST_ID) { - throw new Error(`Request id must be a u32 between 0 and ${MAX_REQUEST_ID}`); - } - - if (expectsResponse && id === 0) { - throw new Error("Request id 0 cannot be used when a response is expected"); - } -} - -/** - * Writes a protocol message payload as a framed unidirectional transport stream. - * 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 writeMessageOnPersistentStream( - connection: ActiveConnection, - payload: Uint8Array, -) { - if (payload.byteLength >= CLOSE_FRAME_LEN) { - throw new Error("Message too large for transport frame"); - } - - const frame = new Uint8Array(4 + payload.byteLength); - writeU32(frame, 0, payload.byteLength); - frame.set(payload, 4); - - const writeAndCatch = async (): Promise => { - 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"); -} - -/** - * Writes a close sentinel frame to the transport. - * @param transport Active transport instance. - * @returns Promise that resolves when the close frame is written. - */ -async function writeCloseFrame(transport: WebTransportLike) { - const stream = await transport.createUnidirectionalStream(); - const writer = stream.getWriter(); - - try { - const frame = new Uint8Array(4); - writeU32(frame, 0, CLOSE_FRAME_LEN); - await writer.write(frame); - await writer.close(); - } finally { - writer.releaseLock(); - } -} - -/** - * Reads a byte stream and emits each framed protocol message it contains. - * @param stream Incoming byte stream for a single unidirectional transport stream. - * @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( - stream: ReadableStream, - connection: ActiveConnection, - 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; - let peerCloseDetected = false; - - try { - while (true) { - const { value, done } = await reader.read(); - if (done) { - break; - } - - bufferedBytes = appendBytes(bufferedBytes, value); - - if (discardFrames || peerCloseDetected) { - bufferedBytes = new Uint8Array(0) as Uint8Array; - continue; - } - - while (bufferedBytes.byteLength >= 4) { - const declaredLength = readU32(bufferedBytes, 0); - - if (declaredLength === CLOSE_FRAME_LEN) { - peerCloseDetected = true; - bufferedBytes = new Uint8Array(0) as Uint8Array; - - try { - connection.transport.close({ - closeCode: APPLICATION_CLOSE_CODE, - reason: APPLICATION_CLOSE_REASON, - }); - } catch { - // Ignore close errors during peer shutdown. - } - - handleStreamFailure( - connection, - new Error("Transport closed by peer"), - ); - break; - } - - const expectedLength = 4 + declaredLength; - if (bufferedBytes.byteLength < expectedLength) { - break; - } - - const frameBytes = bufferedBytes.subarray(0, expectedLength); - bufferedBytes = bufferedBytes.subarray(expectedLength); - - logBinaryMessage("Incoming", frameBytes); - - try { - handleIncomingFrame(decodeCommunicationMessage(frameBytes)); - } catch (error) { - if (error instanceof RecoverableMessageDecodeError) { - handleDecodeFailure(error); - } else { - throw error; - } - } - - // Just like the backend, drop the stream after receiving exactly one incoming message! - return peerCloseDetected; - } - } - - if (!discardFrames && !peerCloseDetected && bufferedBytes.byteLength > 0) { - throw new Error("Received truncated transport frame"); - } - - return peerCloseDetected; - } finally { - // We cancel the reader to signal the stream is naturally dropped, matching Rust's receiver behavior. - reader.cancel().catch(() => {}); - reader.releaseLock(); - } -} - -/** - * Concatenates two byte arrays. - * @param left Existing buffered bytes. - * @param right Newly received bytes. - * @returns Concatenated bytes. - */ -function appendBytes(left: Uint8Array, right: Uint8Array) { - if (left.byteLength === 0) { - return right; - } - - const buffer = new Uint8Array( - left.byteLength + right.byteLength, - ) as Uint8Array; - buffer.set(left, 0); - buffer.set(right, left.byteLength); - return buffer; -} - -/** - * Writes a big-endian unsigned 32-bit integer to a byte buffer. - * @param buffer Destination byte buffer. - * @param offset Byte offset to write at. - * @param value Unsigned 32-bit integer value. - * @returns Void. - */ -function writeU32(buffer: Uint8Array, offset: number, value: number) { - new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).setUint32( - offset, - value, - false, - ); -} - -/** - * Reads a big-endian unsigned 32-bit integer from a byte buffer. - * @param buffer Source byte buffer. - * @param offset Byte offset to read from. - * @returns Unsigned 32-bit integer value. - */ -function readU32(buffer: Uint8Array, offset: number) { - return new DataView( - buffer.buffer, - buffer.byteOffset, - buffer.byteLength, - ).getUint32(offset, false); -} diff --git a/packages/ttp/src/index.ts b/packages/ttp/src/index.ts new file mode 100644 index 0000000..1279632 --- /dev/null +++ b/packages/ttp/src/index.ts @@ -0,0 +1,3 @@ +export * from "@tensamin/ttp-core"; +export * from "./context"; +export * from "./values"; \ No newline at end of file diff --git a/packages/user/src/context.tsx b/packages/user/src/context.tsx index 72c3446..1ab964b 100644 --- a/packages/user/src/context.tsx +++ b/packages/user/src/context.tsx @@ -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";