convex
Version:
Client for the Convex Cloud
490 lines (457 loc) • 13.7 kB
text/typescript
import chalk from "chalk";
import util from "util";
import ws from "ws";
import { ConvexHttpClient } from "../../browser/http_client.js";
import { BaseConvexClient } from "../../browser/index.js";
import {
PaginationResult,
UserIdentityAttributes,
makeFunctionReference,
} from "../../server/index.js";
import { Value, convexToJson, jsonToConvex } from "../../values/value.js";
import { Context, OneoffCtx } from "../../bundler/context.js";
import { logFinishedStep, logMessage, logOutput } from "../../bundler/log.js";
import { waitForever, waitUntilCalled } from "./utils/utils.js";
import JSON5 from "json5";
import path from "path";
import { readProjectConfig } from "./config.js";
import { watchAndPush } from "./dev.js";
export async function runFunctionAndLog(
ctx: Context,
args: {
deploymentUrl: string;
adminKey: string;
functionName: string;
argsString: string;
identityString?: string;
componentPath?: string;
callbacks?: {
onSuccess?: () => void;
};
},
) {
const client = new ConvexHttpClient(args.deploymentUrl);
const identity = args.identityString
? await getFakeIdentity(ctx, args.identityString)
: undefined;
client.setAdminAuth(args.adminKey, identity);
const functionArgs = await parseArgs(ctx, args.argsString);
const { projectConfig } = await readProjectConfig(ctx);
const parsedFunctionName = await parseFunctionName(
ctx,
args.functionName,
projectConfig.functions,
);
let result: Value;
try {
result = await client.function(
makeFunctionReference(parsedFunctionName),
args.componentPath,
functionArgs,
);
} catch (err) {
const errorMessage = (err as Error).toString().trim();
if (errorMessage.includes("Could not find function")) {
const functions = (await runSystemQuery(ctx, {
deploymentUrl: args.deploymentUrl,
adminKey: args.adminKey,
functionName: "_system/cli/modules:apiSpec",
componentPath: args.componentPath,
args: {},
})) as (
| {
functionType: "Query" | "Mutation" | "Action";
identifier: string;
}
| {
functionType: "HttpAction";
}
)[];
const functionNames = functions
.filter(
(
fn,
): fn is {
functionType: "Query" | "Mutation" | "Action";
identifier: string;
} => fn.functionType !== "HttpAction",
)
.map(({ identifier }) => {
const separatorPos = identifier.indexOf(":");
const path =
separatorPos === -1
? ""
: identifier.substring(0, separatorPos + 1);
const name =
separatorPos === -1
? identifier
: identifier.substring(separatorPos + 1);
return `• ${chalk.gray(path)}${name}`;
});
const availableFunctionsMessage =
functionNames.length > 0
? `Available functions:\n${functionNames.join("\n")}`
: "No functions found.";
return await ctx.crash({
exitCode: 1,
errorType: "invalid filesystem data",
printedMessage: `Failed to run function "${args.functionName}":\n${chalk.red(errorMessage)}\n\n${availableFunctionsMessage}`,
});
}
return await ctx.crash({
exitCode: 1,
errorType: "invalid filesystem or env vars",
printedMessage: `Failed to run function "${args.functionName}":\n${chalk.red(errorMessage)}`,
});
}
args.callbacks?.onSuccess?.();
// `null` is the default return type
if (result !== null) {
logOutput(formatValue(result));
}
}
async function getFakeIdentity(ctx: Context, identityString: string) {
let identity: UserIdentityAttributes;
try {
identity = JSON5.parse(identityString);
} catch (err) {
return await ctx.crash({
exitCode: 1,
errorType: "fatal",
printedMessage: `Failed to parse identity as JSON: "${identityString}"\n${chalk.red((err as Error).toString().trim())}`,
});
}
const subject = identity.subject ?? "" + simpleHash(JSON.stringify(identity));
const issuer = identity.issuer ?? "https://convex.test";
const tokenIdentifier =
identity.tokenIdentifier ?? `${issuer.toString()}|${subject.toString()}`;
return {
...identity,
subject,
issuer,
tokenIdentifier,
};
}
export async function parseArgs(ctx: Context, argsString: string) {
try {
const argsJson = JSON5.parse(argsString);
return jsonToConvex(argsJson) as Record<string, Value>;
} catch (err) {
return await ctx.crash({
exitCode: 1,
errorType: "invalid filesystem or env vars",
printedMessage: `Failed to parse arguments as JSON: "${argsString}"\n${chalk.red((err as Error).toString().trim())}`,
});
}
}
export async function parseFunctionName(
ctx: Context,
functionName: string,
// Usually `convex/` -- should contain trailing slash
functionDirName: string,
) {
// api.foo.bar -> foo:bar
// foo/bar -> foo/bar:default
// foo/bar:baz -> foo/bar:baz
// convex/foo/bar -> foo/bar:default
// This is the `api.foo.bar` format
if (functionName.startsWith("api.") || functionName.startsWith("internal.")) {
const parts = functionName.split(".");
if (parts.length < 3) {
return await ctx.crash({
exitCode: 1,
errorType: "fatal",
printedMessage: `Function name has too few parts: "${functionName}"`,
});
}
const exportName = parts.pop();
const parsedName = `${parts.slice(1).join("/")}:${exportName}`;
return parsedName;
}
// This is the `foo/bar:baz` format
// This is something like `convex/foo/bar`, which could either be addressing `foo/bar:default` or `convex/foo/bar:default`
// if there's a directory with the same name as the functions directory nested directly underneath.
// We'll prefer the `convex/foo/bar:default` version, and check if the file exists, and otherwise treat this as a relative path from the project root.
const filePath = functionName.split(":")[0];
const possibleExtensions = [
".ts",
".js",
".tsx",
".jsx",
".mts",
".mjs",
".cts",
".cjs",
];
let hasExtension = false;
let normalizedFilePath: string = filePath;
for (const extension of possibleExtensions) {
if (filePath.endsWith(extension)) {
normalizedFilePath = filePath.slice(0, -extension.length);
hasExtension = true;
break;
}
}
const exportName = functionName.split(":")[1] ?? "default";
const normalizedName = `${normalizedFilePath}:${exportName}`;
// This isn't a relative path from the project root
if (!filePath.startsWith(functionDirName)) {
return normalizedName;
}
const filePathWithoutPrefix = normalizedFilePath.slice(
functionDirName.length,
);
const functionNameWithoutPrefix = `${filePathWithoutPrefix}:${exportName}`;
if (hasExtension) {
if (ctx.fs.exists(path.join(functionDirName, filePath))) {
return normalizedName;
} else {
return functionNameWithoutPrefix;
}
} else {
const exists = possibleExtensions.some((extension) =>
ctx.fs.exists(path.join(functionDirName, filePath + extension)),
);
if (exists) {
return normalizedName;
} else {
return functionNameWithoutPrefix;
}
}
}
function simpleHash(string: string) {
let hash = 0;
for (let i = 0; i < string.length; i++) {
const char = string.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32bit integer
}
return hash;
}
export async function runSystemPaginatedQuery(
ctx: Context,
args: {
deploymentUrl: string;
adminKey: string;
functionName: string;
componentPath: string | undefined;
args: Record<string, Value>;
limit?: number;
},
) {
const results = [];
let cursor = null;
let isDone = false;
while (!isDone && (args.limit === undefined || results.length < args.limit)) {
const paginationResult = (await runSystemQuery(ctx, {
...args,
args: {
...args.args,
paginationOpts: {
cursor,
numItems:
args.limit === undefined ? 10000 : args.limit - results.length,
},
},
})) as unknown as PaginationResult<Record<string, Value>>;
isDone = paginationResult.isDone;
cursor = paginationResult.continueCursor;
results.push(...paginationResult.page);
}
return results;
}
export async function runSystemQuery(
ctx: Context,
args: {
deploymentUrl: string;
adminKey: string;
functionName: string;
componentPath: string | undefined;
args: Record<string, Value>;
},
): Promise<Value> {
let onResult: (result: Value) => void;
const resultPromise = new Promise<Value>((resolve) => {
onResult = resolve;
});
const [donePromise, onDone] = waitUntilCalled();
await subscribe(ctx, {
...args,
parsedFunctionName: args.functionName,
parsedFunctionArgs: args.args,
until: donePromise,
callbacks: {
onChange: (result) => {
onDone();
onResult(result);
},
},
});
return resultPromise;
}
export function formatValue(value: Value) {
const json = convexToJson(value);
if (process.stdout.isTTY) {
// TODO (Tom) add JSON syntax highlighting like https://stackoverflow.com/a/51319962/398212
// until then, just spit out something that isn't quite JSON because it's easy
return util.inspect(value, { colors: true, depth: null });
} else {
return JSON.stringify(json, null, 2);
}
}
export async function subscribeAndLog(
ctx: Context,
args: {
deploymentUrl: string;
adminKey: string;
functionName: string;
argsString: string;
identityString?: string;
componentPath: string | undefined;
},
) {
const { projectConfig } = await readProjectConfig(ctx);
const parsedFunctionName = await parseFunctionName(
ctx,
args.functionName,
projectConfig.functions,
);
const identity = args.identityString
? await getFakeIdentity(ctx, args.identityString)
: undefined;
const functionArgs = await parseArgs(ctx, args.argsString);
return subscribe(ctx, {
deploymentUrl: args.deploymentUrl,
adminKey: args.adminKey,
identity,
parsedFunctionName,
parsedFunctionArgs: functionArgs,
componentPath: args.componentPath,
until: waitForever(),
callbacks: {
onStart() {
logFinishedStep(
`Watching query ${args.functionName} on ${args.deploymentUrl}...`,
);
},
onChange(result) {
logOutput(formatValue(result));
},
onStop() {
logMessage(`Closing connection to ${args.deploymentUrl}...`);
},
},
});
}
export async function subscribe(
ctx: Context,
args: {
deploymentUrl: string;
adminKey: string;
identity?: UserIdentityAttributes;
parsedFunctionName: string;
parsedFunctionArgs: Record<string, Value>;
componentPath: string | undefined;
until: Promise<unknown>;
callbacks?: {
onStart?: () => void;
onChange?: (result: Value) => void;
onStop?: () => void;
};
},
) {
const client = new BaseConvexClient(
args.deploymentUrl,
(updatedQueries) => {
for (const queryToken of updatedQueries) {
args.callbacks?.onChange?.(client.localQueryResultByToken(queryToken)!);
}
},
{
// pretend that a Node.js 'ws' library WebSocket is a browser WebSocket
webSocketConstructor: ws as unknown as typeof WebSocket,
unsavedChangesWarning: false,
},
);
client.setAdminAuth(args.adminKey, args.identity);
const { unsubscribe } = client.subscribe(
args.parsedFunctionName,
args.parsedFunctionArgs,
{
componentPath: args.componentPath,
},
);
args.callbacks?.onStart?.();
let done = false;
const [donePromise, onDone] = waitUntilCalled();
const stopWatching = () => {
if (done) {
return;
}
done = true;
unsubscribe();
void client.close();
process.off("SIGINT", sigintListener);
onDone();
args.callbacks?.onStop?.();
};
function sigintListener() {
stopWatching();
}
process.on("SIGINT", sigintListener);
void args.until.finally(stopWatching);
while (!done) {
// loops once per day (any large value < 2**31 would work)
const oneDay = 24 * 60 * 60 * 1000;
await Promise.race([
donePromise,
new Promise((resolve) => setTimeout(resolve, oneDay)),
]);
}
}
export async function runInDeployment(
ctx: OneoffCtx,
args: {
deploymentUrl: string;
adminKey: string;
deploymentName: string | null;
functionName: string;
argsString: string;
identityString?: string;
push: boolean;
watch: boolean;
typecheck: "enable" | "try" | "disable";
typecheckComponents: boolean;
codegen: boolean;
componentPath: string | undefined;
liveComponentSources: boolean;
},
) {
if (args.push) {
await watchAndPush(
ctx,
{
url: args.deploymentUrl,
adminKey: args.adminKey,
deploymentName: args.deploymentName,
verbose: false,
dryRun: false,
typecheck: args.typecheck,
typecheckComponents: args.typecheckComponents,
debug: false,
debugNodeApis: false,
codegen: args.codegen,
liveComponentSources: args.liveComponentSources,
},
{
once: true,
traceEvents: false,
untilSuccess: true,
},
);
}
if (args.watch) {
return await subscribeAndLog(ctx, args);
}
return await runFunctionAndLog(ctx, args);
}