refactor: update Vite application structure and dependencies

- Removed the `index.html` file as part of the transition to a new routing system.
- Updated `package.json` scripts to streamline development commands using `concurrently`.
- Enhanced dependency management by adding new packages and updating existing ones in `package.json`.
- Introduced a new server function for branding and session management, improving API interactions.
- Refactored routing to utilize TanStack Start, ensuring better integration with the existing Dokploy architecture.
- Improved static file handling and server response management in the custom Node.js server.
- Updated README to reflect changes in architecture and development processes.
This commit is contained in:
Mauricio Siu 2026-08-11 01:15:43 -06:00
parent f838c07348
commit a7adc3c36b
17 changed files with 1563 additions and 459 deletions

View File

@ -1,50 +1,43 @@
# dokploy-vite
Vite + TanStack Router migration of the Dokploy dashboard UI. The Next.js app (`apps/dokploy`) stays intact and keeps serving the backend (tRPC, better-auth, websockets, webhooks). This app is a pure SPA that reuses the existing UI code directly from `apps/dokploy` — components are NOT duplicated.
TanStack Start migration of the Dokploy dashboard. The Next.js app (`apps/dokploy`) stays intact; this app reuses all its UI code and its backend logic without duplicating either.
## How it works
## Architecture
- **Aliases** (`vite.config.ts`): `@/*` resolves into `apps/dokploy/*`, so all 380+ components, hooks, lib and utils are consumed from their original location. `~/*` resolves to this app's `src/`.
- **Next.js shims** (`src/shims/`): `next/link`, `next/router`, `next/navigation`, `next/head`, `next/dynamic`, `next/script` and `nextjs-toploader` are aliased to thin adapters over TanStack Router, so shared components run unmodified.
- **tRPC**: `@/utils/api` is re-aliased to `src/utils/api.ts` (`createTRPCReact` instead of `createTRPCNext`), same links (ws split + FormData split + superjson).
- **Auth**: SSR `getServerSideProps` guards were replaced by `beforeLoad` guards using the existing better-auth client (`@/lib/auth-client`) with a 30s session cache (`src/utils/session.ts`).
- **Routes** (`src/routes/`): file-based TanStack routes mirroring `apps/dokploy/pages`. `/dashboard` is a shared layout route rendering `DashboardLayout` once (it persists across navigations, unlike the pages-router setup).
- **Dev proxy**: `/api` and every websocket endpoint (`/drawer-logs`, `/terminal`, `/docker-container-*`, `/listen-*`) proxy to the Next custom server on `localhost:3000`.
Two tiers, one process in production:
## Standalone server (no Next.js)
- **UI tier (TanStack Start)**: file-based routes in `src/routes/` with **selective SSR** — public pages (login, register, invitation, reset-password) are fully server-rendered (real first paint with content and whitelabel branding), while everything under `/dashboard` is `ssr: false` (pure client, no SSR cost). The document shell (`src/routes/__root.tsx`) SSRs per request with whitelabel title/meta/favicon/customCss loaded from the DB via a server function.
- **API tier (`server/server.ts`)**: Next-free node server owning tRPC, better-auth, OpenAPI REST, deploy/Stripe/git webhooks (reused from `apps/dokploy/pages/api` through `server/next-compat.ts`), websockets, cron jobs and the deployment worker. Start's server functions never import backend code — they call this tier over loopback HTTP (`/api/branding`, `/api/auth/get-session`), which keeps the UI dev process free of native/server deps.
`server/server.ts` is a Next-free replacement for `apps/dokploy/server/server.ts`. It reuses the entire existing backend from the monorepo:
- **tRPC** (`/api/trpc/*`) and **OpenAPI REST** (`/api/*` catch-all) — the exact same handlers from `apps/dokploy/pages/api`, mounted through a thin Next-API compat adapter (`server/next-compat.ts`) that provides `req.query/body/cookies` and `res.status/json/send/redirect` on plain Node req/res.
- **better-auth** (`/api/auth/*`) — `toNodeHandler(auth.handler)`, already framework-agnostic.
- **Webhooks/callbacks** — deploy (github, refreshToken, compose), Stripe (raw body preserved for signature check), GitHub setup/webhook, GitLab/Gitea OAuth callbacks: all reused unchanged via the adapter.
- **Websockets** — same `setup*WebSocketServer` functions from `apps/dokploy/server/wss`.
- **Bootstrapping** — same production init (Traefik config, cron jobs, schedules, volume backups, deployment worker) as the original server.
- **Static SPA** — in production it serves `dist/` with SPA fallback to `index.html` (`server/static.ts`).
Code reuse (unchanged from the SPA phase): `@/*` aliases into `apps/dokploy/*` (383 components consumed in place), `next/*` shims over TanStack Router in `src/shims/`, `@/utils/api` re-aliased to a `createTRPCReact` client with identical hooks/links.
## Development
One command — the server embeds Vite in middleware mode, so API + websockets + UI (with HMR) all run same-origin on :3000:
One command (runs both tiers via concurrently):
```bash
pnpm --filter dokploy-vite dev
# → http://localhost:3000 (no Next involved)
# [api] API + websockets → :3000
# [ui] Start dev (UI + SSR + HMR) → :5173 ← open this one
```
Alternative split mode (Vite dev server on :5173 proxying to whichever backend runs on :3000 — the Next custom server or this one):
Or individually: `dev:api` / `dev:ui`. The Vite dev server proxies `/api` and all websocket paths to `:3000` (rewriting the `origin` header so better-auth accepts it). Note: in dev the two tiers are separate processes because Start's dev server owns the Vite process (it's a full SSR runtime, not an embeddable middleware); in production it's a single process (`server.ts` bridges Start's built handler).
Env loading: the API tier loads `apps/vite/.env` and falls back to `apps/dokploy/.env`. The UI tier intentionally reads no env files — its server functions only talk to the API over loopback, and leaking `NODE_ENV` from a dotfile into `vite build` produces broken dev-mode SSR bundles (`jsxDEV is not a function`).
## Production — single process, no Next
```bash
pnpm --filter dokploy dev # or: nothing, if dev above is running
pnpm --filter dokploy-vite dev:client # → :5173
pnpm --filter dokploy-vite build # Start build → dist/client + dist/server
pnpm --filter dokploy-vite build:server # esbuild → dist-server/*.mjs (server + migration + scripts)
pnpm --filter dokploy-vite start # node server on :3000
```
The `/api` proxy rewrites the `origin` header to :3000 so better-auth's trusted-origin check passes in split mode.
`server/server.ts` serves `/api/*` + websockets directly, static assets from `dist/client`, and bridges every other request to Start's built `fetch` handler (`server/start-bridge.ts`) for SSR. Docker: `Dockerfile.vite` at the repo root (image ~1GB vs 4.33GB for the official Next-based image; ~2.6x less memory).
Production: `pnpm --filter dokploy-vite build && pnpm --filter dokploy-vite start` — one process serving API + websockets + static SPA on :3000, no Next.js at runtime.
## Notes
The server loads `.env` from `apps/vite` first, then falls back to `apps/dokploy/.env`.
## Dropped vs Next (known gaps)
- SSR prefetching (`createServerSideHelpers`) — the SPA fetches on mount instead.
- Per-page IS_CLOUD / role / permission SSR redirects beyond session checks — components and tRPC procedures still enforce them; route-level guards can be added incrementally in `beforeLoad`.
- `routeTree.gen.ts` is generated by the Start plugin (ignored by biome).
- The theme anti-flash script is SSR'd automatically by next-themes — no manual hack.
- Route-level data preload can be added per route with `loader` + `queryClient.ensureQueryData` (framework-native now).
- Endgame cleanup (when `apps/dokploy` is retired): move components in, codemod the 35 `useRouter` call sites to native TanStack APIs, delete `src/shims/` and `server/next-compat.ts` (ideally porting API handlers to Hono routes in the same pass).

