fully-react
Version:
React Server for Vite
480 lines (438 loc) • 12.5 kB
text/typescript
import { reactServerComponents } from "./rsc-plugin";
import { hattip } from "./winterkit/vite-plugin";
import type { Plugin, PluginOption } from "vite";
import path, { dirname, join } from "node:path";
import inspect from "vite-plugin-inspect";
import { tsconfigPaths } from "./rsc-plugin/tsconfig-paths";
import reactRefresh from "@vitejs/plugin-react";
import { cpSync, existsSync, readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { defineFileSystemRoutes } from "./fs-router";
import { exposeDevServer } from "./vite-dev-server";
function makeDefaultNodeEntry(hattipEntry: string | undefined) {
if (!hattipEntry) {
throw new Error("No hattip entry found");
}
return `
import handler from ${JSON.stringify(hattipEntry)};
import { createMiddleware } from "fully-react/node";
export default createMiddleware(handler);
`;
}
const _dirname = dirname(fileURLToPath(import.meta.url));
import { createRequire } from "node:module";
import { Worker } from "node:worker_threads";
import { ReadableStream } from "node:stream/web";
import { generateTypes, prettyPrintRoutes } from "./fs-router/dev";
import { createServerRoutes } from "./fs-router/server-routes";
import { Env } from "./env";
const require = createRequire(import.meta.url);
// TODO: Use something other than JSON for worker communication.
/**
* Create a worker thread that will be used to render RSC chunks.
* @param buildPath Absolute path to the the built RSC bundle.
*/
export async function createRSCWorker(buildPath: string, onReload: () => void) {
const rscWorker = require.resolve("./rsc-worker");
const worker = new Worker(rscWorker, {
execArgv: ["--conditions", "react-server"],
env: {
RSC_WORKER: "true",
// DEBUG: "vite:*",
NODE_ENV: process.env.NODE_ENV ?? "production",
MINIFY: process.argv.includes("--minify") ? "true" : "false",
},
workerData: {
buildPath,
},
});
await new Promise<void>((resolve, reject) =>
worker.once("message", (event) => {
if (event === "ready") {
resolve();
} else {
reject(new Error("rsc worker failed to start"));
}
}),
);
const responses = new Map<string, (event: any) => void>();
const encoder = new TextEncoder();
worker.on("message", (msg) => {
const { id, ...event } = JSON.parse(msg);
if (event.type === "reload") {
onReload();
return;
}
const res = responses.get(id)!;
// if (chunk === "end") {
// res.close();
// responses.delete(id);
// return;
// }
// if (chunk)
// res.enqueue(encoder.encode(chunk));
res(event);
});
worker.once("exit", (code) => {
console.log("RSC worker exited with code", code);
process.exit(code);
});
return {
render(component: string, props: any) {
const id = Math.random() + "";
worker.postMessage(
JSON.stringify({
component,
props,
type: "render",
id,
}),
);
return new ReadableStream({
start(controller) {
responses.set(id, ({ chunk }) => {
if (chunk === "end") {
controller.close();
responses.delete(id);
return;
}
if (chunk) controller.enqueue(encoder.encode(chunk));
});
},
});
},
build: () => {
return new Promise((resolve) => {
const id = Math.random() + "";
responses.set(id, ({ status }) => {
if (status === "built") {
resolve("");
}
});
worker.postMessage(
JSON.stringify({
type: "build",
id,
}),
);
});
},
close: () => {
worker.unref();
},
};
}
export function react({
server = true,
inspect: _inspect = true,
reactRefresh: _reactRefresh = true,
tsconfigPaths: _tsconfigPaths = true,
serverEntry = "fully-react/entry-server",
clientEntry = undefined as string | null | undefined,
rscEntry = undefined as string | null | undefined,
appRoot = "app",
} = {}): (Plugin | PluginOption[] | PluginOption | undefined)[] {
let isSsrBuild = false;
let worker: any;
let routesConfig: any = {};
return [
{
name: "fully-react",
async configureServer(server) {
const root = server.config.root ?? process.cwd();
// @ts-ignore
server.routesConfig = routesConfig;
if (!process.env.RSC_WORKER) {
const rscWorker = await createRSCWorker("", () => {
server.ws.send("reload-rsc", { msg: "hello" });
});
// @ts-ignore
server.rscWorker = rscWorker;
worker = rscWorker;
process.on("beforeExit", () => {
rscWorker.close();
});
// const stream = rscWorker.render(
// new URL("/", "http://localhost:3000"),
// );
// console.log(text(stream).then(console.log));
}
},
closeWatcher() {
if (worker) {
worker.close();
}
},
config(config, env) {
const root = config.root ?? process.cwd();
isSsrBuild = env.ssrBuild ?? false;
const absoluteAppRoot = path.join(root, appRoot);
if (existsSync(path.join(root, appRoot, "routes"))) {
routesConfig = defineFileSystemRoutes(path.join(root, appRoot));
const env: Env = {
manifests: {
routesConfig,
},
lazyComponent(id: string) {
return null;
},
} as unknown as Env;
const routes = createServerRoutes(env, "root");
prettyPrintRoutes(routes, 2);
generateTypes(
routes,
absoluteAppRoot,
absoluteAppRoot.replace(/\/app$/, "/.vite/app"),
env.manifests!.routesConfig,
);
}
// @ts-ignore
globalThis.routesConfig = routesConfig;
const findAny = (
path: string,
name: string,
exts = [".js", ".ts", ".jsx", ".tsx", ".mjs", ".mts"],
) => {
for (const ext of exts) {
const file = join(path, name + ext);
if (existsSync(file)) {
return file;
}
}
return null;
};
// let clientModules: string[] = [];
// let serverModules: string[] = [];
// if (
// existsSync(join(root, "dist", "react-server", "client-manifest.json"))
// ) {
// clientModules = JSON.parse(
// readFileSync(
// join(root, "dist", "react-server", "client-manifest.json"),
// {
// encoding: "utf8",
// },
// ),
// );
// }
// if (
// existsSync(join(root, "dist", "react-server", "server-manifest.json"))
// ) {
// serverModules = JSON.parse(
// readFileSync(
// join(root, "dist", "react-server", "server-manifest.json"),
// {
// encoding: "utf8",
// },
// ),
// );
// }
clientEntry =
clientEntry ?? findAny(join(root, appRoot), "entry-client");
if (!clientEntry) {
clientEntry = join(_dirname, "..", "src", "entry-client.tsx");
}
rscEntry = rscEntry ?? findAny(join(root, appRoot), "entry-rsc");
if (!rscEntry) {
rscEntry = join(_dirname, "..", "dist", "entry-rsc.production.js");
}
config.build ||= {};
if (env.ssrBuild) {
if (!process.env.RSC_WORKER) {
if (config.build.ssr === true) {
config.build.rollupOptions ||= {};
config.build.rollupOptions.input ||= {
handler: "/virtual:vavite-connect-handler",
index: "/virtual:vavite-connect-server",
};
}
config.build.outDir ||= "dist/server";
config.build.ssrEmitAssets = true;
config.build.manifest = true;
} else {
config.build.rollupOptions ||= {};
config.build.rollupOptions.input = {};
config.build.manifest = true;
config.build.rollupOptions.input["react-server"] = rscEntry;
config.build.rollupOptions.input["root"] = "/app/root";
if (routesConfig) {
Object.entries(routesConfig).forEach(([name, route]) => {
let chunkName = name.replaceAll(":", "_").replaceAll("/", "_");
chunkName = chunkName.length > 0 ? chunkName : "root-layout";
// @ts-ignore
config.build!.rollupOptions!.input[chunkName] = route.file;
});
}
config.build.outDir ||= "dist/react-server";
config.build.ssrEmitAssets = true;
}
} else {
config.build.outDir ||= "dist/static";
config.build.ssrManifest = true;
config.build.rollupOptions ||= {};
config.build.rollupOptions.treeshake = false;
config.build.rollupOptions.preserveEntrySignatures = "exports-only";
config.build.rollupOptions.input ||= [clientEntry];
config.build.manifest = true;
}
return {
routes: routesConfig,
resolve: {
alias: {
"~": path.resolve(root, "app"),
"~react/entry-client": clientEntry,
},
conditions: process.env.RSC_WORKER
? ["node", "import", "react-server", "production"]
: [],
},
define: {
"import.meta.env.CLIENT_ENTRY": JSON.stringify(clientEntry),
"import.meta.env.ROOT_DIR": JSON.stringify(root),
"import.meta.env.REACT_SERVER_PROD_ENTRY": JSON.stringify(
"dist/server/react-server/react-server.js",
),
"process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV),
},
ssr: {
noExternal: ["fully-react", "react-error-boundary"],
},
};
},
async buildStart(options) {
const root = process.cwd();
if (isSsrBuild) {
if (!process.env.RSC_WORKER) {
worker = await createRSCWorker("", () => {
throw new Error("RSC Worker should not reload while building");
});
await worker.build();
let clientModules: string[] = [];
let serverModules: string[] = [];
if (
existsSync(
join(root, "dist", "react-server", "client-manifest.json"),
)
) {
clientModules = JSON.parse(
readFileSync(
join(root, "dist", "react-server", "client-manifest.json"),
{
encoding: "utf8",
},
),
);
}
if (
existsSync(
join(root, "dist", "react-server", "server-manifest.json"),
)
) {
serverModules = JSON.parse(
readFileSync(
join(root, "dist", "react-server", "server-manifest.json"),
{
encoding: "utf8",
},
),
);
}
options.input = {
...options.input,
...Object.fromEntries([
...clientModules.map((m) => [path.basename(m), m]),
...serverModules.map((m) => [path.basename(m), m]),
]),
};
}
} else {
let clientModules: string[] = [];
if (
existsSync(
join(root, "dist", "react-server", "client-manifest.json"),
)
) {
clientModules = JSON.parse(
readFileSync(
join(root, "dist", "react-server", "client-manifest.json"),
{
encoding: "utf8",
},
),
);
}
// @ts-ignore
options.input.push(...clientModules);
}
},
async buildEnd() {
if (!process.env.RSC_WORKER && isSsrBuild) {
worker.close();
}
},
closeBundle: {
order: "post",
handler() {
if (!isSsrBuild) {
cpSync(
join(process.cwd(), "dist/static/manifest.json"),
join(process.cwd(), "dist/server/static-manifest.json"),
);
}
},
},
generateBundle(options) {
if (isSsrBuild && !process.env.RSC_WORKER) {
console.log("copying");
cpSync("dist/react-server", "dist/server/react-server", {
recursive: true,
});
} else if (isSsrBuild && process.env.RSC_WORKER) {
writeFileSync(
"dist/react-server/routes.json",
JSON.stringify(routesConfig, null, 2),
);
} else if (!isSsrBuild) {
if (existsSync(join(process.cwd(), "dist/server/assets/"))) {
cpSync(
join(process.cwd(), "dist/server/assets/"),
join(options!.dir!, "assets/"),
{
recursive: true,
filter: (src) => !src.endsWith(".js"),
},
);
}
if (
existsSync(join(process.cwd(), "dist/server/react-server/assets/"))
) {
cpSync(
join(process.cwd(), "dist/server/react-server/assets/"),
join(options!.dir!, "assets/"),
{
recursive: true,
filter: (src) => !src.endsWith(".js"),
},
);
}
}
},
} satisfies Plugin,
_tsconfigPaths ? tsconfigPaths() : undefined,
_inspect
? inspect({
build: true,
})
: undefined,
_reactRefresh ? reactRefresh() : undefined,
(server
? hattip({
clientConfig: {},
hattipEntry: serverEntry,
devEntry: makeDefaultNodeEntry,
})
: exposeDevServer()) as Plugin,
reactServerComponents(),
];
}
export default react;