convex
Version:
Client for the Convex Cloud
266 lines (249 loc) • 8.87 kB
text/typescript
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;
}