View File

@ -1,19 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Dokploy</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -5,12 +5,12 @@
"license": "Apache-2.0",
"type": "module",
"scripts": {
"dev": "tsx -r dotenv/config server/server.ts",
"dev:client": "vite",
"build": "vite build",
"dev": "concurrently -n api,ui -c blue,green \"pnpm dev:api\" \"pnpm dev:ui\"",
"dev:api": "tsx -r dotenv/config server/server.ts",
"dev:ui": "vite dev",
"build": "NODE_ENV=production vite build",
"build:server": "tsx esbuild.config.ts",
"start": "NODE_ENV=production tsx -r dotenv/config server/server.ts",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"check": "biome check --write --no-errors-on-unmatched --files-ignore-unknown=true"
},
@ -27,13 +27,35 @@
"@better-auth/passkey": "1.6.23",
"@better-auth/scim": "1.6.23",
"@better-auth/sso": "1.6.23",
"@codemirror/autocomplete": "^6.18.6",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.11.0",
"@codemirror/legacy-modes": "6.4.0",
"@codemirror/search": "^6.6.0",
"@codemirror/view": "^6.39.15",
"@dokploy/server": "workspace:*",
"@dokploy/trpc-openapi": "0.0.18",
"@faker-js/faker": "^8.4.1",
"@hookform/resolvers": "^5.2.2",
"@octokit/auth-app": "^6.1.3",
"@octokit/webhooks": "^13.9.0",
"@react-email/components": "^1.0.12",
"@stepperize/react": "4.0.1",
"@stripe/stripe-js": "4.8.0",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-router": "^1.170.25",
"@tanstack/react-start": "^1.168.42",
"@tanstack/react-table": "^8.21.3",
"@trpc/client": "^11.10.0",
"@trpc/react-query": "^11.10.0",
"@trpc/server": "^11.10.0",
"@uiw/codemirror-theme-github": "^4.23.12",
"@uiw/react-codemirror": "^4.23.12",
"@xterm/addon-attach": "0.10.0",
"@xterm/addon-clipboard": "0.1.0",
"@xterm/xterm": "^5.5.0",
"adm-zip": "^0.5.16",
"ai": "^6.0.86",
"ai-sdk-ollama": "^3.7.0",
@ -41,14 +63,23 @@
"better-auth": "1.6.23",
"bl": "6.0.11",
"boxen": "^7.1.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^0.2.1",
"copy-to-clipboard": "^3.3.3",
"date-fns": "3.6.0",
"dockerode": "4.0.2",
"dompurify": "^3.3.3",
"dotenv": "16.4.5",
"drizzle-orm": "0.45.2",
"drizzle-zod": "0.8.3",
"fancy-ansi": "^0.1.3",
"input-otp": "^1.4.2",
"lodash": "4.17.21",
"lucide-react": "^0.469.0",
"micromatch": "4.0.8",
"nanoid": "3.3.11",
"next-themes": "^0.2.1",
"node-os-utils": "2.0.1",
"node-pty": "1.1.0",
"node-schedule": "2.1.1",
@ -58,42 +89,37 @@
"pino-pretty": "11.2.2",
"postgres": "3.4.4",
"public-ip": "6.0.2",
"qrcode": "^1.5.4",
"radix-ui": "^1.6.0",
"react": "19.2.7",
"react-confetti-explosion": "3.0.3",
"react-day-picker": "10.0.1",
"react-dom": "19.2.7",
"react-hook-form": "^7.71.2",
"react-markdown": "^9.1.0",
"recharts": "^3.8.0",
"resend": "^6.0.2",
"semver": "7.7.3",
"shell-quote": "^1.8.1",
"slugify": "^1.6.6",
"sonner": "^1.7.4",
"ssh2": "~1.16.0",
"stripe": "17.2.0",
"superjson": "^2.2.2",
"swagger-ui-react": "^5.32.6",
"tailwind-merge": "^2.6.1",
"toml": "3.0.0",
"undici": "^6.21.3",
"use-resize-observer": "9.1.0",
"ws": "8.16.0",
"xterm-addon-fit": "^0.8.0",
"yaml": "2.8.1",
"zod": "^4.3.6",
"zod-form-data": "^3.0.1"
},
"devDependencies": {
"@codemirror/autocomplete": "^6.18.6",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.11.0",
"@codemirror/legacy-modes": "6.4.0",
"@codemirror/search": "^6.6.0",
"@codemirror/view": "^6.39.15",
"@hookform/resolvers": "^5.2.2",
"@stepperize/react": "4.0.1",
"@stripe/stripe-js": "4.8.0",
"@tailwindcss/typography": "0.5.16",
"@tailwindcss/vite": "^4.3.1",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-router": "^1.132.0",
"@tanstack/react-table": "^8.21.3",
"@tanstack/router-plugin": "^1.132.0",
"@trpc/client": "^11.10.0",
"@trpc/react-query": "^11.10.0",
"@types/js-cookie": "^3.0.6",
"@types/lodash": "4.17.4",
"@types/node": "^24.4.0",
@ -102,41 +128,16 @@
"@types/react-dom": "^19.2.0",
"@types/semver": "7.7.1",
"@types/swagger-ui-react": "^4.19.0",
"@uiw/codemirror-theme-github": "^4.23.12",
"@uiw/react-codemirror": "^4.23.12",
"@vitejs/plugin-react": "^5.0.0",
"@xterm/addon-attach": "0.10.0",
"@xterm/addon-clipboard": "0.1.0",
"@xterm/xterm": "^5.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^0.2.1",
"copy-to-clipboard": "^3.3.3",
"dompurify": "^3.3.3",
"concurrently": "^10.0.4",
"esbuild": "0.20.2",
"fancy-ansi": "^0.1.3",
"input-otp": "^1.4.2",
"js-cookie": "^3.0.5",
"lucide-react": "^0.469.0",
"next-themes": "^0.2.1",
"qrcode": "^1.5.4",
"radix-ui": "^1.6.0",
"react-confetti-explosion": "3.0.3",
"react-day-picker": "10.0.1",
"react-hook-form": "^7.71.2",
"react-markdown": "^9.1.0",
"recharts": "^3.8.0",
"shadcn": "^4.11.0",
"sonner": "^1.7.4",
"swagger-ui-react": "^5.32.6",
"tailwind-merge": "^2.6.1",
"tailwindcss": "^4.3.1",
"tailwindcss-animate": "^1.0.7",
"tsx": "^4.22.4",
"tw-animate-css": "^1.4.0",
"typescript": "^5.8.3",
"use-resize-observer": "9.1.0",
"vite": "^7.1.0",
"xterm-addon-fit": "^0.8.0"
"vite": "^7.1.0"
}
}

