Packages

React inside Phoenix LiveView — async code splitting, client-side props diffing, and zero-config component discovery.

Retired package: Release invalid - Not production-ready

Current section

Files

Jump to
react_phx assets vite-plugin.ts
Raw

assets/vite-plugin.ts

import { existsSync } from "node:fs";
import { resolve } from "node:path";
import type { IncomingMessage, ServerResponse } from "node:http";
import type { Plugin, ResolvedConfig, ViteDevServer, ModuleNode } from "vite";
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
function hotUpdateType(path: string): "css-update" | "js-update" | null {
if (path.endsWith("css")) return "css-update";
if (path.endsWith("js")) return "js-update";
return null;
}
function jsonResponse(
res: ServerResponse,
statusCode: number,
data: unknown,
): void {
res.statusCode = statusCode;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify(data));
}
interface RequestWithBody extends IncomingMessage {
body?: Record<string, unknown>;
}
/** Simple JSON body parser middleware. */
function jsonMiddleware(
req: RequestWithBody,
res: ServerResponse,
next: () => void,
): void {
let data = "";
req.on("data", (chunk: Buffer | string) => {
data += chunk;
});
req.on("end", () => {
try {
req.body = JSON.parse(data) as Record<string, unknown>;
next();
} catch {
jsonResponse(res, 400, { error: "Invalid JSON" });
}
});
req.on("error", (err: Error) => {
console.error(err);
jsonResponse(res, 500, { error: "Internal Server Error" });
});
}
// ---------------------------------------------------------------------------
// Plugin options
// ---------------------------------------------------------------------------
export interface ReactPhxPluginOptions {
/** Directory containing React components. Defaults to "./react-components". */
componentDir?: string;
/** Enable SSR support. Defaults to true. */
ssr?: boolean;
/** Enable automatic vendor chunk splitting. Defaults to true. */
vendorChunks?: boolean;
/** Enable HMR for .heex/.ex file changes. Defaults to true. */
hmr?: boolean;
/** SSR endpoint path. Defaults to "/ssr_render". */
ssrPath?: string;
/** SSR entrypoint module. Defaults to "./js/server.js". */
ssrEntry?: string;
// ---- Deprecated aliases kept for backward compatibility ----
/** @deprecated Use `ssrPath` instead. */
path?: string;
/** @deprecated Use `ssrEntry` instead. */
entrypoint?: string;
}
// ---------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------
export default function reactPhxPlugin(
opts: ReactPhxPluginOptions = {},
): Plugin {
const componentDir = opts.componentDir ?? "./react-components";
const hmr = opts.hmr ?? true;
const vendorChunks = opts.vendorChunks ?? true;
const ssr = opts.ssr ?? true;
const ssrPath = opts.ssrPath ?? opts.path ?? "/ssr_render";
const ssrEntry = opts.ssrEntry ?? opts.entrypoint ?? "./js/server.js";
let viteRoot = process.cwd();
return {
name: "react-phx",
// --- Capture resolved Vite root for glob path resolution ---
configResolved(config: ResolvedConfig) {
viteRoot = config.root;
},
// --- Virtual module resolution ---
resolveId(id: string) {
if (id === "virtual:react-phx") return "\0virtual:react-phx";
if (id === "virtual:react-phx/server") return "\0virtual:react-phx/server";
},
// --- Virtual module content ---
load(id: string) {
// In virtual modules, Vite requires glob patterns to start with '/'.
// A leading '/' means "relative to the Vite project root", so we
// compute the path relative to root and prepend '/'.
const absDir = resolve(viteRoot, componentDir);
const relFromRoot = absDir === viteRoot
? ""
: "/" + absDir.slice(viteRoot.length + 1).split("\\").join("/");
if (id === "\0virtual:react-phx") {
return `
import { getHooks } from "react_phx";
const components = import.meta.glob("${relFromRoot}/**/*.{jsx,tsx}", { eager: false });
const hooks = getHooks(components);
export default hooks;
export { components };
`;
}
if (id === "\0virtual:react-phx/server") {
return `
import { getRender } from "react_phx/server";
const components = import.meta.glob("${relFromRoot}/**/*.{jsx,tsx}", { eager: true });
export const render = getRender(components);
`;
}
},
// --- Automatic vendor chunk splitting ---
config(userConfig, { command }) {
if (command !== "build" || !vendorChunks) return;
// Don't override user's manualChunks
const output = userConfig.build?.rollupOptions?.output;
if (output && (Array.isArray(output) || output.manualChunks)) return;
return {
build: {
rollupOptions: {
output: {
manualChunks(id: string) {
if (
id.includes("node_modules/react/") ||
id.includes("node_modules/react-dom/")
)
return "vendor-react";
if (id.includes("/deps/phoenix")) return "vendor-phoenix";
},
},
},
},
};
},
// --- HMR for .heex/.ex files ---
handleHotUpdate({ file, modules, server, timestamp }) {
if (!hmr) return;
if (file.match(/\.(heex|ex)$/)) {
const invalidatedModules = new Set<ModuleNode>();
for (const mod of modules) {
server.moduleGraph.invalidateModule(
mod,
invalidatedModules,
timestamp,
true,
);
}
const updates = Array.from(invalidatedModules)
.filter((m) => m.file && hotUpdateType(m.file))
.map((m) => ({
type: hotUpdateType(m.file!) as "css-update" | "js-update",
path: m.url,
acceptedPath: m.url,
timestamp,
}));
server.ws.send({ type: "update", updates });
// We handle the hot update ourselves
return [];
}
},
// --- SSR middleware ---
configureServer(server: ViteDevServer) {
// Terminate the watcher when Phoenix quits
process.stdin.on("close", () => process.exit(0));
process.stdin.resume();
// Determine the SSR entrypoint:
// 1. If user set explicit ssrEntry/entrypoint, use it.
// 2. Else if ssr is enabled and the default server.js doesn't exist, use the virtual module.
let resolvedEntry = ssrEntry;
if (
!opts.ssrEntry &&
!opts.entrypoint &&
ssr &&
!existsSync(resolve(process.cwd(), ssrEntry))
) {
resolvedEntry = "virtual:react-phx/server";
}
server.middlewares.use(function reactPhxMiddleware(
req: RequestWithBody,
res: ServerResponse,
next: () => void,
) {
if (
req.method === "POST" &&
req.url?.split("?", 1)[0] === ssrPath
) {
jsonMiddleware(req, res, async () => {
try {
const mod = (await server.ssrLoadModule(resolvedEntry)) as {
render: (
name: string,
props: Record<string, unknown>,
slots: Record<string, unknown>,
) => Promise<string> | string;
};
const html = await mod.render(
req.body!.name as string,
req.body!.props as Record<string, unknown>,
req.body!.slots as Record<string, unknown>,
);
res.end(html);
} catch (e) {
server.ssrFixStacktrace(e as Error);
jsonResponse(res, 500, { error: e });
}
});
} else {
next();
}
});
},
};
}