Add template

This commit is contained in:
Alois 2026-07-29 15:28:20 +02:00
commit 5800a5ebbf
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
27 changed files with 7866 additions and 1 deletions

1
.envrc Normal file
View file

@ -0,0 +1 @@
use flake

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules/
dist/
.vite/
.direnv/
result

4
.prettierignore Normal file
View file

@ -0,0 +1,4 @@
apps/web/dist/
apps/web/src/routeTree.gen.ts
node_modules/
result

15
LICENSE Normal file
View file

@ -0,0 +1,15 @@
Copyright (c) 2025 Methanium
All rights reserved.
No part of this software, source code, documentation, or
associated materials may be copied, reproduced, modified,
distributed, published, sublicensed, sold, or used to create
derivative works without prior written permission from the
copyright holder.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY
OF ANY KIND, EXPRESS OR IMPLIED. TO THE MAXIMUM
EXTENT PERMITTED BY LAW, THE COPYRIGHT HOLDER SHALL
NOT BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER
LIABILITY ARISING FROM THE SOFTWARE OR ITS USE.

View file

@ -1,2 +1,51 @@
# template # Methanium Template
A pnpm React template with Vite, Tailwind CSS, Methanium UI, MTP, TanStack Router, and a base nix flake.
## Development
```sh
nix develop
pnpm install
pnpm dev
```
## Configure MTP
1. Define the host's communication and data types in `apps/web/type-maps.yaml`.
2. Set the connection options in `apps/web/src/mtp-options.ts`.
The reusable provider and `useMTP` hook are in `packages/mtp/`.
## Nix
Build the static frontend:
```sh
nix build
```
To serve it with nginx on NixOS, import the included module:
```nix
{
inputs.methanium-template.url =
"git+https://git.methanium.net/methanium/template";
outputs = {nixpkgs, methanium-template, ...}: {
nixosConfigurations.host = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
methanium-template.nixosModules.default
{
services.methanium-template = {
enable = true;
hostName = "app.example.com";
openFirewall = true;
};
}
];
};
};
}
```

38
apps/web/index.html Normal file
View file

@ -0,0 +1,38 @@
<!doctype html>
<html lang="en" class="dark" data-theme="methanium">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#171e26" />
<title>Methanium Vite Template</title>
<style>
html.dark,
html.dark body {
background-color: #171e26;
color: #d2e0f0;
color-scheme: dark;
}
html.light,
html.light body {
background-color: #edf1f4;
color: #10161d;
color-scheme: light;
}
</style>
<script>
const savedTheme = localStorage.getItem("theme");
const theme =
savedTheme === "light" ||
(savedTheme === "system" &&
matchMedia("(prefers-color-scheme: light)").matches)
? "light"
: "dark";
document.documentElement.classList.replace("dark", theme);
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

28
apps/web/package.json Normal file
View file

@ -0,0 +1,28 @@
{
"name": "@methanium/template-web",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@methanium/template-mtp": "workspace:*",
"@methanium/ui": "*",
"@tanstack/react-router": "^1.131.35",
"lucide-react": "^1.21.0",
"mtp": "*",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.1",
"@tanstack/router-plugin": "^1.131.35",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.4",
"tailwindcss": "^4.3.1",
"vite": "^7.1.12"
}
}

View file