View File

@ -1,10 +1,13 @@
import fs from "node:fs";
import http from "node:http";
import path from "node:path";
import { pathToFileURL } from "node:url";
import {
auth,
createDefaultMiddlewares,
createDefaultServerTraefikConfig,
createDefaultTraefikConfig,
getPublicWhitelabelingConfig,
IS_CLOUD,
initCancelDeployments,
initCronJobs,
@ -37,6 +40,7 @@ import { setupDeploymentLogsWebSocketServer } from "@/server/wss/listen-deployme
import { setupTerminalWebSocketServer } from "@/server/wss/terminal";
import packageInfo from "../package.json";
import { runNextApiHandler } from "./next-compat";
import { createStartBridge } from "./start-bridge";
import { createStaticHandler } from "./static";
config({ path: ".env" });
@ -63,6 +67,17 @@ const handleApi = async (
if (pathname === "/api/health") {
return runNextApiHandler(healthHandler, req, res);
}
if (pathname === "/api/branding") {
let config = null;
try {
config = await getPublicWhitelabelingConfig();
} catch {
config = null;
}
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
res.end(JSON.stringify(config));
return;
}
if (pathname.startsWith("/api/auth/")) {
return authHandler(req, res);
}
@ -125,7 +140,7 @@ const startServer = async () => {
req: http.IncomingMessage,
res: http.ServerResponse,
url: URL,
) => void = (_req, res) => {
) => void | Promise<void> = (_req, res) => {
res.writeHead(503, { "content-type": "text/plain" });
res.end("UI not ready");
};
@ -137,7 +152,7 @@ const startServer = async () => {
await handleApi(req, res, url.pathname);
return;
}
handleUi(req, res, url);
await handleUi(req, res, url);
} catch (error) {
console.error("Request error", error);
if (!res.headersSent) {
@ -148,22 +163,36 @@ const startServer = async () => {
});
if (isProd) {
handleUi = createStaticHandler(path.resolve(import.meta.dirname, "../dist"));
} else {
const { createServer: createViteServer } = await import("vite");
const vite = await createViteServer({
root: path.resolve(import.meta.dirname, ".."),
configFile: path.resolve(import.meta.dirname, "../vite.config.ts"),
appType: "spa",
server: { middlewareMode: true, hmr: { server } },
});
handleUi = (req, res) => {
vite.middlewares(req, res, () => {
res.statusCode = 404;
res.end();
});
const clientDir = path.resolve(import.meta.dirname, "../dist/client");
const serverEntry = path.resolve(
import.meta.dirname,
"../dist/server/server.js",
);
const staticHandler = createStaticHandler(clientDir);
const entry = await import(pathToFileURL(serverEntry).href);
const bridge = createStartBridge(entry.default);
handleUi = async (req, res, url) => {
const pathname = decodeURIComponent(url.pathname);
const filePath = path.join(clientDir, pathname);
if (
pathname !== "/" &&
filePath.startsWith(clientDir) &&
fs.existsSync(filePath) &&
fs.statSync(filePath).isFile()
) {
staticHandler(req, res, url);
return;
}
await bridge(req, res);
};
console.log("TanStack Start SSR bridge enabled");
} else {
handleUi = (_req, res) => {
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
res.end(
"API-only mode: the UI dev server runs separately (pnpm --filter dokploy-vite dev → http://localhost:5173).",
);
};
console.log("Vite dev middleware enabled (UI + HMR on this port)");
}
setupDrawerLogsWebSocketServer(server);

View File

@ -0,0 +1,52 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { Readable } from "node:stream";
interface FetchHandler {
fetch: (request: Request) => Response | Promise<Response>;
}
export const createStartBridge = (handler: FetchHandler) => {
return async (req: IncomingMessage, res: ServerResponse) => {
const url = `http://${req.headers.host ?? "localhost"}${req.url ?? "/"}`;
const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
if (value === undefined) continue;
if (Array.isArray(value)) {
for (const item of value) headers.append(key, item);
} else {
headers.set(key, value);
}
}
const method = req.method ?? "GET";
const hasBody = method !== "GET" && method !== "HEAD";
const request = new Request(url, {
method,
headers,
...(hasBody
? {
body: Readable.toWeb(req) as unknown as ReadableStream,
duplex: "half",
}
: {}),
} as RequestInit);
const response = await handler.fetch(request);
const outHeaders: Record<string, string | string[]> = {};
response.headers.forEach((value, key) => {
if (key === "set-cookie") return;
outHeaders[key] = value;
});
const setCookies = response.headers.getSetCookie();
if (setCookies.length > 0) {
outHeaders["set-cookie"] = setCookies;
}
res.writeHead(response.status, outHeaders);
if (response.body) {
Readable.fromWeb(response.body as never).pipe(res);
} else {
res.end();
}
};
};

View File

@ -25,35 +25,12 @@ const MIME_TYPES: Record<string, string> = {
".webmanifest": "application/manifest+json",
};
const sendFile = (
res: ServerResponse,
filePath: string,
cacheControl: string,
) => {
res.writeHead(200, {
"content-type":
MIME_TYPES[path.extname(filePath).toLowerCase()] ??
"application/octet-stream",
"cache-control": cacheControl,
});
fs.createReadStream(filePath).pipe(res);
};
export const createStaticHandler = (distDir: string) => {
const hasDist = fs.existsSync(path.join(distDir, "index.html"));
return (req: IncomingMessage, res: ServerResponse, url: URL) => {
if (req.method !== "GET" && req.method !== "HEAD") {
res.writeHead(404).end();
return;
}
if (!hasDist) {
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
res.end(
"UI build not found. In development run the Vite dev server (pnpm --filter dokploy-vite dev); for production run vite build first.",
);
return;
}
const pathname = decodeURIComponent(url.pathname);
const filePath = path.join(distDir, pathname);
@ -62,18 +39,20 @@ export const createStaticHandler = (distDir: string) => {
return;
}
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
const cacheControl = pathname.startsWith("/assets/")
? "public, max-age=31536000, immutable"
: "public, max-age=0, must-revalidate";
sendFile(res, filePath, cacheControl);
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
res.writeHead(404).end();
return;
}
sendFile(
res,
path.join(distDir, "index.html"),
"no-cache, no-store, must-revalidate",
);
const cacheControl = pathname.startsWith("/assets/")
? "public, max-age=31536000, immutable"
: "public, max-age=0, must-revalidate";
res.writeHead(200, {
"content-type":
MIME_TYPES[path.extname(filePath).toLowerCase()] ??
"application/octet-stream",
"cache-control": cacheControl,
});
fs.createReadStream(filePath).pipe(res);
};
};

View File

@ -1,27 +0,0 @@
import "./styles/globals.css";
import { createRouter, RouterProvider } from "@tanstack/react-router";
import ReactDOM from "react-dom/client";
import { routeTree } from "./routeTree.gen";
import { setRouterInstance } from "./shims/next-router";
import { TRPCProvider } from "./utils/trpc-provider";
const router = createRouter({ routeTree });
setRouterInstance(router);
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
const rootElement = document.getElementById("root");
if (rootElement && !rootElement.innerHTML) {
ReactDOM.createRoot(rootElement).render(
<TRPCProvider>
<RouterProvider router={router} />
</TRPCProvider>,
);
}

View File

@ -1109,3 +1109,12 @@ const rootRouteChildren: RootRouteChildren = {
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}

24
apps/vite/src/router.tsx Normal file
View File

@ -0,0 +1,24 @@
import { createRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
import { setRouterInstance } from "./shims/next-router";
import { TRPCProvider } from "./utils/trpc-provider";
export function getRouter() {
const router = createRouter({
routeTree,
defaultPreload: "intent",
Wrap: ({ children }) => <TRPCProvider>{children}</TRPCProvider>,
});
if (typeof window !== "undefined") {
setRouterInstance(router);
}
return router;
}
declare module "@tanstack/react-router" {
interface Register {
router: ReturnType<typeof getRouter>;
}
}

View File

@ -1,10 +1,17 @@
import { createRootRoute, Outlet } from "@tanstack/react-router";
import {
createRootRoute,
HeadContent,
Outlet,
Scripts,
} from "@tanstack/react-router";
import { ThemeProvider } from "next-themes";
import { SearchCommand } from "@/components/dashboard/search-command";
import { WhitelabelingProvider } from "@/components/proprietary/whitelabeling/whitelabeling-provider";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { getBrandingFn } from "~/server-fns/branding";
import TopLoader from "~/shims/toploader";
import appCss from "../styles/globals.css?url";
const RootComponent = () => {
return (
@ -25,6 +32,60 @@ const RootComponent = () => {
);
};
const RootDocument = ({ children }: { children: React.ReactNode }) => {
return (
<html lang="en" suppressHydrationWarning>
<head>
<HeadContent />
</head>
<body>
{children}
<Scripts />
</body>
</html>
);
};
export const Route = createRootRoute({
loader: () => getBrandingFn(),
head: ({ loaderData }) => {
const branding = loaderData ?? null;
const title = branding?.metaTitle || branding?.appName || "Dokploy";
return {
meta: [
{ charSet: "utf-8" },
{ name: "viewport", content: "width=device-width, initial-scale=1" },
{ title },
{ property: "og:title", content: title },
...(branding?.appDescription
? [
{ name: "description", content: branding.appDescription },
{ property: "og:description", content: branding.appDescription },
]
: []),
...(branding?.logoUrl
? [{ property: "og:image", content: branding.logoUrl }]
: []),
],
links: [
{ rel: "stylesheet", href: appCss },
{ rel: "icon", href: branding?.faviconUrl || "/icon.svg" },
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{
rel: "preconnect",
href: "https://fonts.gstatic.com",
crossOrigin: "anonymous",
},
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap",
},
],
styles: branding?.customCss
? [{ children: branding.customCss }]
: undefined,
};
},
shellComponent: RootDocument,
component: RootComponent,
});

View File

@ -11,6 +11,7 @@ const DashboardRouteComponent = () => {
};
export const Route = createFileRoute("/dashboard")({
ssr: false,
beforeLoad: async () => {
const session = await getCachedSession();
if (!session?.session) {

View File

@ -0,0 +1,37 @@
import { createServerFn } from "@tanstack/react-start";
export interface PublicBranding {
appName: string | null;
appDescription: string | null;
logoUrl: string | null;
loginLogoUrl: string | null;
faviconUrl: string | null;
customCss: string | null;
metaTitle: string | null;
errorPageTitle: string | null;
errorPageDescription: string | null;
footerText: string | null;
}
let cached: { at: number; value: PublicBranding | null } | null = null;
export const getBrandingFn = createServerFn({ method: "GET" }).handler(
async (): Promise<PublicBranding | null> => {
if (cached && Date.now() - cached.at < 60_000) {
return cached.value;
}
let value: PublicBranding | null = null;
try {
const response = await fetch(
`http://localhost:${process.env.PORT ?? 3000}/api/branding`,
);
if (response.ok) {
value = (await response.json()) as PublicBranding | null;
}
} catch {
value = null;
}
cached = { at: Date.now(), value };
return value;
},
);

View File

@ -0,0 +1,27 @@
import { createServerFn } from "@tanstack/react-start";
interface SessionPayload {
session: { id: string; userId: string } | null;
user: { id: string; email: string } | null;
}
const fetchSession = async (): Promise<SessionPayload | null> => {
const { getRequest } = await import("@tanstack/react-start/server");
try {
const request = getRequest();
const cookie = request.headers.get("cookie") ?? "";
if (!cookie) return null;
const response = await fetch(
`http://localhost:${process.env.PORT ?? 3000}/api/auth/get-session`,
{ headers: { cookie } },
);
if (!response.ok) return null;
return (await response.json()) as SessionPayload | null;
} catch {
return null;
}
};
export const getSessionFn = createServerFn({ method: "GET" }).handler(() =>
fetchSession(),
);

View File

@ -1,18 +1,21 @@
import { authClient } from "@/lib/auth-client";
import { getSessionFn } from "~/server-fns/session";
type SessionData = Awaited<ReturnType<typeof authClient.getSession>>["data"];
type SessionData = Awaited<ReturnType<typeof getSessionFn>>;
let cached: { at: number; session: SessionData } | null = null;
const TTL_MS = 30_000;
export const getCachedSession = async (): Promise<SessionData> => {
if (typeof window === "undefined") {
return await getSessionFn();
}
if (cached && Date.now() - cached.at < TTL_MS) {
return cached.session;
}
const { data } = await authClient.getSession();
cached = { at: Date.now(), session: data };
return data;
const session = await getSessionFn();
cached = { at: Date.now(), session };
return session;
};
export const clearSessionCache = () => {

View File

@ -28,26 +28,36 @@ const getOrCreateWSClient = () => {
return wsClientSingleton;
};
const createLinks = () => [
splitLink({
condition: (op) => op.type === "subscription",
true: wsLink({
client: getOrCreateWSClient(),
transformer: superjson,
}),
false: splitLink({
condition: (op) => op.input instanceof FormData,
true: httpLink({
const createLinks = () => {
if (typeof window === "undefined") {
return [
httpBatchLink({
url: "/api/trpc",
transformer: superjson,
}),
false: httpBatchLink({
url: "/api/trpc",
];
}
return [
splitLink({
condition: (op) => op.type === "subscription",
true: wsLink({
client: getOrCreateWSClient(),
transformer: superjson,
}),
false: splitLink({
condition: (op) => op.input instanceof FormData,
true: httpLink({
url: "/api/trpc",
transformer: superjson,
}),
false: httpBatchLink({
url: "/api/trpc",
transformer: superjson,
}),
}),
}),
}),
];
];
};
export const TRPCProvider = ({ children }: { children: React.ReactNode }) => {
const [queryClient] = useState(() => new QueryClient());

View File

@ -1,6 +1,6 @@
import path from "node:path";
import tailwindcss from "@tailwindcss/vite";
import { tanstackRouter } from "@tanstack/router-plugin/vite";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
@ -17,11 +17,7 @@ const wsPaths = [
];
export default defineConfig({
plugins: [
tanstackRouter({ target: "react", autoCodeSplitting: true }),
react(),
tailwindcss(),
],
plugins: [tanstackStart(), react(), tailwindcss()],
publicDir: path.resolve(dokployApp, "public"),
resolve: {
alias: [

File diff suppressed because it is too large Load Diff