Files
Reactive-Resume/apps/web/vite.config.ts
T
Amruth Pillai 725be158c0 feat(deploy): deploy Vercel as frontend and backend services
Vercel now deploys Reactive Resume as two services in one project:
`frontend` serves the static Vite build from apps/web/dist, and
`backend` runs the Hono server Function from apps/server. Top-level
rewrites send server-owned paths (/api, /uploads, /mcp, /.well-known,
robots.txt, sitemap.xml, llms.txt, schema.json, index.html) and every
path without a file extension to `backend`, so HTML shells keep their
injected SEO metadata. Paths with a file extension go to `frontend`.

The service builder needs a few accommodations:

- apps/server/vercel.mjs replaces api/index.mjs as the entrypoint,
  because a service entrypoint must exist before the build runs.
- `outputDirectory: "."` stops the builder from using the Docker
  entrypoint in dist/ as the Function handler.
- The builder loads external CommonJS dependencies through pnpm links
  that it leaves out of the Function. ioredis and react-reconciler are
  now bundled with their dependencies, and bcrypt is replaced with
  bcryptjs, which reads and writes the same $2b$ hashes.

The Vercel compatibility workflow now builds with the services
framework and loads a copy of the backend Function outside the
checkout, so a dependency missing from the Function fails CI. The stale
PDFKit trace checks are removed.

Existing Vercel installations must set Framework Preset to Services
before redeploying; the self-hosting guide documents this.
2026-09-30 00:17:24 +02:00

148 lines
4.8 KiB
TypeScript

import type { ProxyOptions } from "vite";
import { readFileSync } from "node:fs";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { fileURLToPath, pathToFileURL } from "node:url";
import { lingui, linguiTransformerBabelPreset } from "@lingui/vite-plugin";
import babel from "@rolldown/plugin-babel";
import tailwindcss from "@tailwindcss/vite";
import { devtools } from "@tanstack/devtools-vite";
import { tanstackRouter } from "@tanstack/router-plugin/vite";
import viteReact, { reactCompilerPreset } from "@vitejs/plugin-react";
import { defineConfig } from "vite";
const rootPackageJsonPath = new URL("../../package.json", import.meta.url);
const rootPackageJson = JSON.parse(readFileSync(rootPackageJsonPath, "utf-8")) as { version: string | undefined };
const appVersion = JSON.stringify(rootPackageJson.version ?? "0.0.0");
const workspaceRoot = fileURLToPath(new URL("../..", import.meta.url));
const webRoot = fileURLToPath(new URL(".", import.meta.url));
const prerenderBundleDir = `${webRoot}node_modules/.prerender`;
// Outside dist/, so the static server and the CDN never serve these pages at their own addresses.
const prerenderOutDir = `${webRoot}dist-prerender`;
const escapeHtml = (value: string) =>
value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
/**
* Writes dist-prerender/<page>/<locale>.html: the built index.html with the page rendered into #app, in that locale's
* language, direction, title and description. The server sends them for "/" and "/ats-checker"
* (apps/server/src/static/web.ts).
*/
async function prerenderPages() {
type PrerenderModule = typeof import("./src/features/homepage/prerender");
const { locales, prerenderedPages, renderPage }: PrerenderModule = await import(
pathToFileURL(`${prerenderBundleDir}/prerender.js`).href
);
const shell = await readFile(`${webRoot}dist/index.html`, "utf8");
await rm(prerenderOutDir, { recursive: true, force: true });
for (const name of prerenderedPages) {
await mkdir(`${prerenderOutDir}/${name}`, { recursive: true });
for (const locale of locales) {
const page = await renderPage(name, locale);
const html = shell
.replace(/<html lang="[^"]*">/, () => `<html lang="${locale}" dir="${page.dir}">`)
.replace(/<title>[^<]*<\/title>/, () => `<title>${escapeHtml(page.title)}</title>`)
.replace(
/<meta name="description"[^>]*>/,
() => `<meta name="description" content="${escapeHtml(page.description)}">`,
)
.replace('<div id="app"></div>', () => `<div id="app">${page.html}</div>`);
await writeFile(`${prerenderOutDir}/${name}/${locale}.html`, html);
}
}
}
const serverPaths = ["/api", "/mcp", "/uploads", "/.well-known", "/schema.json"] as const;
const serverProxy = serverPaths.reduce(
(acc, path) => {
acc[path] = {
target: `http://localhost:${process.env.SERVER_PORT ?? "3001"}`,
changeOrigin: true,
};
return acc;
},
{} as Record<string, ProxyOptions>,
);
export default defineConfig({
envDir: workspaceRoot,
resolve: {
tsconfigPaths: true,
},
define: {
__APP_VERSION__: appVersion,
},
// `vite build` builds the app, then the marketing pages' server entry, then prerenders the pages with it.
builder: {
buildApp: async (builder) => {
await builder.build(builder.environments.client);
await builder.build(builder.environments.ssr);
await prerenderPages();
},
},
environments: {
ssr: {
build: {
outDir: prerenderBundleDir,
emptyOutDir: true,
rolldownOptions: { input: "src/features/homepage/prerender.tsx" },
},
},
},
build: {
chunkSizeWarningLimit: 10 * 1024, // 10 MB
rolldownOptions: {
external: ["bcryptjs", "sharp", "@aws-sdk/client-s3", "ioredis", "linkedom"],
},
},
// The PDF worker renders templates with translated section titles, so it needs the catalogs and macros too.
worker: {
format: "es",
plugins: () => [lingui(), babel({ presets: [linguiTransformerBabelPreset()] })],
},
server: {
host: true,
strictPort: true,
port: Number.parseInt(process.env.PORT ?? "3000", 10),
proxy: serverProxy,
},
plugins: [
{
name: "cloudflare-rocket-loader-bootstrap",
transformIndexHtml: {
order: "post",
handler: (html) =>
html.replace(
/<script\b(?=[^>]*\btype="module")(?=[^>]*\bsrc="\/assets\/[^"]+")(?![^>]*\bdata-cfasync=)[^>]*>/,
(script) => script.replace('src="', 'data-cfasync="false" src="'),
),
},
},
devtools(),
tailwindcss(),
tanstackRouter({
target: "react",
semicolons: true,
quoteStyle: "double",
autoCodeSplitting: true,
}),
viteReact(),
lingui(),
// Keep @babel/core on 7: under Babel 8, React Compiler 1.0 skips every function with a destructuring default
// (guarded by src/react-compiler.test.ts).
babel({ presets: [reactCompilerPreset(), linguiTransformerBabelPreset()] }),
],
});