@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" width="4441.4" height="6468.8" shape-rendering="geometricPrecision" viewBox="0 0 4441 6469">
<path fill="#331c4e" d="m1714 6033-33-248-5-676 69-1110-247-2069-388-302 196 3614Z"/>
<path fill="#6a438a" d="m1864 2038-214 3093 31 640 820-1486 125-2262Z"/>
<path fill="#291641" d="m3130 1586-504 437-125 2262 305 315Z"/>
<path fill="#0e0619" d="m2501 4285 305 315-120 1018-384 830h-56Z"/>
<path fill="#211136" d="m1762 6196 247 254 237-2 255-2163-822 1476 17 221Z"/>
<path fill="#3f245b" d="m740 4907 255-267 719 1393 48 163-340-112Z"/>
<path fill="#05020d" d="m1407 6080-147 198 414 172h335l-247-254Z"/>
<path fill="#030106" d="m1273 6283-85 173 486-6Z"/>
<path fill="#0a0413" d="m10 5229 297-41 966 1095-85 173-100 3Z"/>
<path fill="#5b3878" d="m740 4907 682 1177-149 199-966-1095Z"/>
<path fill="#794f9a" d="M159 4369 10 5229l297-41Z"/>
<path fill="#b984c5" d="m307 5188 433-281-581-538Z"/>
<path fill="#d4abda" d="m159 4369 581 538 255-267Z"/>
<path d="m2686 5604 312 564-179 263-517 17Z"/>
<path fill="#130922" d="m2686 5604 294-421 588-268 48 443-618 810Z"/>
<path fill="#975dac" d="m2686 5604 1115-1590-379-274-666 1279Z"/>
<path fill="#6a438a" d="m2980 5183 588-268 511-814-429-152Z"/>
<path fill="#190c2b" d="m4079 4101 352 111-815 1146-48-443Z"/>
<path fill="#f0d7f2" d="m2045 11-547 1919 366 108 762-15 70-59Z"/>
<path fill="#a558aa" d="m2045 11 651 1953 434-378Z"/>
<path fill="#b06ab6" d="m2045 11-547 1919-388-302Z"/>
<path fill="#885ca8" d="m1498 1930 238 1992 128-1884Z"/>
<path fill="#c698d0" d="m3650 3949 429 152 273-790Z"/>
<path fill="#4c2d68" d="m4079 4101 352 111-79-901Z"/>
<path fill="#e2c1e6" d="m3422 3740 228 209 702-638Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -0,0 +1,57 @@
import { Moon, Sun } from "lucide-react";
import { Button, useTheme } from "@methanium/ui";
const homepageUrl = "https://methanium.net";
export function Header() {
const { resolvedPolarity, setTheme } = useTheme();
const isDark = resolvedPolarity === "dark";
return (
<header className="sticky top-0 z-40 h-12 border-b border-sidebar-border bg-sidebar text-sidebar-foreground shadow-sm">
<div className="flex h-full items-center justify-between overflow-x-auto px-[10px]">
<Button
nativeButton={false}
variant="ghost"
size="sm"
className="mr-1 shrink-0 text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
style={{ width: "46.59px", height: "36px", padding: "3px 13px" }}
render={<a href="/" aria-label="Methanium home" />}
>
<img src="/methanium.svg" alt="" className="h-8 w-6 object-contain" />
</Button>
<nav
className="mr-auto flex h-full shrink-0 items-center"
aria-label="Primary"
>
<Button
nativeButton={false}
variant="ghost"
size="sm"
className="h-9 text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
render={<a href={homepageUrl} />}
>
Homepage
</Button>
</nav>
<div className="flex h-full items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
onClick={() => setTheme(isDark ? "light" : "dark")}
>
{isDark ? (
<Sun className="size-3.5" />
) : (
<Moon className="size-3.5" />
)}
</Button>
</div>
</div>
</header>
);
}

32
apps/web/src/main.tsx Normal file
View file

@ -0,0 +1,32 @@
import "./styles.css";
import { BUILT_IN_THEMES, ThemeProvider } from "@methanium/ui";
import { Provider as MTPProvider } from "@methanium/template-mtp";
import { createRouter, RouterProvider } from "@tanstack/react-router";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { routeTree } from "./routeTree.gen";
import { mtpOptions } from "./mtp-options.ts";
const router = createRouter({ routeTree });
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ThemeProvider
defaultTheme="dark"
themes={BUILT_IN_THEMES}
defaultParentThemeId="methanium"
>
<MTPProvider options={mtpOptions}>
<RouterProvider router={router} />
</MTPProvider>
</ThemeProvider>
</StrictMode>,
);

