rwsdk
Version:
Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime
313 lines (312 loc) • 16.6 kB
JavaScript
import { join } from "path";
import * as fs from "fs/promises";
import { log } from "./constants.mjs";
import { getSmokeTestFunctionsTemplate } from "./templates/smokeTestFunctions.template";
import { getSmokeTestTemplate } from "./templates/SmokeTest.template";
import { getSmokeTestClientTemplate } from "./templates/SmokeTestClient.template";
import { template as smokeTestUrlStylesCssTemplate } from "./templates/smokeTestUrlStyles.css.template";
import { template as smokeTestClientStylesCssTemplate } from "./templates/smokeTestClientStyles.module.css.template";
import MagicString from "magic-string";
import { parse as parseJsonc } from "jsonc-parser";
/**
* Creates the smoke test components in the target project directory
*/
export async function createSmokeTestComponents(targetDir, skipClient = false) {
console.log("Creating smoke test components in project...");
// Create directories if they don't exist
const componentsDir = join(targetDir, "src", "app", "components");
log("Creating components directory: %s", componentsDir);
await fs.mkdir(componentsDir, { recursive: true });
// Create __smokeTestFunctions.ts
const smokeTestFunctionsPath = join(componentsDir, "__smokeTestFunctions.ts");
log("Creating __smokeTestFunctions.ts at: %s", smokeTestFunctionsPath);
const smokeTestFunctionsContent = getSmokeTestFunctionsTemplate();
// Create SmokeTest.tsx with conditional client component import
const smokeTestPath = join(componentsDir, "__SmokeTest.tsx");
log("Creating __SmokeTest.tsx at: %s", smokeTestPath);
const smokeTestContent = getSmokeTestTemplate(skipClient);
// Write the server files
log("Writing SmokeTestFunctions file");
await fs.writeFile(smokeTestFunctionsPath, smokeTestFunctionsContent);
log("Writing SmokeTest component file");
await fs.writeFile(smokeTestPath, smokeTestContent);
// Create smoke test stylesheet files
await createSmokeTestStylesheets(targetDir);
// Only create client component if not skipping client-side tests
if (!skipClient) {
// Create SmokeTestClient.tsx
const smokeTestClientPath = join(componentsDir, "__SmokeTestClient.tsx");
log("Creating __SmokeTestClient.tsx at: %s", smokeTestClientPath);
const smokeTestClientContent = getSmokeTestClientTemplate();
log("Writing SmokeTestClient component file");
await fs.writeFile(smokeTestClientPath, smokeTestClientContent);
log("Created client-side smoke test component");
}
else {
log("Skipping client-side smoke test component creation");
}
// Modify worker.tsx and wrangler.jsonc for realtime support
await modifyAppForRealtime(targetDir, skipClient);
log("Smoke test components created successfully");
console.log("Created smoke test components:");
console.log(`- ${smokeTestFunctionsPath}`);
console.log(`- ${smokeTestPath}`);
if (!skipClient) {
console.log(`- ${join(componentsDir, "__SmokeTestClient.tsx")}`);
console.log(`- ${join(componentsDir, "smokeTestClientStyles.module.css")}`);
}
else {
console.log("- Client component skipped (--skip-client was specified)");
}
console.log(`- ${join(targetDir, "src", "app", "smokeTestUrlStyles.css")}`);
}
export async function createSmokeTestStylesheets(targetDir) {
log("Creating smoke test stylesheets in project...");
// Create directories if they don't exist
const componentsDir = join(targetDir, "src", "app", "components");
const appDir = join(targetDir, "src", "app");
await fs.mkdir(componentsDir, { recursive: true });
await fs.mkdir(appDir, { recursive: true });
// Create smoke_tests_client_styles.module.css
const clientStylesPath = join(componentsDir, "smokeTestClientStyles.module.css");
log("Creating smokeTestClientStyles.module.css at: %s", clientStylesPath);
await fs.writeFile(clientStylesPath, smokeTestClientStylesCssTemplate);
// Create smoke_tests_url_styles.css
const urlStylesPath = join(appDir, "smokeTestUrlStyles.css");
log("Creating smokeTestUrlStyles.css at: %s", urlStylesPath);
await fs.writeFile(urlStylesPath, smokeTestUrlStylesCssTemplate);
// Modify Document.tsx to include the URL stylesheet using CSS URL import
const documentPath = join(appDir, "Document.tsx");
log("Modifying Document.tsx to include URL stylesheet at: %s", documentPath);
try {
const documentContent = await fs.readFile(documentPath, "utf-8");
const s = new MagicString(documentContent);
// Add the CSS URL import at the top of the file
const importMatch = documentContent.match(/^(import\s+.*?;\s*\n)*/m);
const insertPosition = importMatch ? importMatch[0].length : 0;
s.appendLeft(insertPosition, 'import smokeTestUrlStyles from "./smokeTestUrlStyles.css?url";\n');
// Add the link tag in the head using the imported variable
const headTagEnd = documentContent.indexOf("</head>");
if (headTagEnd !== -1) {
s.appendLeft(headTagEnd, ' <link rel="stylesheet" href={smokeTestUrlStyles} />\n');
await fs.writeFile(documentPath, s.toString(), "utf-8");
log("Successfully modified Document.tsx with CSS URL import pattern");
}
else {
log("Could not find </head> tag in Document.tsx");
}
}
catch (e) {
log("Could not modify Document.tsx: %s", e);
}
log("Smoke test stylesheets created successfully");
}
/**
* Modifies the worker.tsx and wrangler.jsonc files to add realtime support
*/
export async function modifyAppForRealtime(targetDir, skipClient = false) {
log("Modifying worker.tsx and wrangler.jsonc for realtime support");
// Modify worker.tsx
const workerPath = join(targetDir, "src", "worker.tsx");
if (await fs
.access(workerPath)
.then(() => true)
.catch(() => false)) {
log("Found worker.tsx, checking for realtime code");
const workerContent = await fs.readFile(workerPath, "utf-8");
// Check if the realtime export line already exists
const hasRealtimeExport = workerContent.includes('export { RealtimeDurableObject } from "rwsdk/realtime/durableObject"');
const hasRealtimeRoute = workerContent.includes("realtimeRoute(");
const hasEnvImport = workerContent.includes('import { env } from "cloudflare:workers"');
const hasSmokeTestImport = workerContent.includes('import { SmokeTest } from "@/app/components/__SmokeTest.tsx"');
const hasSmokeTestRoute = workerContent.includes('route("/__smoke_test", SmokeTest)');
if (!hasRealtimeExport ||
!hasRealtimeRoute ||
!hasEnvImport ||
!hasSmokeTestImport ||
!hasSmokeTestRoute) {
log("Need to modify worker.tsx for realtime and smoke test support");
const s = new MagicString(workerContent);
// Add the export line if it doesn't exist
if (!hasRealtimeExport) {
const importRegex = /import.*?from.*?;\n/g;
let lastImportMatch;
let lastImportPosition = 0;
// Find the position after the last import statement
while ((lastImportMatch = importRegex.exec(workerContent)) !== null) {
lastImportPosition =
lastImportMatch.index + lastImportMatch[0].length;
}
if (lastImportPosition > 0) {
s.appendRight(lastImportPosition, 'export { RealtimeDurableObject } from "rwsdk/realtime/durableObject";\n');
log("Added RealtimeDurableObject export");
}
}
// Add the env import if it doesn't exist
if (!hasEnvImport) {
const importRegex = /import.*?from.*?;\n/g;
let firstImportMatch = importRegex.exec(workerContent);
if (firstImportMatch) {
s.appendLeft(firstImportMatch.index, 'import { env } from "cloudflare:workers";\n');
log("Added env import from cloudflare:workers");
}
}
// Add SmokeTest import if it doesn't exist
if (!hasSmokeTestImport) {
const importRegex = /import.*?from.*?;\n/g;
let lastImportMatch;
let lastImportPosition = 0;
// Find the position after the last import statement
while ((lastImportMatch = importRegex.exec(workerContent)) !== null) {
lastImportPosition =
lastImportMatch.index + lastImportMatch[0].length;
}
if (lastImportPosition > 0) {
s.appendRight(lastImportPosition, 'import { SmokeTest } from "@/app/components/__SmokeTest";\n');
log("Added SmokeTest import");
}
else {
// if no imports found, just prepend to the file
s.prepend('import { SmokeTest } from "@/app/components/__SmokeTest";\n');
log("Added SmokeTest import to the beginning of the file");
}
}
// Add the realtimeRoute line if it doesn't exist
if (!hasRealtimeRoute) {
const defineAppMatch = workerContent.match(/export default defineApp\(\[/);
if (defineAppMatch && defineAppMatch.index !== undefined) {
const insertPosition = defineAppMatch.index + defineAppMatch[0].length;
s.appendRight(insertPosition, "\n realtimeRoute(() => env.REALTIME_DURABLE_OBJECT),");
log("Added realtimeRoute to defineApp");
}
}
// Add the smoke test route if it doesn't exist
if (!hasSmokeTestRoute) {
const defineAppRegex = /(export default defineApp\(\[)([\s\S]*)(\]\);)/m;
const match = workerContent.match(defineAppRegex);
if (match) {
const insertionPoint = match.index + match[1].length + match[2].length;
s.appendLeft(insertionPoint, ' render(Document, [route("/__smoke_test", SmokeTest)]),\n');
log("Added smoke test route to defineApp");
}
}
// Import realtimeRoute if it's not already imported
if (!workerContent.includes("realtimeRoute")) {
// First check if we already have the import from rwsdk/realtime/worker
const realtimeImportMatch = workerContent.match(/import.*?from "rwsdk\/realtime\/worker";/);
if (realtimeImportMatch) {
// If we have the import but not the specific function, add it
if (!realtimeImportMatch[0].includes("realtimeRoute")) {
s.replace(realtimeImportMatch[0], realtimeImportMatch[0].replace(/import (.*?) from "rwsdk\/realtime\/worker";/, (match, imports) => {
if (imports.includes("{") && imports.includes("}")) {
// It's a named import
return imports.includes("realtimeRoute")
? match
: match.replace(/\{ (.*?) \}/, `{ realtimeRoute, $1 }`);
}
else {
// It's a default import or something else
return `import { realtimeRoute } from "rwsdk/realtime/worker";${match}`;
}
}));
}
}
else {
// We don't have the rwsdk/realtime/worker import at all, add it
const importRegex = /import.*?from.*?;\n/g;
let lastImportMatch;
let lastImportPosition = 0;
// Find the position after the last import statement
while ((lastImportMatch = importRegex.exec(workerContent)) !== null) {
lastImportPosition =
lastImportMatch.index + lastImportMatch[0].length;
}
if (lastImportPosition > 0) {
s.appendRight(lastImportPosition, 'import { realtimeRoute } from "rwsdk/realtime/worker";\n');
log("Added realtimeRoute import from rwsdk/realtime/worker");
}
}
}
// Write the modified file
await fs.writeFile(workerPath, s.toString(), "utf-8");
log("Successfully modified worker.tsx");
}
else {
log("worker.tsx already has realtime and smoke test support, no changes needed");
}
}
else {
log("worker.tsx not found, skipping modification");
}
// Modify wrangler.jsonc
const wranglerPath = join(targetDir, "wrangler.jsonc");
if (await fs
.access(wranglerPath)
.then(() => true)
.catch(() => false)) {
log("Found wrangler.jsonc, checking for realtime durable objects");
const wranglerContent = await fs.readFile(wranglerPath, "utf-8");
const wranglerConfig = parseJsonc(wranglerContent);
let modified = false;
// Check if REALTIME_DURABLE_OBJECT already exists in durable_objects bindings
const hasDurableObjectBinding = wranglerConfig.durable_objects?.bindings?.some((binding) => binding.name === "REALTIME_DURABLE_OBJECT");
// Check if RealtimeDurableObject is already in migrations
const hasMigration = wranglerConfig.migrations?.some((migration) => migration.new_sqlite_classes?.includes("RealtimeDurableObject"));
if (!hasDurableObjectBinding || !hasMigration) {
log("Need to modify wrangler.jsonc for realtime support");
// Create a deep copy of the config to make modifications
const newConfig = JSON.parse(JSON.stringify(wranglerConfig));
// Add durable objects binding if needed
if (!hasDurableObjectBinding) {
if (!newConfig.durable_objects) {
newConfig.durable_objects = {};
}
if (!newConfig.durable_objects.bindings) {
newConfig.durable_objects.bindings = [];
}
newConfig.durable_objects.bindings.push({
name: "REALTIME_DURABLE_OBJECT",
class_name: "RealtimeDurableObject",
});
modified = true;
log("Added REALTIME_DURABLE_OBJECT to durable_objects bindings");
}
// Add migration if needed
if (!hasMigration) {
if (!newConfig.migrations) {
newConfig.migrations = [
{
tag: "v1",
new_sqlite_classes: ["RealtimeDurableObject"],
},
];
modified = true;
log("Added new migrations with RealtimeDurableObject");
}
else if (newConfig.migrations.length > 0) {
// Add RealtimeDurableObject to the first migration's sqlite classes
const firstMigration = newConfig.migrations[0];
if (!firstMigration.new_sqlite_classes) {
firstMigration.new_sqlite_classes = ["RealtimeDurableObject"];
}
else if (!firstMigration.new_sqlite_classes.includes("RealtimeDurableObject")) {
firstMigration.new_sqlite_classes.push("RealtimeDurableObject");
}
modified = true;
log("Added RealtimeDurableObject to existing migration");
}
}
if (modified) {
// Write the modified config back to the file
await fs.writeFile(wranglerPath, JSON.stringify(newConfig, null, 2), "utf-8");
log("Successfully modified wrangler.jsonc");
}
}
else {
log("wrangler.jsonc already has realtime support, no changes needed");
}
}
else {
log("wrangler.jsonc not found, skipping modification");
}
}