convex
Version:
Client for the Convex Cloud
345 lines (326 loc) • 10.3 kB
text/typescript
import { Command, Option } from "@commander-js/extra-typings";
import {
DeploymentSelection,
deploymentSelectionFromOptions,
fetchDeploymentCredentialsProvisionProd,
} from "./lib/api.js";
import {
Context,
logFailure,
logFinishedStep,
logMessage,
oneoffContext,
showSpinner,
} from "../bundler/context.js";
import * as net from "net";
import * as dns from "dns";
import * as crypto from "crypto";
import {
bareDeploymentFetch,
formatDuration,
formatSize,
} from "./lib/utils/utils.js";
import chalk from "chalk";
const ipFamilyNumbers = { ipv4: 4, ipv6: 6, auto: 0 } as const;
const ipFamilyNames = { 4: "ipv4", 6: "ipv6", 0: "auto" } as const;
export const networkTest = new Command("network-test")
.description("Run a network test to Convex's servers")
.addOption(
new Option(
"--timeout <timeout>",
"Timeout in seconds for the network test (default: 30).",
),
)
.addOption(
new Option(
"--ip-family <ipFamily>",
"IP family to use (ipv4, ipv6, or auto)",
),
)
.addOption(
new Option(
"--prod",
"Perform the network test on this project's production deployment. Defaults to your dev deployment without this flag.",
).conflicts(["--preview-name", "--deployment-name", "--url"]),
)
.addOption(
new Option(
"--preview-name <previewName>",
"Perform the network test on the preview deployment with the given name. Defaults to your dev deployment without this flag.",
).conflicts(["--prod", "--deployment-name", "--url"]),
)
.addOption(
new Option(
"--deployment-name <deploymentName>",
"Perform the network test on the specified deployment. Defaults to your dev deployment without this flag.",
).conflicts(["--prod", "--preview-name", "--url"]),
)
.addOption(
new Option("--url <url>")
.conflicts(["--prod", "--preview-name", "--deployment-name"])
.hideHelp(),
)
.addOption(new Option("--admin-key <adminKey>").hideHelp())
.addOption(new Option("--url <url>"))
.action(async (options) => {
const ctx = oneoffContext;
const timeoutSeconds = options.timeout
? Number.parseFloat(options.timeout)
: 30;
await withTimeout(
ctx,
"Network test",
timeoutSeconds * 1000,
runNetworkTest(ctx, options),
);
});
async function runNetworkTest(
ctx: Context,
options: {
prod?: boolean | undefined;
previewName?: string | undefined;
deploymentName?: string | undefined;
url?: string | undefined;
adminKey?: string | undefined;
ipFamily?: string;
},
) {
showSpinner(ctx, "Performing network test...");
const deploymentSelection = deploymentSelectionFromOptions(options);
const url = await loadUrl(ctx, deploymentSelection);
// First, check DNS to see if we can resolve the URL's hostname.
await checkDns(ctx, url);
// Second, check to see if we can open a TCP connection to the hostname.
await checkTcp(ctx, url, options.ipFamily ?? "auto");
// Fourth, do a simple HTTPS request and check that we receive a 200.
await checkHttp(ctx, url);
// Fifth, check a small echo request, much smaller than most networks' MTU.
await checkEcho(ctx, url, 128);
// Finally, try a few large echo requests, much larger than most networks' MTU.
await checkEcho(ctx, url, 4 * 1024 * 1024);
await checkEcho(ctx, url, 64 * 1024 * 1024);
logFinishedStep(ctx, "Network test passed.");
}
async function loadUrl(
ctx: Context,
deploymentSelection: DeploymentSelection,
): Promise<string> {
// Try to fetch the URL following the usual paths, but special case the
// `--url` argument in case the developer doesn't have network connectivity.
let url: string;
if (
deploymentSelection.kind === "urlWithAdminKey" ||
deploymentSelection.kind === "urlWithLogin"
) {
url = deploymentSelection.url;
} else {
const credentials = await fetchDeploymentCredentialsProvisionProd(
ctx,
deploymentSelection,
);
url = credentials.url;
}
logMessage(ctx, `${chalk.green(`✔`)} Project URL: ${url}`);
return url;
}
async function checkDns(ctx: Context, url: string) {
try {
const hostname = new URL("/", url).hostname;
const start = performance.now();
type DnsResult = { duration: number; address: string; family: number };
const result = await new Promise<DnsResult>((resolve, reject) => {
dns.lookup(hostname, (err, address, family) => {
if (err) {
reject(err);
} else {
resolve({ duration: performance.now() - start, address, family });
}
});
});
logMessage(
ctx,
`${chalk.green(`✔`)} OK: DNS lookup => ${result.address}:${
ipFamilyNames[result.family as keyof typeof ipFamilyNames]
} (${formatDuration(result.duration)})`,
);
} catch (e: any) {
return ctx.crash({
exitCode: 1,
errorType: "transient",
printedMessage: `FAIL: DNS lookup (${e})`,
});
}
}
async function checkTcp(ctx: Context, urlString: string, ipFamilyOpt: string) {
const url = new URL(urlString);
if (url.protocol === "http:") {
const port = Number.parseInt(url.port || "80");
await checkTcpHostPort(ctx, url.hostname, port, ipFamilyOpt);
} else if (url.protocol === "https:") {
const port = Number.parseInt(url.port || "443");
await checkTcpHostPort(ctx, url.hostname, port, ipFamilyOpt);
// If we didn't specify a port, also try port 80.
if (!url.port) {
await checkTcpHostPort(ctx, url.hostname, 80, ipFamilyOpt);
}
} else {
// eslint-disable-next-line no-restricted-syntax
throw new Error(`Unknown protocol: ${url.protocol}`);
}
}
async function checkTcpHostPort(
ctx: Context,
host: string,
port: number,
ipFamilyOpt: string,
) {
const ipFamily = ipFamilyNumbers[ipFamilyOpt as keyof typeof ipFamilyNumbers];
const tcpString =
`TCP` + (ipFamilyOpt === "auto" ? "" : `/${ipFamilyOpt} ${host}:${port}`);
try {
const start = performance.now();
const duration = await new Promise<number>((resolve, reject) => {
const socket = net.connect(
{
host,
port,
noDelay: true,
family: ipFamily,
},
() => resolve(performance.now() - start),
);
socket.on("error", (e) => reject(e));
});
logMessage(
ctx,
`${chalk.green(`✔`)} OK: ${tcpString} connect (${formatDuration(
duration,
)})`,
);
} catch (e: any) {
return ctx.crash({
exitCode: 1,
errorType: "transient",
printedMessage: `FAIL: ${tcpString} connect (${e})`,
});
}
}
async function checkHttp(ctx: Context, urlString: string) {
const url = new URL(urlString);
const isHttps = url.protocol === "https:";
if (isHttps) {
url.protocol = "http:";
url.port = "80";
await checkHttpOnce(ctx, "HTTP", url.toString(), 301, false);
}
await checkHttpOnce(ctx, isHttps ? "HTTPS" : "HTTP", urlString, 200, true);
}
async function checkHttpOnce(
ctx: Context,
name: string,
url: string,
expectedStatus: number,
allowRedirects: boolean,
) {
try {
const start = performance.now();
// Be sure to use the same `deploymentFetch` we use elsewhere so we're actually
// getting coverage of our network stack.
const fetch = bareDeploymentFetch(url);
const instanceNameUrl = new URL("/instance_name", url);
// Set `maxRedirects` to 0 so our HTTP test doesn't try HTTPS.
const resp = await fetch(instanceNameUrl.toString(), {
redirect: allowRedirects ? "follow" : "manual",
});
if (resp.status !== expectedStatus) {
// eslint-disable-next-line no-restricted-syntax
throw new Error(`Unexpected status code: ${resp.status}`);
}
const duration = performance.now() - start;
logMessage(
ctx,
`${chalk.green(`✔`)} OK: ${name} check (${formatDuration(duration)})`,
);
} catch (e: any) {
return ctx.crash({
exitCode: 1,
errorType: "transient",
printedMessage: `FAIL: ${name} check (${e})`,
});
}
}
async function checkEcho(ctx: Context, url: string, size: number) {
try {
const start = performance.now();
const fetch = bareDeploymentFetch(url, (err) => {
logFailure(
ctx,
chalk.red(`FAIL: echo ${formatSize(size)} (${err}), retrying...`),
);
});
const echoUrl = new URL(`/echo`, url);
const data = crypto.randomBytes(size);
const resp = await fetch(echoUrl.toString(), {
body: data,
method: "POST",
});
if (resp.status !== 200) {
// eslint-disable-next-line no-restricted-syntax
throw new Error(`Unexpected status code: ${resp.status}`);
}
const respData = await resp.arrayBuffer();
if (!data.equals(Buffer.from(respData))) {
// eslint-disable-next-line no-restricted-syntax
throw new Error(`Response data mismatch`);
}
const duration = performance.now() - start;
const bytesPerSecond = size / (duration / 1000);
logMessage(
ctx,
`${chalk.green(`✔`)} OK: echo ${formatSize(size)} (${formatDuration(
duration,
)}, ${formatSize(bytesPerSecond)}/s)`,
);
} catch (e: any) {
return ctx.crash({
exitCode: 1,
errorType: "transient",
printedMessage: `FAIL: echo ${formatSize(size)} (${e})`,
});
}
}
export async function withTimeout<T>(
ctx: Context,
name: string,
timeoutMs: number,
f: Promise<T>,
) {
let timer: NodeJS.Timeout | null = null;
try {
type TimeoutPromise = { kind: "ok"; result: T } | { kind: "timeout" };
const result = await Promise.race<TimeoutPromise>([
f.then((r) => {
return { kind: "ok", result: r };
}),
new Promise((resolve) => {
timer = setTimeout(() => {
resolve({ kind: "timeout" as const });
timer = null;
}, timeoutMs);
}),
]);
if (result.kind === "ok") {
return result.result;
} else {
return await ctx.crash({
exitCode: 1,
errorType: "transient",
printedMessage: `FAIL: ${name} timed out after ${formatDuration(timeoutMs)}.`,
});
}
} finally {
if (timer !== null) {
clearTimeout(timer);
}
}
}