View file

@ -0,0 +1,13 @@
import type { MTPClientOptions } from "mtp";
// Replace null with your application's stable MTP connection configuration.
export const mtpOptions: MTPClientOptions | null = null;
// Example:
// export const mtpOptions: MTPClientOptions = {
// url: "https://mtp.example.com:4433",
// credentials: { clientId: 1, keyring: new Uint8Array([...]) },
// hostPublicKey: "...",
// descriptor: "web",
// pings: true,
// };

View file

@ -0,0 +1,59 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/'
fileRoutesByTo: FileRoutesByTo
to: '/'
id: '__root__' | '/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()

View file

@ -0,0 +1,17 @@
import { createRootRoute, Outlet } from "@tanstack/react-router";
import { Header } from "../components/layout/Header";
const EmptyPage = () => (
<main className="min-h-[calc(100vh-3rem)] bg-background" />
);
export const Route = createRootRoute({
component: () => (
<div className="min-h-screen bg-background text-foreground">
<Header />
<Outlet />
</div>
),
notFoundComponent: EmptyPage,
});

View file

@ -0,0 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/")({ component: Home });
function Home() {
return <main className="min-h-[calc(100vh-3rem)] bg-background" />;
}

2
apps/web/src/styles.css Normal file
View file

@ -0,0 +1,2 @@
@import "tailwindcss";
@import "@methanium/ui/index.css";

10
apps/web/type-maps.yaml Normal file
View file

@ -0,0 +1,10 @@
protocol_version: "1.0"
type_maps:
"1.0":
CommunicationTypes:
ExampleRequest: 32
ExampleResponse: 33
DataTypes:
Message: 32
ReceivedAt: 33

28
apps/web/vite.config.ts Normal file
View file

@ -0,0 +1,28 @@
import { methaniumUi } from "@methanium/ui/vite";
import { tanstackRouter } from "@tanstack/router-plugin/vite";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { mtp } from "mtp/vite";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig, type Plugin } from "vite";
const root = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [
tanstackRouter({ target: "react", autoCodeSplitting: true }),
tailwindcss(),
react(),
methaniumUi({ defaultThemeId: "methanium" }),
mtp({ typeMaps: "./type-maps.yaml" }) as Plugin,
],
resolve: {
alias: {
"@methanium/template-mtp": path.resolve(
root,
"../../packages/mtp/index.ts",
),
},
},
});

62
flake.lock generated Normal file
View file

@ -0,0 +1,62 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1785090369,
"narHash": "sha256-m0pDuRJG7EDo9ri+4Ksu83VsI+PlxNC9lNBfydejce4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "624af665418d3c65d544145b4d34ad696439570e",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1744536153,
"narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1785302874,
"narHash": "sha256-fpKEww3TJoo1ANHO2q918ei+ayOrp0YEQAO1DuBLOB4=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "b99d48435bc3e34309d2c7ae6f7d45e77a156c38",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

211
flake.nix Normal file
View file

