rwsdk
Version:
Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime
236 lines (235 loc) • 10.8 kB
JavaScript
import { resolve } from "node:path";
import colors from "picocolors";
import { readFile } from "node:fs/promises";
import debug from "debug";
import { VIRTUAL_SSR_PREFIX } from "./ssrBridgePlugin.mjs";
import { normalizeModulePath } from "../lib/normalizeModulePath.mjs";
import { hasDirective as sourceHasDirective } from "./hasDirective.mjs";
import { isJsFile } from "./isJsFile.mjs";
import { invalidateModule } from "./invalidateModule.mjs";
import { getShortName } from "../lib/getShortName.mjs";
const log = debug("rwsdk:vite:hmr-plugin");
let hasErrored = false;
const hasDirective = async (filepath, directive) => {
if (!isJsFile(filepath)) {
return false;
}
const content = await readFile(filepath, "utf-8");
return sourceHasDirective(content, directive);
};
const hasEntryAsAncestor = (module, entryFile, seen = new Set()) => {
// Prevent infinite recursion
if (seen.has(module)) {
return false;
}
seen.add(module);
// Check direct importers
for (const importer of module.importers) {
if (importer.file === entryFile)
return true;
// Recursively check importers
if (hasEntryAsAncestor(importer, entryFile, seen))
return true;
}
return false;
};
const isInUseClientGraph = ({ file, clientFiles, server, }) => {
const id = normalizeModulePath(file, server.config.root);
if (clientFiles.has(id)) {
return true;
}
const modules = server.environments.client.moduleGraph.getModulesByFile(file);
if (!modules) {
return false;
}
for (const m of modules) {
for (const importer of m.importers) {
if (importer.file &&
isInUseClientGraph({ file: importer.file, clientFiles, server })) {
return true;
}
}
}
return false;
};
export const miniflareHMRPlugin = (givenOptions) => [
{
name: "rwsdk:miniflare-hmr",
configureServer(server) {
return () => {
server.middlewares.use(function rwsdkDevServerErrorHandler(err, _req, _res, next) {
if (err) {
hasErrored = true;
}
next(err);
});
};
},
async hotUpdate(ctx) {
if (hasErrored) {
const shortName = getShortName(ctx.file, ctx.server.config.root);
this.environment.logger.info(`${colors.cyan(`attempting to recover from error`)}: update to ${colors.dim(shortName)}`, {
clear: true,
timestamp: true,
});
hasErrored = false;
ctx.server.hot.send({
type: "full-reload",
path: "*",
});
return [];
}
const { clientFiles, serverFiles, viteEnvironment: { name: environment }, workerEntryPathname: entry, } = givenOptions;
if (process.env.VERBOSE) {
log(`Hot update: (env=${this.environment.name}) ${ctx.file}\nModule graph:\n\n${dumpFullModuleGraph(ctx.server, this.environment.name)}`);
}
if (!isJsFile(ctx.file) && !ctx.file.endsWith(".css")) {
return;
}
if (this.environment.name === "ssr") {
log("SSR update, invalidating recursively", ctx.file);
const isUseClientUpdate = isInUseClientGraph({
file: ctx.file,
clientFiles,
server: ctx.server,
});
if (!isUseClientUpdate) {
return [];
}
invalidateModule(ctx.server, "ssr", ctx.file);
invalidateModule(ctx.server, environment, VIRTUAL_SSR_PREFIX +
normalizeModulePath(ctx.file, givenOptions.rootDir));
return [];
}
if (!["client", environment].includes(this.environment.name)) {
return [];
}
const hasClientDirective = await hasDirective(ctx.file, "use client");
const hasServerDirective = !hasClientDirective && (await hasDirective(ctx.file, "use server"));
let clientDirectiveChanged = false;
let serverDirectiveChanged = false;
if (!clientFiles.has(ctx.file) && hasClientDirective) {
clientFiles.add(normalizeModulePath(ctx.file, givenOptions.rootDir));
clientDirectiveChanged = true;
}
else if (clientFiles.has(ctx.file) && !hasClientDirective) {
clientFiles.delete(normalizeModulePath(ctx.file, givenOptions.rootDir));
clientDirectiveChanged = true;
}
if (!serverFiles.has(ctx.file) && hasServerDirective) {
serverFiles.add(normalizeModulePath(ctx.file, givenOptions.rootDir));
serverDirectiveChanged = true;
}
else if (serverFiles.has(ctx.file) && !hasServerDirective) {
serverFiles.delete(normalizeModulePath(ctx.file, givenOptions.rootDir));
serverDirectiveChanged = true;
}
if (clientDirectiveChanged) {
["client", "ssr", environment].forEach((environment) => {
invalidateModule(ctx.server, environment, "virtual:use-client-lookup.js");
});
invalidateModule(ctx.server, environment, VIRTUAL_SSR_PREFIX + "/@id/virtual:use-client-lookup.js");
invalidateModule(ctx.server, environment, VIRTUAL_SSR_PREFIX + "virtual:use-client-lookup.js");
}
if (serverDirectiveChanged) {
["client", "ssr", environment].forEach((environment) => {
invalidateModule(ctx.server, environment, "virtual:use-server-lookup.js");
});
invalidateModule(ctx.server, environment, VIRTUAL_SSR_PREFIX + "/@id/virtual:use-server-lookup.js");
invalidateModule(ctx.server, environment, VIRTUAL_SSR_PREFIX + "virtual:use-server-lookup.js");
}
// todo(justinvdm, 12 Dec 2024): Skip client references
const modules = Array.from(ctx.server.environments[environment].moduleGraph.getModulesByFile(ctx.file) ?? []);
const isWorkerUpdate = Boolean(modules);
// The worker needs an update, but this is the client environment
// => Notify for HMR update of any css files imported by in worker, that are also in the client module graph
// Why: There may have been changes to css classes referenced, which might css modules to change
if (this.environment.name === "client") {
if (isWorkerUpdate) {
for (const [_, module] of ctx.server.environments[environment]
.moduleGraph.idToModuleMap) {
// todo(justinvdm, 13 Dec 2024): We check+update _all_ css files in worker module graph,
// but it could just be a subset of css files that are actually affected, depending
// on the importers and imports of the changed file. We should be smarter about this.
if (module.file && module.file.endsWith(".css")) {
const clientModules = ctx.server.environments.client.moduleGraph.getModulesByFile(module.file);
for (const clientModule of clientModules ?? []) {
invalidateModule(ctx.server, "client", clientModule);
}
}
}
}
const isUseClientUpdate = isInUseClientGraph({
file: ctx.file,
clientFiles,
server: ctx.server,
});
if (!isUseClientUpdate && !ctx.file.endsWith(".css")) {
return [];
}
return ctx.modules;
}
// The worker needs an update, and the hot check is for the worker environment
// => Notify for custom RSC-based HMR update, then short circuit HMR
if (isWorkerUpdate && this.environment.name === environment) {
const shortName = getShortName(ctx.file, ctx.server.config.root);
this.environment.logger.info(`${colors.green(`worker update`)} ${colors.dim(shortName)}`, {
clear: true,
timestamp: true,
});
const m = ctx.server.environments.client.moduleGraph
.getModulesByFile(resolve(givenOptions.rootDir, "src", "app", "style.css"))
?.values()
.next().value;
if (m) {
invalidateModule(ctx.server, environment, m);
}
const virtualSSRModule = ctx.server.environments[environment].moduleGraph.idToModuleMap.get(VIRTUAL_SSR_PREFIX +
normalizeModulePath(ctx.file, givenOptions.rootDir));
if (virtualSSRModule) {
invalidateModule(ctx.server, environment, virtualSSRModule);
}
ctx.server.environments.client.hot.send({
type: "custom",
event: "rsc:update",
data: {
file: ctx.file,
},
});
return [];
}
},
},
];
function dumpFullModuleGraph(server, environment, { includeDisconnected = true } = {}) {
const moduleGraph = server.environments[environment].moduleGraph;
const seen = new Set();
const output = [];
function walk(node, depth = 0) {
const id = node.id || node.url;
if (!id || seen.has(id))
return;
seen.add(id);
const pad = " ".repeat(depth);
const suffix = node.id?.startsWith("virtual:") ? " [virtual]" : "";
output.push(`${pad}- ${id}${suffix}`);
for (const dep of node.importedModules) {
walk(dep, depth + 1);
}
}
// Start with all modules with no importers (roots)
const roots = Array.from(moduleGraph.urlToModuleMap.values()).filter((mod) => mod.importers.size === 0);
for (const root of roots) {
walk(root);
}
// If requested, show disconnected modules too
if (includeDisconnected) {
for (const mod of moduleGraph.urlToModuleMap.values()) {
const id = mod.id || mod.url;
if (!seen.has(id)) {
output.push(`- ${id} [disconnected]`);
}
}
}
return output.join("\n");
}