From ad7e58570f442bb22fedf73c64ba768855255e47 Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Sat, 16 May 2026 20:37:57 -0400 Subject: [PATCH] feat: add fate vite integration --- .gitignore | 1 + CODEBASE_FINDINGS.md | 2 ++ app/server/fate.ts | 71 ++++++++++++++++++++++++++++++++++++++++++ app/server/hono-app.ts | 14 +++++++-- app/spa/app.tsx | 13 +++----- package.json | 2 +- tsconfig.json | 8 ++++- vite.config.ts | 10 +++++- 8 files changed, 107 insertions(+), 14 deletions(-) create mode 100644 app/server/fate.ts diff --git a/.gitignore b/.gitignore index eaba650..3a033fc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules +/.fate /.react-router /.cache /.data diff --git a/CODEBASE_FINDINGS.md b/CODEBASE_FINDINGS.md index 68f59e9..15fbb66 100644 --- a/CODEBASE_FINDINGS.md +++ b/CODEBASE_FINDINGS.md @@ -552,6 +552,8 @@ Do not add project-level wrappers such as `HeadplaneLivePublisher`, `HeadplaneDa - Added a plain Vite SPA `index.html` and `app/spa` entry with TanStack Router and a raw Fate client shell using `createClient` + `createHTTPTransport`. - Moved the old React Router Vite config to `vite-old.config.ts` and replaced `vite.config.ts` with a clean SPA config. - Added Hono and `@hono/node-server`, plus a minimal Hono server shell in `app/server/hono-app.ts`, `app/server/hono-dev.ts`, and `app/server/hono-main.ts`. +- Added `app/server/fate.ts`, which exports a raw Fate server and live bus. Hono mounts Fate's `createHonoFateHandler(fate)` at `/admin/fate` and `/admin/fate/*`, passing the existing app context through Hono variables. +- Wired the official `react-fate/vite` plugin to `app/server/fate.ts`, ignored generated `.fate/` output, and made `pnpm run typecheck` run `fate generate` before `tsgo` so generated `react-fate/client` typings exist from a clean checkout. - `pnpm dev` now runs the Hono/Vite middleware shell with local `.data` storage for the example config; `pnpm build` now runs `vite build` for the SPA. - Fate's Drizzle peer currently warns against the repo's Drizzle `1.0.0-beta.21`; avoid Fate's Drizzle adapter until that compatibility is resolved, and start with a direct Headscale source/resolver instead. diff --git a/app/server/fate.ts b/app/server/fate.ts new file mode 100644 index 0000000..742aa7f --- /dev/null +++ b/app/server/fate.ts @@ -0,0 +1,71 @@ +import { + createFateServer, + createLiveEventBus, + type SourceDefinition, + type SourceRegistry, +} from "@nkzw/fate/server"; +import type { Context } from "hono"; + +import type { AppContext } from "./context"; +import type { RuntimeApiClient } from "./headscale/api/endpoints"; +import type { Principal } from "./web/auth"; + +export interface HonoFateEnv { + Variables: { + appContext: AppContext; + }; +} + +interface FateContext { + api: RuntimeApiClient; + app: AppContext; + principal: Principal; + request: Request; +} + +type FateAdapterContext = Context; + +export const live = createLiveEventBus(); + +export const roots = {}; +const queries = {}; +const lists = {}; +const mutations = {}; + +const registry = new Map() as SourceRegistry; + +export const fate = createFateServer< + FateContext, + typeof roots, + typeof queries, + typeof lists, + typeof mutations, + FateAdapterContext +>({ + context: async ({ adapterContext, request }) => { + if (!adapterContext) { + throw new Error("Fate request is missing Hono context"); + } + + const app = adapterContext.get("appContext"); + const principal = await app.auth.require(request); + const api = app.hsApi.getRuntimeClient(app.auth.getHeadscaleApiKey(principal)); + + return { + api, + app, + principal, + request, + }; + }, + live, + roots, + sources: { + registry, + getSource(target) { + return target as SourceDefinition; + }, + }, +}); + +export type { FateAdapterContext, FateContext }; diff --git a/app/server/hono-app.ts b/app/server/hono-app.ts index c4bcfba..9aed4f6 100644 --- a/app/server/hono-app.ts +++ b/app/server/hono-app.ts @@ -1,9 +1,11 @@ import { versions } from "node:process"; import { serveStatic } from "@hono/node-server/serve-static"; +import { createHonoFateHandler } from "@nkzw/fate/server"; import { Hono, type Context } from "hono"; import type { AppContext } from "./context"; +import { fate, type HonoFateEnv } from "./fate"; interface HonoAppOptions { context: AppContext; @@ -12,7 +14,13 @@ interface HonoAppOptions { } export function createHeadplaneHonoApp({ context, prefix, staticRoot }: HonoAppOptions) { - const app = new Hono(); + const app = new Hono(); + const fateHandler = createHonoFateHandler(fate); + + app.use("*", async (c, next) => { + c.set("appContext", context); + await next(); + }); const health = async (c: Context) => { const api = context.hsApi.getRuntimeClient("fake-api-key"); @@ -86,8 +94,8 @@ export function createHeadplaneHonoApp({ context, prefix, staticRoot }: HonoAppO } }); - app.all(`${prefix}/fate`, (c) => c.json({ error: "Fate server is not mounted yet" }, 501)); - app.all(`${prefix}/fate/*`, (c) => c.json({ error: "Fate server is not mounted yet" }, 501)); + app.all(`${prefix}/fate`, fateHandler); + app.all(`${prefix}/fate/*`, fateHandler); if (staticRoot) { const stripPrefix = (path: string) => path.slice(prefix.length) || "/"; diff --git a/app/spa/app.tsx b/app/spa/app.tsx index f5bf977..849196f 100644 --- a/app/spa/app.tsx +++ b/app/spa/app.tsx @@ -1,15 +1,12 @@ import { RouterProvider } from "@tanstack/react-router"; -import { FateClient, createClient, createHTTPTransport } from "react-fate"; +import { FateClient } from "react-fate"; +import { createFateClient } from "react-fate/client"; import { router } from "./router"; -const fate = createClient({ - roots: {}, - transport: createHTTPTransport({ - fetch: (input, init) => fetch(input, { ...init, credentials: "include" }), - url: `${__PREFIX__}/fate`, - }), - types: [], +const fate = createFateClient({ + fetch: (input, init) => fetch(input, { ...init, credentials: "include" }), + url: `${__PREFIX__}/fate`, }); export function App() { diff --git a/package.json b/package.json index 2d6ec01..fe825c9 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "build": "vite build", "dev": "HEADPLANE_CONFIG_PATH=./config.example.yaml HEADPLANE_SERVER__DATA_PATH=./.data tsx app/server/hono-dev.ts", "start": "tsx app/server/hono-main.ts", - "typecheck": "react-router typegen && tsgo", + "typecheck": "fate generate && react-router typegen && tsgo", "test:unit": "vitest run --project unit", "test:integration": "vitest run --project integration:*", "docs:dev": "vitepress dev docs", diff --git a/tsconfig.json b/tsconfig.json index 535de19..36c3693 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,11 @@ { - "include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"], + "include": [ + "**/*", + "**/.server/**/*", + "**/.client/**/*", + ".fate/**/*", + ".react-router/types/**/*" + ], "exclude": ["docs"], "compilerOptions": { "lib": ["DOM", "DOM.Iterable", "ES2023"], diff --git a/vite.config.ts b/vite.config.ts index cc327db..cf29492 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url"; import tailwindcss from "@tailwindcss/vite"; import react from "@vitejs/plugin-react"; +import { fate } from "react-fate/vite"; import { defineConfig } from "vite"; import { parse } from "yaml"; @@ -41,7 +42,14 @@ const { server } = parse(config); export default defineConfig({ appType: "spa", base: `${PREFIX}/`, - plugins: [react(), tailwindcss()], + plugins: [ + fate({ + module: "./app/server/fate.ts", + transport: "native", + }), + react(), + tailwindcss(), + ], server: { host: server.host, port: server.port,