@ -0,0 +1,211 @@
{
description = "Methanium Template";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
rust-overlay.url = "github:oxalica/rust-overlay";
};
outputs =
inputs@{
self,
nixpkgs,
rust-overlay,
...
}:
let
systems = [
"aarch64-darwin"
"aarch64-linux"
"x86_64-darwin"
"x86_64-linux"
];
forAllSystems = nixpkgs.lib.genAttrs systems;
pkgsFor =
system:
import nixpkgs {
inherit system;
overlays = [ rust-overlay.overlays.default ];
};
in
{
packages = forAllSystems (
system:
let
pkgs = pkgsFor system;
root = toString ./.;
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
targets = [ "wasm32-unknown-unknown" ];
};
source = pkgs.lib.cleanSourceWith {
src = ./.;
filter =
path: _type:
let
relative = pkgs.lib.removePrefix "${root}/" (toString path);
in
!(pkgs.lib.hasPrefix "node_modules/" relative)
&& !(pkgs.lib.hasPrefix "apps/web/node_modules/" relative)
&& !(pkgs.lib.hasPrefix "apps/web/dist/" relative)
&& relative != "node_modules"
&& relative != "apps/web/node_modules"
&& relative != "apps/web/dist"
&& relative != "result";
};
nativeBuildInputs = [
pkgs.cacert
pkgs.git
pkgs.nodejs
pkgs.pnpm
pkgs.wasm-pack
rustToolchain
];
webDeps = pkgs.stdenvNoCC.mkDerivation {
pname = "methanium-template-web-deps";
version = "0.1.0";
src = source;
nativeBuildInputs = [
pkgs.cacert
pkgs.git
pkgs.nodejs
pkgs.pnpm
pkgs.wasm-pack
rustToolchain
];
outputHashAlgo = "sha256";
outputHashMode = "recursive";
outputHash = "sha256-ihavd0hJDptdyEtjWjVgghwAl/rn6aUdQ9CT3B4if3E=";
dontFixup = true;
__structuredAttrs = true;
unsafeDiscardReferences.out = true;
buildPhase = ''
runHook preBuild
export HOME="$TMPDIR/home"
export CARGO_HOME="$TMPDIR/cargo"
mkdir -p "$HOME"
pnpm install --frozen-lockfile
patchShebangs node_modules
pnpm build
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p "$out/apps/web"
cp -a node_modules "$out/node_modules"
cp -a apps/web/node_modules "$out/apps/web/node_modules"
cp -a "$CARGO_HOME" "$out/cargo"
cp -a "$HOME/.cache/.wasm-pack" "$out/wasm-pack"
runHook postInstall
'';
};
web = pkgs.stdenvNoCC.mkDerivation {
pname = "methanium-template-web";
version = "0.1.0";
src = source;
inherit nativeBuildInputs;
buildPhase = ''
runHook preBuild
cp -a ${webDeps}/node_modules .
cp -a ${webDeps}/apps/web/node_modules apps/web/node_modules
cp -a ${webDeps}/cargo "$TMPDIR/cargo"
chmod -R u+w node_modules apps/web/node_modules "$TMPDIR/cargo"
export HOME="$TMPDIR/home"
export CARGO_HOME="$TMPDIR/cargo"
export CARGO_NET_OFFLINE=true
mkdir -p "$HOME/.cache"
cp -a ${webDeps}/wasm-pack "$HOME/.cache/.wasm-pack"
chmod -R u+w "$HOME/.cache/.wasm-pack"
pnpm build
runHook postBuild
'';
installPhase = ''
runHook preInstall
cp -r apps/web/dist "$out"
runHook postInstall
'';
};
in
{
default = web;
web = web;
}
);
devShells = forAllSystems (
system:
let
pkgs = pkgsFor system;
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
extensions = [ "rust-src" ];
targets = [ "wasm32-unknown-unknown" ];
};
in
{
default = pkgs.mkShell {
packages = [
pkgs.nodejs
pkgs.pnpm
pkgs.wasm-pack
rustToolchain
];
};
}
);
checks = forAllSystems (system: {
inherit (self.packages.${system}) web;
});
nixosModules.default =
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.methanium-template;
in
{
options.services.methanium-template = {
enable = lib.mkEnableOption "the Methanium frontend";
package = lib.mkOption {
type = lib.types.package;
default = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
defaultText = lib.literalExpression "self.packages.${pkgs.stdenv.hostPlatform.system}.default";
description = "Static frontend package to serve.";
};
hostName = lib.mkOption {
type = lib.types.str;
default = "localhost";
description = "nginx virtual host name.";
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to open TCP port 80 in the firewall.";
};
};
config = lib.mkIf cfg.enable {
services.nginx = {
enable = true;
virtualHosts.${cfg.hostName} = {
root = cfg.package;
locations."/".tryFiles = "$uri $uri/ /index.html";
};
};
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ 80 ];
};
};
};
}

