UNPKG

rwsdk

Version:

Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime

344 lines (343 loc) 13.4 kB
import chokidar from "chokidar"; import { $ } from "execa"; import { existsSync } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { lock } from "proper-lockfile"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const cleanupViteEntries = async (targetDir) => { const nodeModulesDir = path.join(targetDir, "node_modules"); if (!existsSync(nodeModulesDir)) { return; } try { const entries = await fs.readdir(nodeModulesDir); const viteEntries = entries.filter((entry) => entry.startsWith(".vite")); for (const entry of viteEntries) { const entryPath = path.join(nodeModulesDir, entry); try { const stat = await fs.lstat(entryPath); if (!stat.isSymbolicLink()) { console.log(`Removing vite cache entry: ${entry}`); await fs.rm(entryPath, { recursive: true, force: true }); } else { console.log(`Skipping symlinked vite cache entry: ${entry}`); } } catch { // If we can't stat it, try to remove it console.log(`Removing vite cache entry: ${entry}`); await fs.rm(entryPath, { recursive: true, force: true }); } } } catch (error) { console.log(`Failed to cleanup vite cache entries: ${error}`); } }; const syncFilesWithRsyncOrFs = async (sdkDir, destDir, filesEntries) => { const sources = filesEntries.map((p) => path.join(sdkDir, p)); // Always include package.json in sync const pkgJsonPath = path.join(sdkDir, "package.json"); sources.push(pkgJsonPath); await fs.mkdir(destDir, { recursive: true }); // Try rsync across all sources in one shot try { if (sources.length > 0) { const rsyncArgs = [ "-a", "--delete", "--omit-dir-times", "--no-perms", "--no-owner", "--no-group", ...sources, destDir + path.sep, ]; await $({ stdio: "inherit" })("rsync", rsyncArgs); return; } } catch { // fall through to fs fallback } console.log("Rsync failed, falling back to fs"); // Fallback: destructive copy using Node fs to mirror content await fs.rm(destDir, { recursive: true, force: true }); await fs.mkdir(destDir, { recursive: true }); for (const src of sources) { const rel = path.relative(sdkDir, src); const dst = path.join(destDir, rel); await fs.mkdir(path.dirname(dst), { recursive: true }); try { const stat = await fs.lstat(src); if (stat.isDirectory()) { await fs.cp(src, dst, { recursive: true, force: true }); } else { await fs.copyFile(src, dst); } } catch { await fs.cp(src, dst, { recursive: true, force: true }).catch(() => { }); } } }; const findUp = async (names, startDir) => { let dir = startDir; while (dir !== path.dirname(dir)) { for (const name of names) { const filePath = path.join(dir, name); if (existsSync(filePath)) { return dir; } } dir = path.dirname(dir); } return undefined; }; const getMonorepoRoot = async (startDir) => { return await findUp(["pnpm-workspace.yaml"], startDir); }; const areDependenciesEqual = (deps1, deps2) => { // Simple string comparison for this use case is sufficient return JSON.stringify(deps1 ?? {}) === JSON.stringify(deps2 ?? {}); }; const isPlaygroundExample = async (targetDir, monorepoRoot) => { const pkgJson = JSON.parse(await fs.readFile(path.join(monorepoRoot, "package.json"), "utf-8")); if (pkgJson.name === "rw-sdk-monorepo") { const playgroundDir = path.join(monorepoRoot, "playground"); return targetDir.startsWith(playgroundDir); } return false; }; const performFullSync = async (sdkDir, targetDir, monorepoRoot) => { console.log("📦 Performing full sync with tarball..."); let tarballPath = ""; const projectName = path.basename(targetDir); const rwsyncDir = path.join(monorepoRoot, "node_modules", `.rwsync_${projectName}`); try { // 1. Pack the SDK const packResult = await $({ cwd: sdkDir }) `npm pack --json`; const json = JSON.parse(packResult.stdout || "[]"); const packInfo = Array.isArray(json) ? json[0] : undefined; const tarballName = (packInfo && (packInfo.filename || packInfo.name)) || ""; if (!tarballName) { throw new Error("Failed to get tarball name from npm pack."); } tarballPath = path.resolve(sdkDir, tarballName); // 2. Prepare isolated install directory console.log(`Preparing isolated install directory at ${rwsyncDir}`); await fs.rm(rwsyncDir, { recursive: true, force: true }); await fs.mkdir(rwsyncDir, { recursive: true }); await fs.writeFile(path.join(rwsyncDir, "package.json"), JSON.stringify({ name: `rwsync-env-${projectName}` }, null, 2)); // 3. Perform isolated install console.log(`Installing ${tarballName} in isolation...`); await $("pnpm", ["add", tarballPath], { cwd: rwsyncDir, stdio: "inherit", }); // 4. Create symlink const symlinkSource = path.join(rwsyncDir, "node_modules", "rwsdk"); const symlinkTarget = path.join(targetDir, "node_modules", "rwsdk"); console.log(`Symlinking ${symlinkTarget} -> ${symlinkSource}`); await fs.rm(symlinkTarget, { recursive: true, force: true }); await fs.mkdir(path.dirname(symlinkTarget), { recursive: true }); await fs.symlink(symlinkSource, symlinkTarget, "dir"); } finally { if (tarballPath) { console.log("Removing tarball..."); await fs.unlink(tarballPath).catch(() => { }); } } }; const performFastSync = async (sdkDir, targetDir, monorepoRoot) => { console.log("⚡️ Performing fast sync with rsync..."); const projectName = path.basename(targetDir); const rwsyncDir = path.join(monorepoRoot, "node_modules", `.rwsync_${projectName}`); const syncDestDir = path.join(rwsyncDir, "node_modules", "rwsdk"); // Copy directories/files declared in package.json#files (plus package.json) const filesToSync = JSON.parse(await fs.readFile(path.join(sdkDir, "package.json"), "utf-8")) .files || []; await syncFilesWithRsyncOrFs(sdkDir, syncDestDir, filesToSync); }; const performSync = async (sdkDir, targetDir) => { console.log("🏗️ Rebuilding SDK..."); await $ `pnpm build`; // Clean up vite cache in the target project await cleanupViteEntries(targetDir); const monorepoRoot = await getMonorepoRoot(targetDir); const rootDir = monorepoRoot ?? targetDir; const projectName = path.basename(targetDir); if (monorepoRoot && (await isPlaygroundExample(targetDir, monorepoRoot))) { console.log("Playground example detected. Skipping file sync; workspace linking will be used."); console.log("✅ Done syncing"); return; } const installedSdkPackageJsonPath = path.join(rootDir, "node_modules", `.rwsync_${projectName}`, "node_modules", "rwsdk", "package.json"); let needsFullSync = false; if (!existsSync(installedSdkPackageJsonPath)) { console.log("No previous sync found, performing full sync."); needsFullSync = true; } else { const sdkPackageJson = JSON.parse(await fs.readFile(path.join(sdkDir, "package.json"), "utf-8")); const installedSdkPackageJson = JSON.parse(await fs.readFile(installedSdkPackageJsonPath, "utf-8")); if (!areDependenciesEqual(sdkPackageJson.dependencies, installedSdkPackageJson.dependencies) || !areDependenciesEqual(sdkPackageJson.devDependencies, installedSdkPackageJson.devDependencies)) { console.log("Dependency changes detected, performing full sync."); needsFullSync = true; } } if (needsFullSync) { await performFullSync(sdkDir, targetDir, rootDir); } else { await performFastSync(sdkDir, targetDir, rootDir); } console.log("✅ Done syncing"); }; export const debugSync = async (opts) => { const { targetDir, sdkDir = process.cwd(), watch } = opts; if (!targetDir) { console.error("❌ Please provide a target directory as an argument."); process.exit(1); } // If not in watch mode, just do a one-time sync and exit. if (!watch) { await performSync(sdkDir, targetDir); return; } // --- Watch Mode Logic --- const lockfilePath = path.join(targetDir, "node_modules", ".rwsync.lock"); let release; // Ensure the directory for the lockfile exists await fs.mkdir(path.dirname(lockfilePath), { recursive: true }); // "Touch" the file to ensure it exists before locking await fs.appendFile(lockfilePath, "").catch(() => { }); try { release = await lock(lockfilePath, { retries: 0 }); } catch (e) { if (e.code === "ELOCKED") { console.error(`❌ Another rwsync process is already watching ${targetDir}.`); console.error(` If this is not correct, please remove the lockfile at ${lockfilePath}`); process.exit(1); } throw e; } // Initial sync for watch mode. We do it *after* acquiring the lock. try { await performSync(sdkDir, targetDir); } catch (error) { console.error("❌ Initial sync failed:", error); console.log(" Still watching for changes..."); } const filesToWatch = [ path.join(sdkDir, "src"), path.join(sdkDir, "types"), path.join(sdkDir, "bin"), path.join(sdkDir, "package.json"), ]; console.log("👀 Watching for changes..."); let childProc = null; const runWatchedCommand = () => { if (typeof watch === "string") { console.log(`\n> ${watch}\n`); childProc = $({ stdio: "inherit", shell: true, cwd: targetDir, reject: false, }) `${watch}`; } }; const watcher = chokidar.watch(filesToWatch, { ignoreInitial: true, cwd: sdkDir, }); let syncing = false; let pendingResync = false; const triggerResync = async (reason) => { if (syncing) { pendingResync = true; return; } syncing = true; if (reason) { console.log(`\nDetected change, re-syncing... (file: ${reason})`); } else { console.log(`\nDetected change, re-syncing...`); } if (childProc && !childProc.killed) { console.log("Stopping running process..."); childProc.kill(); await childProc.catch(() => { /* ignore kill errors */ }); } try { await performSync(sdkDir, targetDir); runWatchedCommand(); } catch (error) { console.error("❌ Sync failed:", error); console.log(" Still watching for changes..."); } finally { syncing = false; } if (pendingResync) { pendingResync = false; // Coalesce any rapid additional events into a single follow-up sync await new Promise((r) => setTimeout(r, 50)); return triggerResync(); } }; watcher.on("all", async (_event, filePath) => { if (filePath.endsWith(".tgz")) { return; } await triggerResync(filePath); }); const cleanup = async () => { console.log("\nCleaning up..."); if (childProc && !childProc.killed) { childProc.kill(); } await release(); process.exit(); }; process.on("SIGINT", cleanup); process.on("SIGTERM", cleanup); // Run the watched command even if the initial sync fails. This allows the // user to see application errors and iterate more quickly. runWatchedCommand(); }; if (import.meta.url === new URL(process.argv[1], import.meta.url).href) { const args = process.argv.slice(2); const watchFlagIndex = args.indexOf("--watch"); let watchCmd = watchFlagIndex !== -1; let cmdArgs = args; if (watchFlagIndex !== -1) { if (watchFlagIndex + 1 < args.length && !args[watchFlagIndex + 1].startsWith("--")) { watchCmd = args[watchFlagIndex + 1]; } // remove --watch and its potential command from args const watchArgCount = typeof watchCmd === "string" ? 2 : 1; cmdArgs = args.filter((_, i) => i < watchFlagIndex || i >= watchFlagIndex + watchArgCount); } const targetDir = cmdArgs[0] ?? process.cwd(); const sdkDir = path.resolve(__dirname, "..", ".."); debugSync({ targetDir, sdkDir, watch: watchCmd, }); }