diff --git a/apps/web/package.json b/apps/web/package.json index d3ae33a..bc29665 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -25,7 +25,6 @@ "@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 b59e9b4..d97b3b1 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"; +import { useTTP } from "@tensamin/ttp/context"; 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 ce9bb6b..636424c 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 (
- - - - - - - - - - - - - - + + +
@@ -143,11 +68,9 @@ function AppShell() { - - - - - + + + diff --git a/apps/web/src/routes/app/home.tsx b/apps/web/src/routes/app/home.tsx index a677a78..c7641b3 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"; +import { useTTP } from "@tensamin/ttp/context"; 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 new file mode 100644 index 0000000..5ba7bb9 --- /dev/null +++ b/apps/web/src/routes/layout.tsx @@ -0,0 +1,86 @@ +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/apps/web/tsconfig.app.json b/apps/web/tsconfig.app.json index a514d29..381e35c 100644 --- a/apps/web/tsconfig.app.json +++ b/apps/web/tsconfig.app.json @@ -24,6 +24,7 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true, + "baseUrl": ".", "paths": { "@/*": ["./src/*"] } diff --git a/bun.lock b/bun.lock index 2f835b8..7b237e3 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,6 @@ "": { "name": "tensamin", "dependencies": { - "@tensamin/ttp-core": "*", "@tensamin/ui": "*", }, "devDependencies": { @@ -56,7 +55,6 @@ "@tensamin/shared": "workspace:*", "@tensamin/storage": "workspace:*", "@tensamin/tauri": "workspace:*", - "@tensamin/tauth": "workspace:*", "@tensamin/ttp": "workspace:*", "@tensamin/ui": "*", "@tensamin/user": "workspace:*", @@ -169,7 +167,6 @@ "name": "@tensamin/shared", "version": "0.0.0", "dependencies": { - "@tensamin/ui": "*", "lucide-react": "^0.564.0", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -187,25 +184,6 @@ "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", @@ -214,7 +192,6 @@ "@tensamin/crypto": "workspace:*", "@tensamin/shared": "workspace:*", "@tensamin/storage": "workspace:*", - "@tensamin/ttp-core": "*", "@tensamin/ui": "*", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -238,8 +215,7 @@ }, }, "overrides": { - "@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", + "@tensamin/ui": "https://git.methanium.net/tensamin/ui/archive/0.0.20.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=="], @@ -332,8 +308,6 @@ "@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=="], @@ -492,42 +466,6 @@ "@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=="], @@ -584,12 +522,6 @@ "@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=="], @@ -686,13 +618,9 @@ "@tensamin/tauri": ["@tensamin/tauri@workspace:apps/tauri"], - "@tensamin/tauth": ["@tensamin/tauth@workspace:packages/tauth"], - "@tensamin/ttp": ["@tensamin/ttp@workspace:packages/ttp"], - "@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/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/user": ["@tensamin/user@workspace:packages/user"], @@ -708,24 +636,6 @@ "@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=="], @@ -744,8 +654,6 @@ "@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=="], @@ -790,8 +698,6 @@ "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=="], @@ -834,8 +740,6 @@ "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=="], @@ -870,40 +774,12 @@ "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=="], @@ -920,8 +796,6 @@ "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=="], @@ -936,12 +810,6 @@ "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=="], @@ -958,8 +826,6 @@ "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=="], @@ -990,8 +856,6 @@ "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=="], @@ -1060,8 +924,6 @@ "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=="], @@ -1070,7 +932,7 @@ "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], - "globals": ["globals@17.5.0", "", {}, "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g=="], + "globals": ["globals@17.4.0", "", {}, "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw=="], "globrex": ["globrex@0.1.2", "", {}, "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="], @@ -1102,18 +964,12 @@ "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=="], @@ -1360,32 +1216,12 @@ "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=="], @@ -1542,10 +1378,6 @@ "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=="], @@ -1556,10 +1388,6 @@ "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=="], @@ -1624,18 +1452,6 @@ "@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=="], @@ -1652,10 +1468,6 @@ "@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/eslint.config.ts b/eslint.config.ts index 2209bf9..61efd0d 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -3,10 +3,6 @@ import globals from "globals"; import tseslint from "typescript-eslint"; import reactHooks from "eslint-plugin-react-hooks"; import * as tsParser from "@typescript-eslint/parser"; -import { dirname } from "node:path"; -import { fileURLToPath } from "node:url"; - -const tsconfigRootDir = dirname(fileURLToPath(import.meta.url)); export default [ { @@ -18,9 +14,6 @@ export default [ files: ["**/*.{ts,tsx}"], languageOptions: { parser: tsParser, - parserOptions: { - tsconfigRootDir, - }, globals: { ...globals.browser, ...globals.node, diff --git a/package.json b/package.json index 00389ac..eda5245 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,6 @@ "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", @@ -34,11 +33,9 @@ "typescript-eslint": "^8.58.1" }, "overrides": { - "@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" + "@tensamin/ui": "https://git.methanium.net/tensamin/ui/archive/0.0.20.tar.gz" }, "dependencies": { - "@tensamin/ui": "*", - "@tensamin/ttp-core": "*" + "@tensamin/ui": "*" } } diff --git a/packages/call/src/context.tsx b/packages/call/src/context.tsx index fd49e61..d77029e 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"; +import { useTTP } from "@tensamin/ttp/context"; 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 f447988..7f45884 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"; +import { useTTP } from "@tensamin/ttp/context"; import { useCrypto } from "@tensamin/crypto/context"; import { log, toast } from "@tensamin/shared/log"; import Message from "./message"; @@ -74,7 +74,7 @@ export default function InputComponent({ const reference = addLiveMessage({ height, not_encrypted: true, - send_time: time, + timestamp: time, content: currentValue, sent_by_self: true, message_state: "awaiting", @@ -86,13 +86,13 @@ export default function InputComponent({ height, content: encryptedContext, receiver_id: userId, - send_time: time, + timestamp: time, }).catch((e) => { log(0, "Chat", "red", "Failed to send message", e, { content: currentValue, encryptedContext, receiver_id: userId, - send_time: time, + timestamp: time, }); reference.setFailed(true); toast("error", "Failed to send message"); @@ -108,7 +108,7 @@ export default function InputComponent({ className={cn( "rounded-none border-b-0 pt-0 pb-[env(safe-area-inset-bottom)]", isMobile - ? "border-0 border-t fixed w-full bottom-0 left-0 px-2" + ? "border-0 border-t-1 fixed w-full bottom-0 left-0 px-2" : "rounded-t-xl", )} > @@ -123,7 +123,7 @@ export default function InputComponent({ message={{ height: 0, not_encrypted: true, - send_time: 0, + timestamp: 0, content: value, sent_by_self: true, message_state: "awaiting", diff --git a/packages/chat/src/components/message.tsx b/packages/chat/src/components/message.tsx index 0e2e81f..6815e13 100644 --- a/packages/chat/src/components/message.tsx +++ b/packages/chat/src/components/message.tsx @@ -157,7 +157,7 @@ export default React.memo(MessageComponent, (prev, next) => { return ( prev.notEncrypted === next.notEncrypted && prev.measureRef === next.measureRef && - prev.message.send_time === next.message.send_time && + prev.message.timestamp === next.message.timestamp && prev.message.content === next.message.content && prev.message.height === next.message.height && prev.message.sent_by_self === next.message.sent_by_self && diff --git a/packages/chat/src/context.tsx b/packages/chat/src/context.tsx index 738211b..13a3a42 100644 --- a/packages/chat/src/context.tsx +++ b/packages/chat/src/context.tsx @@ -15,24 +15,24 @@ 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"; +import { useTTP } from "@tensamin/ttp/context"; import { log } from "@tensamin/shared/log"; export const context = createContext(undefined); const queryClient = new QueryClient(); -function updateMessageStateBySendTime< - T extends { send_time: number; message_state: RawMessage["message_state"] }, +function updateMessageStateByTimestamp< + T extends { timestamp: number; message_state: RawMessage["message_state"] }, >( messages: T[], - sendTime: number, + timestamp: number, messageState: RawMessage["message_state"], ): { next: T[]; updated: boolean } { let updated = false; const next = messages.map((item) => { - if (item.send_time !== sendTime || item.message_state === messageState) { + if (item.timestamp !== timestamp || item.message_state === messageState) { return item; } @@ -141,14 +141,14 @@ export default function Provider(props: { children: ReactNode }) { } const rawMessages = messages.data.messages; - const sorted = [...rawMessages].sort((a, b) => a.send_time - b.send_time); + const sorted = [...rawMessages].sort((a, b) => a.timestamp - b.timestamp); if (sorted.length > 0) { - const fetchedSendTimes = new Set(sorted.map((item) => item.send_time)); + const fetchedTimestamps = new Set(sorted.map((item) => item.timestamp)); setLiveMessagesState((prev) => { const filtered = prev.filter( - (liveMessage) => !fetchedSendTimes.has(liveMessage.send_time), + (liveMessage) => !fetchedTimestamps.has(liveMessage.timestamp), ); return filtered.length === prev.length ? prev : filtered; @@ -200,19 +200,19 @@ export default function Provider(props: { children: ReactNode }) { const rawData = message.data as { chat_partner_id: unknown; - send_time: unknown; + timestamp: unknown; message_state: RawMessage["message_state"]; }; const nextState = { chat_partner_id: Number(rawData.chat_partner_id), - send_time: Number(rawData.send_time), + timestamp: Number(rawData.timestamp), message_state: rawData.message_state, }; if ( !Number.isFinite(nextState.chat_partner_id) || - !Number.isFinite(nextState.send_time) + !Number.isFinite(nextState.timestamp) ) { log( 3, @@ -238,9 +238,9 @@ export default function Provider(props: { children: ReactNode }) { } setLiveMessagesState((prev) => { - const { next, updated } = updateMessageStateBySendTime( + const { next, updated } = updateMessageStateByTimestamp( prev, - nextState.send_time, + nextState.timestamp, nextState.message_state, ); return updated ? next : prev; @@ -257,9 +257,9 @@ export default function Provider(props: { children: ReactNode }) { let updated = false; const pages = current.pages.map((page) => { - const nextPage = updateMessageStateBySendTime( + const nextPage = updateMessageStateByTimestamp( page, - nextState.send_time, + nextState.timestamp, nextState.message_state, ); diff --git a/packages/chat/src/screen.tsx b/packages/chat/src/screen.tsx index 60e3f00..395e518 100644 --- a/packages/chat/src/screen.tsx +++ b/packages/chat/src/screen.tsx @@ -80,11 +80,11 @@ export default function Screen() { return liveMessagesSnapshot; } - const historicalSendTimes = new Set( - historicalMessages.map((message) => message.send_time), + const historicalTimestamps = new Set( + historicalMessages.map((message) => message.timestamp), ); const liveWithoutDuplicates = liveMessagesSnapshot.filter( - (message) => !historicalSendTimes.has(message.send_time), + (message) => !historicalTimestamps.has(message.timestamp), ); return [...historicalMessages, ...liveWithoutDuplicates]; @@ -97,7 +97,7 @@ export default function Screen() { }, [messages]); const getItemKey = React.useCallback((index: number) => { - return messagesRef.current[index]?.send_time ?? index; + return messagesRef.current[index]?.timestamp ?? index; }, []); // eslint-disable-next-line react-hooks/incompatible-library @@ -167,11 +167,7 @@ export default function Screen() { } setLastLiveMessageCount(count); - }, [ - lastLiveMessageCount, - liveMessagesSnapshot.length, - stickyBottomThreshold, - ]); + }, [lastLiveMessageCount, liveMessagesSnapshot.length, stickyBottomThreshold]); React.useLayoutEffect(() => { if ( @@ -295,7 +291,7 @@ export default function Screen() { return (
(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 deleted file mode 100644 index 9714625..0000000 --- a/packages/tauth/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "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 667efa0..46ede0c 100644 --- a/packages/ttp/package.json +++ b/packages/ttp/package.json @@ -4,15 +4,18 @@ "version": "0.0.0", "type": "module", "exports": { - ".": "./src/index.ts" + "./core": "./src/core.ts", + "./context": "./src/context.tsx", + "./send": "./src/send.ts", + "./values": "./src/values.ts" }, "scripts": { "format": "bunx prettier --write .", "lint": "eslint src --ext .ts,.tsx", - "build": "tsc -p tsconfig.json --noEmit" + "test": "bun test", + "build": "bun run test && 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 new file mode 100644 index 0000000..4ac4faa --- /dev/null +++ b/packages/ttp/src/bun-test.d.ts @@ -0,0 +1,11 @@ +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 new file mode 100644 index 0000000..b42d566 --- /dev/null +++ b/packages/ttp/src/codec.ts @@ -0,0 +1,1213 @@ +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 85421ca..4273f7f 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 "@tensamin/ttp-core"; -import type { PushHandler } from "@tensamin/ttp-core"; +import { createTransportClient, READY_STATE, type BoundSendFn } from "./core"; +import type { PushHandler } from "./core"; import { PING_INTERVAL, RECONNECT_RESET, @@ -155,7 +155,7 @@ const TTPContext = createContext(undefined); * @param props Component props with children. * @returns Loading, error, or provider-wrapped JSX. */ -export function Provider(props: { +export default function Provider(props: { children: ReactNode; blockConnection?: boolean; }) { diff --git a/packages/ttp/src/core.test.ts b/packages/ttp/src/core.test.ts new file mode 100644 index 0000000..6b5e63b --- /dev/null +++ b/packages/ttp/src/core.test.ts @@ -0,0 +1,575 @@ +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 new file mode 100644 index 0000000..588e409 --- /dev/null +++ b/packages/ttp/src/core.ts @@ -0,0 +1,1000 @@ +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 deleted file mode 100644 index 1279632..0000000 --- a/packages/ttp/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -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 1ab964b..72c3446 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"; +import { useTTP } from "@tensamin/ttp/context"; import { ttp as schemas } from "@tensamin/shared/data"; import type z from "zod"; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b2e1f1a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "module": "esnext", + "baseUrl": ".", + "paths": { + "@app/*": ["packages/*/src", "apps/*/src"] + } + } +}