20
package.json Normal file
View file

@ -0,0 +1,20 @@
{
"name": "methanium-template",
"private": true,
"type": "module",
"packageManager": "pnpm@11.17.0",
"scripts": {
"dev": "pnpm --filter @methanium/template-web dev",
"build": "pnpm --filter @methanium/template-web build",
"check": "tsc -p tsconfig.json",
"lint": "oxlint apps/web/src packages/mtp",
"fmt": "prettier --write .",
"fmt:check": "prettier --check ."
},
"devDependencies": {
"oxlint": "^1.28.0",
"prettier": "^3.6.2",
"typescript": "^5.9.3",
"@types/node": "^24.0.0"
}
}

357
packages/mtp/context.tsx Normal file
View file

@ -0,0 +1,357 @@
import { toast } from "@methanium/ui";
import {
ConnectionState,
MTPClient,
type MTPClientOptions,
type MTPRequestOptions,
} from "mtp";
import {
createContext,
type JSX,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { RECONNECT_RESET, RECONNECT_TRIES, RETRY_INTERVAL } from "./values.ts";
export type ProtocolMessage = {
id?: number;
type: string;
data: unknown;
sender?: bigint;
receiver?: bigint;
};
export type BoundSendFn = (
type: string,
data?: Record<string, unknown>,
options?: MTPRequestOptions,
) => Promise<ProtocolMessage>;
export type PushHandler = (message: ProtocolMessage) => void;
export type MTPExchange = {
type: string;
data: Record<string, unknown>;
response: ProtocolMessage;
};
export type MTPInterceptor = (exchange: MTPExchange) => void | Promise<void>;
type Subscription = {
type: string;
handler: PushHandler;
unsubscribe?: () => void;
};
type ClientWaiter = {
resolve: (client: MTPClient) => void;
reject: (error: Error) => void;
};
type ContextType = {
send: BoundSendFn;
subscribe: (type: string, handler: PushHandler) => () => void;
addInterceptor: (interceptor: MTPInterceptor) => () => void;
reconnect: () => void;
readyState: number;
connected: boolean;
authenticated: boolean;
contextReady: boolean;
loadingDescription: string;
error: Error | null;
};
const MTPContext = createContext<ContextType | undefined>(undefined);
export type MTPProviderProps = {
children: ReactNode;
options: MTPClientOptions | null;
authenticate?: boolean;
blockConnection?: boolean;
};
export function Provider({
children,
options,
authenticate = true,
blockConnection = false,
}: MTPProviderProps): JSX.Element {
const [readyState, setReadyState] = useState<number>(
ConnectionState.Disconnected,
);
const [authenticated, setAuthenticated] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [reconnectGeneration, setReconnectGeneration] = useState(0);
const clientRef = useRef<MTPClient | null>(null);
const readyRef = useRef(false);
const waitersRef = useRef(new Set<ClientWaiter>());
const subscriptionsRef = useRef(new Set<Subscription>());
const interceptorsRef = useRef(new Set<MTPInterceptor>());
const unbindSubscriptions = useCallback(() => {
for (const subscription of subscriptionsRef.current) {
subscription.unsubscribe?.();
subscription.unsubscribe = undefined;
}
}, []);
const bindSubscriptions = useCallback((client: MTPClient) => {
for (const subscription of subscriptionsRef.current) {
subscription.unsubscribe?.();
subscription.unsubscribe = client.subscribe(
subscription.type,
(message) => subscription.handler(message as ProtocolMessage),
);
}
}, []);
useEffect(() => {
if (!options || blockConnection) {
readyRef.current = false;
setReadyState(ConnectionState.Disconnected);
setAuthenticated(false);
return;
}
const connectionOptions = options;
let disposed = false;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectResetTimer: ReturnType<typeof setTimeout> | null = null;
let attempts = 0;
let connectionGeneration = 0;
const disconnectClient = () => {
readyRef.current = false;
unbindSubscriptions();
const activeClient = clientRef.current;
clientRef.current = null;
activeClient?.disconnect();
setReadyState(ConnectionState.Disconnected);
setAuthenticated(false);
};
const scheduleReconnect = (connectionError: Error) => {
if (disposed || reconnectTimer) return;
setError(connectionError);
if (attempts >= RECONNECT_TRIES) {
toast.error("MTP connection failed", {
id: "mtp-connection",
description: connectionError.message,
duration: Infinity,
closeButton: true,
});
return;
}
attempts += 1;
toast.loading(`Reconnecting to MTP (${attempts}/${RECONNECT_TRIES})`, {
id: "mtp-connection",
});
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
void connect();
}, RETRY_INTERVAL);
};
async function connect() {
if (disposed) return;
const generation = ++connectionGeneration;
let client: MTPClient | null = null;
try {
disconnectClient();
setError(null);
setReadyState(ConnectionState.Connecting);
await MTPClient.init(connectionOptions.wasm);
client = await MTPClient.create({
...connectionOptions,
logger: (event) => {
connectionOptions.logger?.(event);
if (disposed || generation !== connectionGeneration) return;
if ((event as { type: string }).type !== "state") return;
const nextState = client?.state ?? ConnectionState.Disconnected;
setReadyState(nextState);
if (
client !== null &&
nextState === ConnectionState.Disconnected &&
clientRef.current === client
) {
disconnectClient();
scheduleReconnect(new Error("MTP connection lost"));
}
},
});
if (disposed || generation !== connectionGeneration) {
client.disconnect();
return;
}
clientRef.current = client;
setReadyState(client.state);
if (authenticate) {
if (!client.credentials || client.credentials.clientId === null) {
throw new Error("MTP credentials are required for authentication");
}
await client.auth();
} else {
await client.connect();
}
if (disposed || clientRef.current !== client) return;
readyRef.current = true;
setReadyState(client.state);
setAuthenticated(true);
setError(null);
bindSubscriptions(client);
for (const waiter of waitersRef.current) waiter.resolve(client);
waitersRef.current.clear();
toast.dismiss("mtp-connection");
if (reconnectResetTimer) clearTimeout(reconnectResetTimer);
reconnectResetTimer = setTimeout(() => {
attempts = 0;
reconnectResetTimer = null;
}, RECONNECT_RESET * 1_000);
} catch (cause) {
if (disposed || generation !== connectionGeneration) {
client?.disconnect();
return;
}
disconnectClient();
scheduleReconnect(
cause instanceof Error ? cause : new Error(String(cause)),
);
}
}
void connect();
return () => {
disposed = true;
connectionGeneration += 1;
if (reconnectTimer) clearTimeout(reconnectTimer);
if (reconnectResetTimer) clearTimeout(reconnectResetTimer);
disconnectClient();
toast.dismiss("mtp-connection");
const cancellation = new Error("MTP provider was disconnected");
for (const waiter of waitersRef.current) waiter.reject(cancellation);
waitersRef.current.clear();
};
}, [
authenticate,
bindSubscriptions,
blockConnection,
options,
reconnectGeneration,
unbindSubscriptions,
]);
const getClient = useCallback(() => {
if (readyRef.current && clientRef.current) {
return Promise.resolve(clientRef.current);
}
if (!options || blockConnection) {
return Promise.reject(new Error("MTP connection is not configured"));
}
return new Promise<MTPClient>((resolve, reject) => {
waitersRef.current.add({ resolve, reject });
});
}, [blockConnection, options]);
const send = useCallback<BoundSendFn>(
async (type, data = {}, requestOptions) => {
const client = await getClient();
const response = await client.request(type, data, requestOptions);
const message = response as ProtocolMessage;
for (const interceptor of interceptorsRef.current) {
void Promise.resolve(
interceptor({ type, data, response: message }),
).catch((interceptorError) => {
console.error("MTP interceptor failed", interceptorError);
});
}
return message;
},
[getClient],
);
const subscribe = useCallback((type: string, handler: PushHandler) => {
const subscription: Subscription = { type, handler };
subscriptionsRef.current.add(subscription);
if (readyRef.current && clientRef.current) {
subscription.unsubscribe = clientRef.current.subscribe(type, (message) =>
handler(message as ProtocolMessage),
);
}
return () => {
subscription.unsubscribe?.();
subscriptionsRef.current.delete(subscription);
};
}, []);
const addInterceptor = useCallback((interceptor: MTPInterceptor) => {
interceptorsRef.current.add(interceptor);
return () => interceptorsRef.current.delete(interceptor);
}, []);
const reconnect = useCallback(() => {
setReconnectGeneration((generation) => generation + 1);
}, []);
const connected = readyState === ConnectionState.Connected;
const contextReady = connected && authenticated;
const loadingDescription = useMemo(() => {
if (!options) return "Waiting for connection configuration";
if (error) return error.message;
if (readyState === ConnectionState.Connecting || !connected) {
return "Establishing transport channel";
}
if (!authenticated) return "Waiting for authenticated session";
return "Connected";
}, [authenticated, connected, error, options, readyState]);
return (
<MTPContext.Provider
value={{
send,
subscribe,
addInterceptor,
reconnect,
readyState,
connected,
authenticated,
contextReady,
loadingDescription,
error,
}}
>
{children}
</MTPContext.Provider>
);
}
export function useMTP(): ContextType {
const context = useContext(MTPContext);
if (!context) throw new Error("useMTP must be used within an MTP Provider");
return context;
}

