rwsdk
Version:
Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime
552 lines (551 loc) • 26.3 kB
JavaScript
import { join, basename } from "path";
import { setTimeout } from "node:timers/promises";
import { log } from "./constants.mjs";
import { checkUrl, checkServerUp } from "./browser.mjs";
import { $ } from "../../lib/$.mjs";
import { execaCommand } from "execa";
import { existsSync, readFileSync } from "fs";
import { pathExists } from "fs-extra";
import { parse as parseJsonc } from "jsonc-parser";
import * as fs from "fs/promises";
import { extractLastJson, parseJson } from "../../lib/jsonUtils.mjs";
/**
* A mini expect-like utility for handling interactive CLI prompts and verifying output
* @param command The command to execute
* @param expectations Array of {expect, send} objects for interactive responses and verification
* @param options Additional options for command execution including working directory and environment
* @returns Promise that resolves when the command completes
*/
export async function $expect(command, expectations, options = {
reject: true,
}) {
return new Promise((resolve, reject) => {
log("$expect starting with command: %s", command);
log("Working directory: %s", options.cwd ?? process.cwd());
log("Expected patterns: %O", expectations.map((e) => e.expect.toString()));
console.log(`Running command: ${command}`);
// Spawn the process with pipes for interaction
const childProcess = execaCommand(command, {
cwd: options.cwd ?? process.cwd(),
stdio: "pipe",
reject: false, // Never reject so we can handle the error ourselves
env: options.env ?? process.env,
});
log("Process spawned with PID: %s", childProcess.pid);
let stdout = "";
let stderr = "";
let buffer = "";
let lastMatchIndex = 0; // Track the index where the last match occurred
// Track patterns that have been matched
const matchHistory = new Map();
// Track current expectation index to process them in order
let currentExpectationIndex = 0;
// Initialize match count for each pattern
expectations.forEach(({ expect: expectPattern }) => {
matchHistory.set(expectPattern, 0);
log("Initialized pattern match count for: %s", expectPattern.toString());
});
// Collect stdout
childProcess.stdout?.on("data", (data) => {
const chunk = data.toString();
stdout += chunk;
buffer += chunk;
// Print to console
process.stdout.write(chunk);
// Only process expectations that haven't been fully matched yet
// and in the order they were provided
while (currentExpectationIndex < expectations.length) {
const { expect: expectPattern, send } = expectations[currentExpectationIndex];
const pattern = expectPattern instanceof RegExp
? expectPattern
: new RegExp(expectPattern, "m");
// Only search in the unmatched portion of the buffer
const searchBuffer = buffer.substring(lastMatchIndex);
log("Testing pattern: %s against buffer from position %d (%d chars)", pattern.toString(), lastMatchIndex, searchBuffer.length);
// Enhanced debugging: show actual search buffer content
log("Search buffer content for debugging: %O", searchBuffer);
const match = searchBuffer.match(pattern);
if (match) {
// Found a match
const patternStr = expectPattern.toString();
const matchCount = matchHistory.get(expectPattern) || 0;
// Update the lastMatchIndex to point after this match
// Calculate the absolute position in the full buffer
const matchStartPosition = lastMatchIndex + match.index;
const matchEndPosition = matchStartPosition + match[0].length;
lastMatchIndex = matchEndPosition;
log(`Pattern matched: "${patternStr}" (occurrence #${matchCount + 1}) at position ${matchStartPosition}-${matchEndPosition}`);
// Only send a response if one is specified
if (send) {
log(`Sending response: "${send.replace(/\r/g, "\\r")}" to stdin`);
childProcess.stdin?.write(send);
}
else {
log(`Pattern "${patternStr}" matched (verification only)`);
}
// Increment the match count for this pattern
matchHistory.set(expectPattern, matchCount + 1);
log("Updated match count for %s: %d", patternStr, matchCount + 1);
// Move to the next expectation
currentExpectationIndex++;
// If we've processed all expectations but need to wait for stdin response,
// delay closing stdin until the next data event
if (currentExpectationIndex >= expectations.length && send) {
log("All patterns matched, closing stdin after last response");
childProcess.stdin?.end();
}
break; // Exit the while loop to process next chunk
}
else {
log("Pattern not matched. Attempting to diagnose the mismatch:");
// Try to find the closest substring that might partially match
const patternString = pattern.toString();
const patternCore = patternString.substring(1, patternString.lastIndexOf("/") > 0
? patternString.lastIndexOf("/")
: patternString.length);
// Try partial matches to diagnose the issue
for (let i = 3; i < patternCore.length; i++) {
const partialPattern = patternCore.substring(0, i);
const partialRegex = new RegExp(partialPattern, "m");
const matches = partialRegex.test(searchBuffer);
log(" Partial pattern '%s': %s", partialPattern, matches ? "matched" : "not matched");
// Once we find where the matching starts to fail, stop
if (!matches)
break;
}
// Break the while loop as this pattern doesn't match yet
break;
}
}
// If all expectations have been matched, we can close stdin if not already closed
if (currentExpectationIndex >= expectations.length &&
childProcess.stdin?.writable) {
log("All patterns matched, ensuring stdin is closed");
childProcess.stdin.end();
}
});
// Collect stderr if needed
if (childProcess.stderr) {
childProcess.stderr.on("data", (data) => {
const chunk = data.toString();
stderr += chunk;
// Also write stderr to console
process.stderr.write(chunk);
});
}
// Handle process completion
childProcess.on("close", (code) => {
log("Process closed with code: %s", code);
// Log the number of matches for each pattern
log("Pattern match summary:");
for (const [pattern, count] of matchHistory.entries()) {
log(` - "${pattern.toString()}": ${count} matches`);
}
// Check if any required patterns were not matched
const unmatchedPatterns = Array.from(matchHistory.entries())
.filter(([_, count]) => count === 0)
.map(([pattern, _]) => pattern.toString());
if (unmatchedPatterns.length > 0) {
log("WARNING: Some expected patterns were not matched: %O", unmatchedPatterns);
}
log("$expect completed. Total stdout: %d bytes, stderr: %d bytes", stdout.length, stderr.length);
resolve({ stdout, stderr, code });
});
childProcess.on("error", (err) => {
log("Process error: %O", err);
if (options.reject) {
reject(new Error(`Failed to execute command: ${err.message}`));
}
else {
resolve({ stdout, stderr, code: null });
}
});
});
}
/**
* Ensures Cloudflare account ID is set in environment
* First checks wrangler cache, then environment variables, and finally guides the user
*/
export async function ensureCloudflareAccountId(cwd, projectDir) {
// Skip if already set
if (process.env.CLOUDFLARE_ACCOUNT_ID) {
log("CLOUDFLARE_ACCOUNT_ID is already set: %s", process.env.CLOUDFLARE_ACCOUNT_ID);
console.log(`Using existing CLOUDFLARE_ACCOUNT_ID: ${process.env.CLOUDFLARE_ACCOUNT_ID}`);
return;
}
console.log("CLOUDFLARE_ACCOUNT_ID not set, checking wrangler cache...");
try {
// Check wrangler cache in the project directory, not the current working directory
projectDir = projectDir || cwd || process.cwd();
log("Looking for wrangler cache in project directory: %s", projectDir);
const accountCachePath = join(projectDir, "node_modules/.cache/wrangler/wrangler-account.json");
if (existsSync(accountCachePath)) {
try {
const accountCache = JSON.parse(readFileSync(accountCachePath, "utf8"));
if (accountCache.account?.id) {
const accountId = accountCache.account.id;
process.env.CLOUDFLARE_ACCOUNT_ID = accountId;
log("Found CLOUDFLARE_ACCOUNT_ID in wrangler cache: %s", accountId);
console.log(`✅ Setting CLOUDFLARE_ACCOUNT_ID to ${accountId} (from wrangler cache)`);
return;
}
}
catch (parseError) {
log("Failed to parse wrangler account cache: %O", parseError);
// Continue to other methods if cache parsing fails
}
}
else {
log("Wrangler account cache not found at: %s", accountCachePath);
}
// If we get here, we couldn't find the account ID in the cache
// Give clear guidance to the user
console.log("⚠️ Could not find Cloudflare account ID");
console.log("Please either:");
console.log(" 1. Run 'npx wrangler login' to authenticate with Cloudflare");
console.log(" 2. Set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN environment variables");
// Try wrangler whoami as a final attempt
console.log("\nAttempting to get account info from wrangler...");
const result = await $({
cwd: projectDir,
stdio: "pipe",
}) `npx wrangler whoami`;
// First try regex pattern matching on the text output
if (result.stdout) {
const accountIdMatch = result.stdout.match(/Account ID: ([a-f0-9]{32})/);
if (accountIdMatch && accountIdMatch[1]) {
const accountId = accountIdMatch[1];
process.env.CLOUDFLARE_ACCOUNT_ID = accountId;
log("Extracted CLOUDFLARE_ACCOUNT_ID from whoami text: %s", accountId);
console.log(`✅ Setting CLOUDFLARE_ACCOUNT_ID to ${accountId} (from wrangler whoami)`);
return;
}
}
// Fallback: try to extract any JSON that might be in the output
const accountInfo = extractLastJson(result.stdout);
if (accountInfo && accountInfo.account && accountInfo.account.id) {
const accountId = accountInfo.account.id;
process.env.CLOUDFLARE_ACCOUNT_ID = accountId;
log("Extracted CLOUDFLARE_ACCOUNT_ID from whoami JSON: %s", accountId);
console.log(`✅ Setting CLOUDFLARE_ACCOUNT_ID to ${accountId} (from wrangler whoami)`);
return;
}
// If we get here, we've exhausted all options
throw new Error("Could not find Cloudflare account ID. Please login with 'npx wrangler login' or set CLOUDFLARE_ACCOUNT_ID manually.");
}
catch (error) {
log("Error during account ID detection: %O", error);
throw error;
}
}
/**
* Run the release command to deploy to Cloudflare
*/
export async function runRelease(cwd, projectDir, resourceUniqueKey) {
log("Running release command");
console.log("\n🚀 Deploying worker to Cloudflare...");
try {
// Make sure we have an account ID
await ensureCloudflareAccountId(cwd, projectDir);
// Extract worker name from directory name to ensure consistency
const dirName = cwd ? basename(cwd) : "unknown-worker";
// Ensure resource unique key is included in worker name for tracking
if (resourceUniqueKey && !dirName.includes(resourceUniqueKey)) {
log(`Worker name doesn't contain our unique key, this is unexpected: ${dirName}, key: ${resourceUniqueKey}`);
console.log(`⚠️ Worker name doesn't contain our unique key. This might cause cleanup issues.`);
}
// Ensure the worker name in wrangler.jsonc matches our unique name
if (cwd) {
try {
const wranglerPath = join(cwd, "wrangler.jsonc");
if (await pathExists(wranglerPath)) {
log("Updating wrangler.jsonc to use our unique worker name: %s", dirName);
// Read the wrangler config - handle both jsonc and json formats
const wranglerContent = await fs.readFile(wranglerPath, "utf-8");
// Use parseJsonc which handles comments and is more tolerant
let wranglerConfig;
try {
wranglerConfig = parseJsonc(wranglerContent);
}
catch (parseError) {
// Fallback to standard JSON if jsonc parsing fails
log("JSONC parsing failed, trying standard JSON: %O", parseError);
wranglerConfig = JSON.parse(wranglerContent);
}
// Update the name
if (wranglerConfig.name !== dirName) {
wranglerConfig.name = dirName;
await fs.writeFile(wranglerPath, JSON.stringify(wranglerConfig, null, 2));
log("Updated wrangler.jsonc with unique worker name: %s", dirName);
}
}
}
catch (error) {
log("Error updating wrangler.jsonc: %O", error);
console.error(`Warning: Could not update wrangler.jsonc: ${error}`);
}
}
// Run release command with our interactive $expect utility and retry logic
log("Running release command with interactive prompts and retries");
const MAX_RETRIES = 3;
let lastError = null;
let result = null;
for (let i = 0; i < MAX_RETRIES; i++) {
try {
console.log(`\n🚀 Deploying worker to Cloudflare (Attempt ${i + 1}/${MAX_RETRIES})...`);
result = await $expect("npm run release", [
{
// Make the pattern more flexible to account for potential whitespace differences
expect: /Do you want to proceed with deployment\?\s*\(y\/N\)/i,
send: "y\r",
},
], {
reject: false, // Add reject: false to prevent uncaught promise rejections
env: {
RWSDK_RENAME_WORKER: "1",
RWSDK_RENAME_DB: "1",
...process.env,
},
cwd,
});
// Check exit code to ensure command succeeded
if (result.code === 0) {
log(`Release command succeeded on attempt ${i + 1}`);
lastError = null; // Clear last error on success
break; // Exit the loop on success
}
else {
throw new Error(`Release command failed with exit code ${result.code}`);
}
}
catch (error) {
lastError = error;
log(`Attempt ${i + 1} failed: ${lastError.message}`);
if (i < MAX_RETRIES - 1) {
console.log(` Waiting 5 seconds before retrying...`);
await setTimeout(5000);
}
}
}
if (lastError || !result) {
log("ERROR: Release command failed after all retries.");
throw lastError || new Error("Release command failed after all retries.");
}
// Check exit code to ensure command succeeded
if (result.code !== 0) {
// Add more contextual information about the error
let errorMessage = `Release command failed with exit code ${result.code}`;
// Add stderr output to the error message if available
if (result.stderr && result.stderr.trim().length > 0) {
// Extract the most relevant part of the error message
const errorLines = result.stderr
.split("\n")
.filter((line) => line.includes("ERROR") ||
line.includes("error:") ||
line.includes("failed"))
.slice(0, 3) // Take just the first few error lines
.join("\n");
if (errorLines) {
errorMessage += `\nError details: ${errorLines}`;
}
}
log("ERROR: %s", errorMessage);
throw new Error(errorMessage);
}
const stdout = result.stdout;
// Extract deployment URL from output
log("Extracting deployment URL from output");
const urlMatch = stdout.match(/https:\/\/([a-zA-Z0-9-]+)\.redwoodjs\.workers\.dev/);
if (!urlMatch || !urlMatch[0]) {
log("ERROR: Could not extract deployment URL from release output");
// Log more details about the output for debugging
log("Release command stdout: %s", stdout);
if (result.stderr) {
log("Release command stderr: %s", result.stderr);
}
throw new Error("Could not extract deployment URL from release output");
}
const url = urlMatch[0];
const workerName = urlMatch[1];
log("Successfully deployed to %s (worker: %s)", url, workerName);
console.log(`✅ Successfully deployed to ${url}`);
return { url, workerName };
}
catch (error) {
log("ERROR: Failed to run release command: %O", error);
throw error;
}
}
/**
* Runs tests against the production deployment
*/
export async function runReleaseTest(artifactDir, resources, browserPath, headless = true, bail = false, skipClient = false, projectDir, realtime = false, skipHmr = false, skipStyleTests = false) {
log("Starting release test");
console.log("\n🚀 Testing production deployment");
try {
log("Running release process");
const { url, workerName } = await runRelease(resources.targetDir || "", projectDir || "", resources.resourceUniqueKey);
// Wait a moment before checking server availability
log("Waiting 1s before checking server...");
await setTimeout(1000);
// DRY: check both root and custom path
await checkServerUp(url, "/");
// Now run the tests with the custom path
const testUrl = new URL("/__smoke_test", url).toString();
await checkUrl(testUrl, artifactDir, browserPath, headless, bail, skipClient, "Production", realtime, resources.targetDir, // Add target directory parameter
true, // Always skip HMR in production
skipStyleTests);
log("Release test completed successfully");
// Store the worker name if we didn't set it earlier
if (resources && !resources.workerName) {
log("Storing worker name: %s", workerName);
resources.workerName = workerName;
}
// Mark that we created this worker during the test
if (resources) {
log("Marking worker %s as created during this test", workerName);
resources.workerCreatedDuringTest = true;
// Update the global state
if (resources.workerCreatedDuringTest !== undefined) {
resources.workerCreatedDuringTest = true;
}
}
}
catch (error) {
log("Error during release testing: %O", error);
throw error;
}
}
/**
* Check if a resource name includes a specific resource unique key
* This is used to identify resources created during our tests
*/
export function isRelatedToTest(resourceName, resourceUniqueKey) {
return resourceName.includes(resourceUniqueKey);
}
/**
* Delete the worker using wrangler
*/
export async function deleteWorker(name, cwd, resourceUniqueKey) {
console.log(`Cleaning up: Deleting worker ${name}...`);
// Safety check: if we have a resourceUniqueKey, verify this worker name contains it
if (resourceUniqueKey && !isRelatedToTest(name, resourceUniqueKey)) {
log(`Worker ${name} does not contain unique key ${resourceUniqueKey}, not deleting for safety`);
console.log(`⚠️ Worker ${name} does not seem to be created by this test, skipping deletion for safety`);
return;
}
try {
// Use our $expect utility to handle any confirmation prompts
log("Running wrangler delete command with interactive prompts");
await $expect(`npx wrangler delete ${name}`, [
{
expect: "Are you sure you want to delete",
send: "y\r",
},
], {
cwd,
});
console.log(`✅ Worker ${name} deleted successfully`);
}
catch (error) {
console.error(`Failed to delete worker ${name}: ${error}`);
// Retry with force flag if the first attempt failed
try {
console.log("Retrying with force flag...");
await $expect(`npx wrangler delete ${name} --yes --force`, [
{
expect: "Are you sure you want to delete",
send: "y\r",
},
], {
cwd,
});
console.log(`✅ Worker ${name} force deleted successfully`);
}
catch (retryError) {
console.error(`Failed to force delete worker ${name}: ${retryError}`);
}
}
}
/**
* List D1 databases using wrangler
*/
export async function listD1Databases(cwd) {
log("Listing D1 databases");
try {
const result = await $({
cwd,
stdio: "pipe",
}) `npx wrangler d1 list --json`;
// Parse the JSON output to extract the last valid JSON
const data = parseJson(result.stdout, []);
if (Array.isArray(data)) {
log("Found %d D1 databases in parsed array", data.length);
return data;
}
else if (data.databases && Array.isArray(data.databases)) {
log("Found %d D1 databases in 'databases' property", data.databases.length);
return data.databases;
}
// If nothing worked, return an empty array
log("Could not parse JSON from output, returning empty array");
return [];
}
catch (error) {
log("Error listing D1 databases: %O", error);
console.error(`Failed to list D1 databases: ${error}`);
return [];
}
}
/**
* Delete a D1 database using wrangler
*/
export async function deleteD1Database(name, cwd, resourceUniqueKey) {
console.log(`Cleaning up: Deleting D1 database ${name}...`);
try {
// First check if the database exists
const databases = await listD1Databases(cwd);
const exists = databases.some((db) => db.name === name);
if (!exists) {
log(`D1 database ${name} not found, skipping deletion`);
console.log(`⚠️ D1 database ${name} not found, skipping deletion`);
return;
}
// Extra safety check: if we have a resourceUniqueKey, verify this database is related to our test
if (resourceUniqueKey && !isRelatedToTest(name, resourceUniqueKey)) {
log(`D1 database ${name} does not contain unique key ${resourceUniqueKey}, not deleting for safety`);
console.log(`⚠️ D1 database ${name} does not seem to be created by this test, skipping deletion for safety`);
return;
}
// Use our $expect utility to handle any confirmation prompts
log("Running wrangler d1 delete command with interactive prompts");
await $expect(`npx wrangler d1 delete ${name}`, [
{
expect: "Are you sure you want to delete",
send: "y\r",
},
], {
cwd,
});
console.log(`✅ D1 database ${name} deleted successfully`);
}
catch (error) {
console.error(`Failed to delete D1 database ${name}: ${error}`);
// Retry with force flag if the first attempt failed
try {
console.log("Retrying with force flag...");
await $expect(`npx wrangler d1 delete ${name} --yes --force`, [
{
expect: "Are you sure you want to delete",
send: "y\r",
},
], {
cwd,
});
console.log(`✅ D1 database ${name} force deleted successfully`);
}
catch (retryError) {
console.error(`Failed to force delete D1 database ${name}: ${retryError}`);
}
}
}