mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-09-12 19:51:00 +05:00
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:
parent
f838c07348
commit
a7adc3c36b
@ -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).
|
||||
|
||||
@ -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>
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
52
apps/vite/server/start-bridge.ts
Normal file
52
apps/vite/server/start-bridge.ts
Normal 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();
|
||||
}
|
||||
};
|
||||
};
|
||||
@ -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);
|
||||
};
|
||||
};
|
||||
|
||||
@ -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>,
|
||||
);
|
||||
}
|
||||
@ -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
24
apps/vite/src/router.tsx
Normal 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>;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -11,6 +11,7 @@ const DashboardRouteComponent = () => {
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/dashboard")({
|
||||
ssr: false,
|
||||
beforeLoad: async () => {
|
||||
const session = await getCachedSession();
|
||||
if (!session?.session) {
|
||||
|
||||
37
apps/vite/src/server-fns/branding.ts
Normal file
37
apps/vite/src/server-fns/branding.ts
Normal 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;
|
||||
},
|
||||
);
|
||||
27
apps/vite/src/server-fns/session.ts
Normal file
27
apps/vite/src/server-fns/session.ts
Normal 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(),
|
||||
);
|
||||
@ -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 = () => {
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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: [
|
||||
|
||||
1446
pnpm-lock.yaml
1446
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user