10
packages/mtp/index.ts Normal file
View file

@ -0,0 +1,10 @@
export { Provider, useMTP } from "./context.tsx";
export type {
BoundSendFn,
MTPExchange,
MTPInterceptor,
MTPProviderProps,
ProtocolMessage,
PushHandler,
} from "./context.tsx";
export { RECONNECT_RESET, RECONNECT_TRIES, RETRY_INTERVAL } from "./values.ts";

18
packages/mtp/package.json Normal file
View file

@ -0,0 +1,18 @@
{
"name": "@methanium/template-mtp",
"private": true,
"type": "module",
"exports": {
".": "./index.ts"
},
"peerDependencies": {
"@methanium/ui": "*",
"mtp": "*",
"react": "^19.0.0"
},
"devDependencies": {
"@methanium/ui": "*",
"mtp": "*",
"react": "^19.2.0"
}
}

3
packages/mtp/values.ts Normal file
View file

@ -0,0 +1,3 @@
export const RETRY_INTERVAL = 3_000;
export const RECONNECT_TRIES = 3;
export const RECONNECT_RESET = 6;

6759
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

12
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,12 @@
packages:
- apps/*
- packages/*
nodeLinker: hoisted
overrides:
"@methanium/ui": "https://git.methanium.net/methanium/ui/releases/download/0.0.12/methanium-ui.tgz"
mtp: "https://git.methanium.net/methanium/mtp/releases/download/0.2.0-dev-a692bed/mtp-0.2.0.tgz"
allowBuilds:
esbuild: true

21
tsconfig.json Normal file
View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"allowImportingTsExtensions": true,
"jsx": "react-jsx",
"lib": ["DOM", "DOM.Iterable", "ES2023"],
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true,
"paths": {
"@methanium/template-mtp": ["./packages/mtp/index.ts"],
"mtp/raw": ["./apps/web/node_modules/.vite/mtp/mtp_wasm.d.ts"],
"mtp/type-map": ["./apps/web/node_modules/.vite/mtp/mtp_type_map.d.ts"]
},
"skipLibCheck": true,
"strict": true,
"target": "ES2023",
"types": ["node"],
"useDefineForClassFields": true
},
"include": ["apps/web/src", "apps/web/vite.config.ts", "packages/mtp"]
}