UNPKG

convex

Version:

Client for the Convex Cloud

266 lines (249 loc) 8.87 kB
import boxen from "boxen"; import chalk from "chalk"; import { Command, Option } from "commander"; import path from "path"; import { performance } from "perf_hooks"; import { getDevDeployment, getUrlAndAdminKey } from "./lib/api"; import { readProjectConfig } from "./lib/config"; import { oneoffContext } from "./lib/context"; import { offerToWriteToEnv } from "./lib/envvars"; import { checkAuthorization, performLogin } from "./lib/login"; import { PushOptions, runPush } from "./lib/push"; import { ensureProjectDirectory, formatDuration } from "./lib/utils"; import { FatalError, WatchContext, Watcher } from "./lib/watch"; export const dev = new Command("dev") .description( "Watch the local filesystem. When your Convex functions change, push them to your dev deployment and update generated code." ) .option("-v, --verbose", "Show full listing of changes") .addOption( new Option( "--typecheck <mode>", `Check TypeScript files with \`tsc --noEmit\`.` ) .choices(["enable", "try", "disable"]) .default("try") ) .option("--save-url", "Save the dev deployment URL to .env.local") .option("--no-save-url", "Do not save dev deployment URL to .env.local") .addOption( new Option( "--prod", "Develop live against this project's production deployment." ).hideHelp() ) .addOption(new Option("--trace-events").hideHelp()) .addOption(new Option("--once").hideHelp()) .addOption(new Option("--admin-key <adminKey>").hideHelp()) .addOption(new Option("--url <url>").hideHelp()) .addOption( new Option("--codegen <mode>", "Regenerate code in `convex/_generated/`") .choices(["enable", "disable"]) .default("enable") ) .action(async cmdOptions => { const ctx = oneoffContext; const saveUrl = cmdOptions.saveUrl === true ? "yes" : cmdOptions.saveUrl === false ? "no" : "ask"; if (!cmdOptions.url || !cmdOptions.adminKey) { if (!(await checkAuthorization(ctx))) { await performLogin(ctx); } } await ensureProjectDirectory(ctx, true); const config = await readProjectConfig(oneoffContext); const projectSlug = config.projectConfig.project; const teamSlug = config.projectConfig.team; let deployment; if (!cmdOptions.url || !cmdOptions.adminKey) { if (cmdOptions.prod) { deployment = await getUrlAndAdminKey( ctx, config.projectConfig.project, config.projectConfig.team, "prod" ); console.error("Found deployment ready"); } else { deployment = await getDevDeployment(oneoffContext, { projectSlug, teamSlug, }); } } const adminKey = cmdOptions.adminKey ?? deployment?.adminKey; const url = cmdOptions.url ?? deployment?.url; const options: PushOptions = { adminKey, verbose: !!cmdOptions.verbose, dryRun: false, typecheck: cmdOptions.typecheck, debug: false, codegen: cmdOptions.codegen === "enable", url, }; let watcher: Watcher | undefined; let numFailures = 0; await offerToWriteToEnv(ctx, "dev", url, saveUrl); const boxedText = chalk.whiteBright.bold( `${ cmdOptions.prod ? "Production" : "Development" } deployment at ${url} ready!` ) + chalk.white( "\n\nKeep this command running to sync Convex functions when they change." ); const boxenOptions = { align: "center", padding: 1, margin: 1, borderColor: "green", backgroundColor: "#555555", } as const; if (!cmdOptions.once) { console.log(boxen(boxedText, boxenOptions)); } // eslint-disable-next-line no-constant-condition while (true) { console.log("Preparing Convex functions..."); const start = performance.now(); const ctx = new WatchContext(cmdOptions.traceEvents); // If the project or team slugs change, exit because that's the // simplest thing to do. const config = await readProjectConfig(ctx); if ( projectSlug !== config.projectConfig.project || teamSlug !== config.projectConfig.team ) { console.log("Detected a change in your `convex.json`. Exiting..."); return await ctx.fatalError(1, "fs"); } try { await runPush(ctx, options); const end = performance.now(); numFailures = 0; console.log( chalk.green( `Convex functions ready! (${formatDuration(end - start)})` ) ); } catch (e: any) { // Crash the app on unexpected errors. if (!(e instanceof FatalError) || !e.reason) { throw e; } // Retry after an exponential backoff if we hit a network error. if (e.reason === "network") { const delay = nextBackoff(numFailures); numFailures += 1; console.log( chalk.yellow( `Failed due to network error, retrying in ${formatDuration( delay )}...` ) ); await new Promise(resolve => setTimeout(resolve, delay)); continue; } // Fall through if we had a filesystem-based error. console.assert(e.reason === "fs"); if (cmdOptions.once) { await ctx.fatalError(1, "fs"); } } if (cmdOptions.once) { return; } const observations = ctx.fs.finalize(); if (observations === "invalidated") { console.log("Filesystem changed during push, retrying..."); continue; } // Initialize the watcher if we haven't done it already. Chokidar expects to have a // nonempty watch set at initialization, so we can't do it before running our first // push. if (!watcher) { watcher = new Watcher(observations); await watcher.ready(); } // Watch new directories if needed. watcher.update(observations); // Process events until we find one that overlaps with our previous observations. let anyChanges = false; do { await watcher.waitForEvent(); for (const event of watcher.drainEvents()) { if (cmdOptions.traceEvents) { console.log( "Processing", event.name, path.relative("", event.absPath) ); } const result = observations.overlaps(event); if (result.overlaps) { const relPath = path.relative("", event.absPath); if (cmdOptions.traceEvents) { console.log(`${relPath} ${result.reason}, rebuilding...`); } anyChanges = true; break; } } } while (!anyChanges); // Wait for the filesystem to quiesce before starting a new push. It's okay to // drop filesystem events at this stage since we're already committed to doing // a push and resubscribing based on that push's observations. let deadline = performance.now() + quiescenceDelay; // eslint-disable-next-line no-constant-condition while (true) { const now = performance.now(); if (now >= deadline) { break; } const remaining = deadline - now; if (cmdOptions.traceEvents) { console.log(`Waiting for ${formatDuration(remaining)} to quiesce...`); } const remainingWait = new Promise<"timeout">(resolve => setTimeout(() => resolve("timeout"), deadline - now) ); const result = await Promise.race([ remainingWait, watcher.waitForEvent().then<"newEvents">(() => "newEvents"), ]); if (result === "newEvents") { for (const event of watcher.drainEvents()) { const result = observations.overlaps(event); // Delay another `quiescenceDelay` since we had an overlapping event. if (result.overlaps) { if (cmdOptions.traceEvents) { console.log( `Received an overlapping event at ${event.absPath}, delaying push.` ); } deadline = performance.now() + quiescenceDelay; } } } else { console.assert(result === "timeout"); // Let the check above `break` from the loop if we're past our deadlne. } } } }); const initialBackoff = 500; const maxBackoff = 16000; const quiescenceDelay = 500; function nextBackoff(prevFailures: number): number { const baseBackoff = initialBackoff * Math.pow(2, prevFailures); const actualBackoff = Math.min(baseBackoff, maxBackoff); const jitter = actualBackoff * (Math.random() - 0.5); return actualBackoff + jitter; }