import { version as npmVersion } from "../../version.js";
import AdmZip from "adm-zip";
import { Context } from "../../../bundler/context.js";
import {
logFinishedStep,
startLogProgress,
logVerbose,
logMessage,
logError,
logWarning,
} from "../../../bundler/log.js";
import {
dashboardZip,
executablePath,
versionedBinaryDir,
dashboardOutDir,
resetDashboardDir,
loadDashboardConfig,
executableName,
} from "./filePaths.js";
import child_process from "child_process";
import { promisify } from "util";
import { Readable } from "stream";
import { TempPath, nodeFs, withTmpDir } from "../../../bundler/fs.js";
import { recursivelyDelete, recursivelyCopy } from "../fsUtils.js";
import { LocalDeploymentError } from "./errors.js";
import type { ProgressBarInstance } from "../../../vendor/progress/index.js";
import path from "path";
async function makeExecutable(p: string) {
switch (process.platform) {
case "darwin":
case "linux": {
await promisify(child_process.exec)(`chmod +x ${p}`);
}
}
}
export async function ensureBackendBinaryDownloaded(
ctx: Context,
version:
| { kind: "latest"; allowedVersion?: string | undefined }
| { kind: "version"; version: string },
): Promise<{ binaryPath: string; version: string }> {
if (version.kind === "version") {
return _ensureBackendBinaryDownloaded(ctx, version.version);
}
if (version.allowedVersion) {
const latestVersionWithBinary = await findLatestVersionWithBinary(
ctx,
false,
);
if (latestVersionWithBinary === null) {
logWarning(
`Failed to get latest version from GitHub, using downloaded version ${version.allowedVersion}`,
);
return _ensureBackendBinaryDownloaded(ctx, version.allowedVersion);
}
return _ensureBackendBinaryDownloaded(ctx, latestVersionWithBinary);
}
const latestVersionWithBinary = await findLatestVersionWithBinary(ctx, true);
return _ensureBackendBinaryDownloaded(ctx, latestVersionWithBinary);
}
async function _ensureBackendBinaryDownloaded(
ctx: Context,
version: string,
): Promise<{ binaryPath: string; version: string }> {
logVerbose(`Ensuring backend binary downloaded for version ${version}`);
const existingDownload = await checkForExistingDownload(ctx, version);
if (existingDownload !== null) {
logVerbose(`Using existing download at ${existingDownload}`);
return {
binaryPath: existingDownload,
version,
};
}
const binaryPath = await downloadBackendBinary(ctx, version);
return { version, binaryPath };
}
/**
* Finds the latest version of the Convex local backend
* through version.convex.dev
*/
export async function findLatestVersionWithBinary<
RequireSuccess extends boolean,
>(
ctx: Context,
requireSuccess: RequireSuccess,
): Promise<RequireSuccess extends true ? string : string | null> {
// These shouldn't crash when there's a perfectly good binary already available.
async function maybeCrash(
...args: Parameters<typeof ctx.crash>
): Promise<RequireSuccess extends true ? never : null> {
if (requireSuccess) {
return await ctx.crash(...args);
}
if (args[0].printedMessage) {
logError(args[0].printedMessage);
} else {
logError("Error fetching latest version");
}
return null as RequireSuccess extends true ? never : null;
}
logVerbose("Fetching latest backend version from version API");
try {
const response = await fetch(
"https://version.convex.dev/v1/local_backend_version",
{
headers: { "Convex-Client": `npm-cli-${npmVersion}` },
},
);
if (!response.ok) {
const text = await response.text();
return await maybeCrash({
exitCode: 1,
errorType: "fatal",
printedMessage: `version.convex.dev returned ${response.status}: ${text}`,
errForSentry: new LocalDeploymentError(
`version.convex.dev returned ${response.status}: ${text}`,
),
});
}
const data = (await response.json()) as { version: string };
if (!data.version) {
return await maybeCrash({
exitCode: 1,
errorType: "fatal",
printedMessage: "Invalid response missing version field",
errForSentry: new LocalDeploymentError(
"Invalid response missing version field",
),
});
}
logVerbose(`Latest backend version is ${data.version}`);
return data.version;
} catch (e) {
return maybeCrash({
exitCode: 1,
errorType: "fatal",
printedMessage: "Failed to fetch latest backend version",
errForSentry: new LocalDeploymentError(e?.toString()),
});
}
}
/**
*
* @param ctx
* @param version
* @returns The binary path if it exists, or null
*/
async function checkForExistingDownload(
ctx: Context,
version: string,
): Promise<string | null> {
const destDir = versionedBinaryDir(version);
if (!ctx.fs.exists(destDir)) {
return null;
}
const p = executablePath(version);
if (!ctx.fs.exists(p)) {
// This directory isn't what we expected. Remove it.
recursivelyDelete(ctx, destDir, { force: true });
return null;
}
await makeExecutable(p);
return p;
}
async function downloadBackendBinary(
ctx: Context,
version: string,
): Promise<string> {
const downloadPath = getDownloadPath();
// Note: We validate earlier that there's a binary for this platform at the specified version,
// so in practice, we should never hit errors here.
if (downloadPath === null) {
return await ctx.crash({
exitCode: 1,
errorType: "fatal",
printedMessage: `Unsupported platform ${process.platform} and architecture ${process.arch} for local deployment.`,
});
}
await downloadZipFile(ctx, {
version,
filename: downloadPath,
nameForLogging: "Convex backend binary",
onDownloadComplete: async (ctx, unzippedPath) => {
const name = executableName();
const tempExecPath = path.join(unzippedPath, name);
await makeExecutable(tempExecPath);
logVerbose("Marked as executable");
ctx.fs.mkdir(versionedBinaryDir(version), { recursive: true });
ctx.fs.swapTmpFile(tempExecPath as TempPath, executablePath(version));
},
});
return executablePath(version);
}
/**
* Get the artifact name, composed of the target convex-local-backend and
* the Rust "target triple" appropriate for the current machine.
**/
function getDownloadPath() {
switch (process.platform) {
case "darwin":
if (process.arch === "arm64") {
return "convex-local-backend-aarch64-apple-darwin.zip";
} else if (process.arch === "x64") {
return "convex-local-backend-x86_64-apple-darwin.zip";
}
break;
case "linux":
if (process.arch === "arm64") {
return "convex-local-backend-aarch64-unknown-linux-gnu.zip";
} else if (process.arch === "x64") {
return "convex-local-backend-x86_64-unknown-linux-gnu.zip";
}
break;
case "win32":
return "convex-local-backend-x86_64-pc-windows-msvc.zip";
}
return null;
}
function getGithubDownloadUrl(version: string, filename: string) {
return `https://github.com/get-convex/convex-backend/releases/download/${version}/${filename}`;
}
async function downloadZipFile(
ctx: Context,
args: {
version: string;
filename: string;
nameForLogging: string;
onDownloadComplete: (ctx: Context, unzippedPath: TempPath) => Promise<void>;
},
) {
const { version, filename, nameForLogging } = args;
const url = getGithubDownloadUrl(version, filename);
const response = await fetch(url);
const contentLength = parseInt(
response.headers.get("content-length") ?? "",
10,
);
let progressBar: ProgressBarInstance | null = null;
if (!isNaN(contentLength) && contentLength !== 0 && process.stdout.isTTY) {
progressBar = startLogProgress(
`Downloading ${nameForLogging} [:bar] :percent :etas`,
{
width: 40,
total: contentLength,
clear: true,
},
);
} else {
logMessage(`Downloading ${nameForLogging}`);
}
if (response.status !== 200) {
return await ctx.crash({
exitCode: 1,
errorType: "fatal",
printedMessage: `File not found at ${url}.`,
});
}
await withTmpDir(async (tmpDir) => {
logVerbose(`Created tmp dir ${tmpDir.path}`);
// Create a file in the tmp dir
const zipLocation = tmpDir.registerTempPath(null);
const readable = Readable.fromWeb(response.body! as any);
await tmpDir.writeFileStream(zipLocation, readable, (chunk: any) => {
if (progressBar !== null) {
progressBar.tick(chunk.length);
}
});
if (progressBar) {
progressBar.terminate();
logFinishedStep(`Downloaded ${nameForLogging}`);
}
logVerbose("Downloaded zip file");
const zip = new AdmZip(zipLocation);
await withTmpDir(async (versionDir) => {
logVerbose(`Created tmp dir ${versionDir.path}`);
zip.extractAllTo(versionDir.path, true);
logVerbose("Extracted from zip file");
await args.onDownloadComplete(ctx, versionDir.path);
});
});
return executablePath(version);
}
export async function ensureDashboardDownloaded(ctx: Context, version: string) {
const config = loadDashboardConfig(ctx);
if (config !== null && config.version === version) {
return;
}
await resetDashboardDir(ctx);
await _ensureDashboardDownloaded(ctx, version);
}
async function _ensureDashboardDownloaded(ctx: Context, version: string) {
const zipLocation = dashboardZip();
if (ctx.fs.exists(zipLocation)) {
ctx.fs.unlink(zipLocation);
}
const outDir = dashboardOutDir();
await downloadZipFile(ctx, {
version,
filename: "dashboard.zip",
nameForLogging: "Convex dashboard",
onDownloadComplete: async (ctx, unzippedPath) => {
await recursivelyCopy(ctx, nodeFs, unzippedPath, outDir);
logVerbose("Copied into out dir");
},
});
return outDir;
}