rwsdk
Version:
Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime
306 lines (305 loc) • 14.6 kB
JavaScript
// @ts-ignore
import { compile } from "@mdx-js/mdx";
import debug from "debug";
import { glob } from "glob";
import fsp from "node:fs/promises";
import path from "node:path";
import { INTERMEDIATES_OUTPUT_DIR } from "../lib/constants.mjs";
import { normalizeModulePath } from "../lib/normalizeModulePath.mjs";
import { externalModules } from "./constants.mjs";
import { createViteAwareResolver } from "./createViteAwareResolver.mjs";
import { getViteEsbuild } from "./getViteEsbuild.mjs";
import { hasDirective } from "./hasDirective.mjs";
const log = debug("rwsdk:vite:run-directives-scan");
// Copied from Vite's source code.
// https://github.com/vitejs/vite/blob/main/packages/vite/src/shared/utils.ts
const isObject = (value) => Object.prototype.toString.call(value) === "[object Object]";
// Copied from Vite's source code.
// https://github.com/vitejs/vite/blob/main/packages/vite/src/node/utils.ts
const externalRE = /^(https?:)?\/\//;
const isExternalUrl = (url) => externalRE.test(url);
async function findDirectiveRoots({ root, readFileWithCache, directiveCheckCache, }) {
const srcDir = path.resolve(root, "src");
const files = await glob("**/*.{ts,tsx,js,jsx,mjs,mts,cjs,cts,mdx}", {
cwd: srcDir,
absolute: true,
});
const directiveFiles = new Set();
for (const file of files) {
if (directiveCheckCache.has(file)) {
if (directiveCheckCache.get(file)) {
directiveFiles.add(file);
}
continue;
}
try {
const content = await readFileWithCache(file);
const hasClient = hasDirective(content, "use client");
const hasServer = hasDirective(content, "use server");
const hasAnyDirective = hasClient || hasServer;
directiveCheckCache.set(file, hasAnyDirective);
if (hasAnyDirective) {
directiveFiles.add(file);
}
}
catch (e) {
log("Could not read file during pre-scan, skipping:", file);
// Cache the failure to avoid re-reading a problematic file
directiveCheckCache.set(file, false);
}
}
log("Pre-scan found directive files:", Array.from(directiveFiles));
return directiveFiles;
}
export async function resolveModuleWithEnvironment({ path, importer, importerEnv, clientResolver, workerResolver, }) {
const resolver = importerEnv === "client" ? clientResolver : workerResolver;
return new Promise((resolvePromise) => {
resolver({}, importer || "", path, {}, (err, result) => {
if (!err && result) {
resolvePromise({ id: result });
}
else {
if (err) {
const errorMessage = err.message || String(err);
if (errorMessage.includes("Package path . is not exported")) {
log("Package exports error for %s, marking as external", path);
}
else {
log("Resolution failed for %s: %s", path, errorMessage);
}
}
resolvePromise(null);
}
});
});
}
export function classifyModule({ contents, inheritedEnv, }) {
let moduleEnv = inheritedEnv;
const isClient = hasDirective(contents, "use client");
const isServer = hasDirective(contents, "use server");
if (isClient) {
moduleEnv = "client";
}
else if (isServer) {
moduleEnv = "worker";
}
return { moduleEnv, isClient, isServer };
}
export const runDirectivesScan = async ({ rootConfig, environments, clientFiles, serverFiles, entries: initialEntries, }) => {
deferredLog("\n… (rwsdk) Scanning for 'use client' and 'use server' directives...");
// Set environment variable to indicate scanning is in progress
process.env.RWSDK_DIRECTIVE_SCAN_ACTIVE = "true";
try {
const fileContentCache = new Map();
const directiveCheckCache = new Map();
const readFileWithCache = async (path) => {
if (fileContentCache.has(path)) {
return fileContentCache.get(path);
}
const contents = await fsp.readFile(path, "utf-8");
fileContentCache.set(path, contents);
return contents;
};
const esbuild = await getViteEsbuild(rootConfig.root);
const input = initialEntries ?? environments.worker.config.build.rollupOptions?.input;
let entries;
if (Array.isArray(input)) {
entries = input;
}
else if (typeof input === "string") {
entries = [input];
}
else if (isObject(input)) {
entries = Object.values(input);
}
else {
entries = [];
}
if (entries.length === 0) {
log("No entries found for directives scan in worker environment, skipping.");
return;
}
// Filter out virtual modules since they can't be scanned by esbuild
const realEntries = entries.filter((entry) => !entry.includes("virtual:"));
const absoluteEntries = realEntries.map((entry) => path.resolve(rootConfig.root, entry));
const applicationDirectiveFiles = await findDirectiveRoots({
root: rootConfig.root,
readFileWithCache,
directiveCheckCache,
});
const combinedEntries = new Set([
...absoluteEntries,
...applicationDirectiveFiles,
]);
log("Starting directives scan with combined entries:", Array.from(combinedEntries));
const workerResolver = createViteAwareResolver(rootConfig, environments.worker);
const clientResolver = createViteAwareResolver(rootConfig, environments.client);
const moduleEnvironments = new Map();
const esbuildScanPlugin = {
name: "rwsdk:esbuild-scan-plugin",
setup(build) {
// Match Vite's behavior by externalizing assets and special queries.
// This prevents esbuild from trying to bundle them, which would fail.
const scriptFilter = /\.(c|m)?[jt]sx?$|\.mdx$/;
const specialQueryFilter = /[?&](?:url|raw|worker|sharedworker|inline)\b/;
// This regex is used to identify if a path has any file extension.
const hasExtensionRegex = /\.[^/]+$/;
build.onResolve({ filter: specialQueryFilter }, (args) => {
log("Externalizing special query:", args.path);
return { external: true };
});
build.onResolve({ filter: /.*/, namespace: "file" }, (args) => {
// Externalize if the path has an extension AND that extension is not a
// script extension. Extensionless paths are assumed to be scripts and
// are allowed to pass through for resolution.
if (hasExtensionRegex.test(args.path) &&
!scriptFilter.test(args.path)) {
log("Externalizing non-script import:", args.path);
return { external: true };
}
});
build.onResolve({ filter: /.*/ }, async (args) => {
if (externalModules.includes(args.path)) {
return { external: true };
}
log("onResolve called for:", args.path, "from:", args.importer);
let importerEnv = moduleEnvironments.get(args.importer);
// If we don't know the importer's environment yet, check its content
if (!importerEnv &&
args.importer &&
/\.(m|c)?[jt]sx?$/.test(args.importer)) {
try {
const importerContents = await readFileWithCache(args.importer);
const classification = classifyModule({
contents: importerContents,
inheritedEnv: "worker", // Default for entry points
});
importerEnv = classification.moduleEnv;
log("Pre-detected importer environment in:", args.importer, "as", importerEnv);
moduleEnvironments.set(args.importer, importerEnv);
}
catch (e) {
importerEnv = "worker"; // Default fallback
log("Could not pre-read importer, using worker environment:", args.importer);
}
}
else if (!importerEnv) {
importerEnv = "worker"; // Default for entry points or non-script files
}
log("Importer:", args.importer, "environment:", importerEnv);
const resolved = await resolveModuleWithEnvironment({
path: args.path,
importer: args.importer,
importerEnv,
clientResolver,
workerResolver,
});
log("Resolution result:", resolved);
const resolvedPath = resolved?.id;
if (resolvedPath && path.isAbsolute(resolvedPath)) {
// Normalize the path for esbuild compatibility
const normalizedPath = normalizeModulePath(resolvedPath, rootConfig.root, { absolute: true });
log("Normalized path:", normalizedPath);
return {
path: normalizedPath,
pluginData: { inheritedEnv: importerEnv },
};
}
log("Marking as external:", args.path, "resolved to:", resolvedPath);
return { external: true };
});
build.onLoad({ filter: /\.(m|c)?[jt]sx?$|\.mdx$/ }, async (args) => {
log("onLoad called for:", args.path);
if (!path.isAbsolute(args.path) ||
args.path.includes("virtual:") ||
isExternalUrl(args.path)) {
log("Skipping file due to filter:", args.path, {
isAbsolute: path.isAbsolute(args.path),
hasVirtual: args.path.includes("virtual:"),
isExternal: isExternalUrl(args.path),
});
return null;
}
try {
const originalContents = await readFileWithCache(args.path);
const inheritedEnv = args.pluginData?.inheritedEnv || "worker";
const { moduleEnv, isClient, isServer } = classifyModule({
contents: originalContents,
inheritedEnv,
});
// Store the definitive environment for this module, so it can be used when it becomes an importer.
const realPath = await fsp.realpath(args.path);
moduleEnvironments.set(realPath, moduleEnv);
log("Set environment for", realPath, "to", moduleEnv);
// Finally, populate the output sets if the file has a directive.
if (isClient) {
log("Discovered 'use client' in:", realPath);
clientFiles.add(normalizeModulePath(realPath, rootConfig.root));
}
if (isServer) {
log("Discovered 'use server' in:", realPath);
serverFiles.add(normalizeModulePath(realPath, rootConfig.root));
}
let code;
let loader;
if (args.path.endsWith(".mdx")) {
const result = await compile(originalContents, {
jsx: true,
jsxImportSource: "react",
});
code = String(result.value);
loader = "tsx";
}
else if (/\.(m|c)?tsx$/.test(args.path)) {
code = originalContents;
loader = "tsx";
}
else if (/\.(m|c)?ts$/.test(args.path)) {
code = originalContents;
loader = "ts";
}
else if (/\.(m|c)?jsx$/.test(args.path)) {
code = originalContents;
loader = "jsx";
}
else {
code = originalContents;
loader = "js";
}
return { contents: code, loader };
}
catch (e) {
log("Could not read file during scan, skipping:", args.path, e);
return null;
}
});
},
};
await esbuild.build({
entryPoints: Array.from(combinedEntries),
bundle: true,
write: false,
outdir: path.join(INTERMEDIATES_OUTPUT_DIR, "directive-scan"),
platform: "node",
format: "esm",
logLevel: "silent",
plugins: [esbuildScanPlugin],
});
}
catch (e) {
throw new Error(`RWSDK directive scan failed:\n${e.stack}`);
}
finally {
// Always clear the scanning flag when done
delete process.env.RWSDK_DIRECTIVE_SCAN_ACTIVE;
deferredLog("✔ (rwsdk) Done scanning for 'use client' and 'use server' directives.");
process.env.VERBOSE &&
log("Client/server files after scanning: client=%O, server=%O", Array.from(clientFiles), Array.from(serverFiles));
}
};
const deferredLog = (message) => {
const doLog = process.env.RWSDK_WORKER_RUN ? log : console.log;
setTimeout(() => {
doLog(message);
}, 500);
};