convex
Version:
Client for the Convex Cloud
589 lines (538 loc) • 16.1 kB
text/typescript
import {
Infer,
ObjectType,
PropertyValidators,
convexToJson,
jsonToConvex,
} from "../../values/index.js";
import {
AnyFunctionReference,
FunctionReference,
FunctionType,
} from "../api.js";
import { getFunctionAddress } from "../impl/actions_impl.js";
import { performAsyncSyscall, performSyscall } from "../impl/syscall.js";
import { DefaultFunctionArgs, EmptyObject } from "../registration.js";
import {
AppDefinitionAnalysis,
ComponentDefinitionAnalysis,
ComponentDefinitionType,
HttpMount,
} from "./definition.js";
export const toReferencePath = Symbol.for("toReferencePath");
export function extractReferencePath(reference: any): string | null {
return reference[toReferencePath] ?? null;
}
export function isFunctionHandle(s: string): boolean {
return s.startsWith("function://");
}
/**
* @internal
*/
export type FunctionHandle<
Type extends FunctionType,
Args extends DefaultFunctionArgs = any,
ReturnType = any,
> = string & FunctionReference<Type, "internal", Args, ReturnType>;
/**
* @internal
*/
export async function createFunctionHandle<
Type extends FunctionType,
Args extends DefaultFunctionArgs,
ReturnType,
>(
functionReference: FunctionReference<
Type,
"public" | "internal",
Args,
ReturnType
>,
): Promise<FunctionHandle<Type, Args, ReturnType>> {
const address = getFunctionAddress(functionReference);
return await performAsyncSyscall("1.0/createFunctionHandle", { ...address });
}
interface ComponentExports {
[key: string]: FunctionReference<any, any, any, any> | ComponentExports;
}
/**
* @internal
*/
export interface InitCtx {}
/**
* An object of this type should be the default export of a
* convex.config.ts file in a component definition directory.
*
* @internal
*/ // eslint-disable-next-line @typescript-eslint/ban-types
export type ComponentDefinition<
Args extends PropertyValidators = EmptyObject,
Exports extends ComponentExports = any,
> = {
/**
* Install a component with the given definition in this component definition.
*
* Takes a component definition, an optional name, and the args it requires.
*
* For editor tooling this method expects a {@link ComponentDefinition}
* but at runtime the object that is imported will be a {@link ImportedComponentDefinition}
*/
install<Definition extends ComponentDefinition<any, any>>(
definition: Definition,
options: {
name?: string;
// TODO we have to do the "arguments are optional if empty, otherwise required"
args?: ObjectType<ComponentDefinitionArgs<Definition>>;
},
): InstalledComponent<Definition>;
installWithInit<Definition extends ComponentDefinition<any, any>>(
definition: Definition,
options: {
name?: string;
onInit: (
ctx: InitCtx,
args: ObjectType<Args>,
) => ObjectType<ComponentDefinitionArgs<Definition>>;
},
): InstalledComponent<Definition>;
mount(exports: ComponentExports): void;
/**
* Mount a component's HTTP router at a given path prefix.
*/
mountHttp(pathPrefix: string, component: InstalledComponent<any>): void;
// TODO this will be needed once components are responsible for building interfaces for themselves
/**
* @internal
*/
__args: Args;
/**
* @internal
*/
__exports: Exports;
};
type ComponentDefinitionArgs<T extends ComponentDefinition<any, any>> =
T["__args"];
type ComponentDefinitionExports<T extends ComponentDefinition<any, any>> =
T["__exports"];
/**
* An object of this type should be the default export of a
* convex.config.ts file in a component-aware convex directory.
*
* @internal
*/
export type AppDefinition = {
/**
* Install a component with the given definition in this component definition.
*
* Takes a component definition, an optional name, and the args it requires.
*
* For editor tooling this method expects a {@link ComponentDefinition}
* but at runtime the object that is imported will be a {@link ImportedComponentDefinition}
*/
install<Definition extends ComponentDefinition<any, any>>(
definition: Definition,
options: {
name?: string;
args?: ObjectType<ComponentDefinitionArgs<Definition>>;
},
): InstalledComponent<Definition>;
mount(exports: ComponentExports): void;
/**
* Mount a component's HTTP router at a given path prefix.
*/
mountHttp(pathPrefix: string, component: InstalledComponent<any>): void;
};
interface ExportTree {
// Tree with serialized `Reference`s as leaves.
[key: string]: string | ExportTree;
}
type CommonDefinitionData = {
_isRoot: boolean;
_childComponents: [
string,
ImportedComponentDefinition,
Record<string, any> | null,
][];
_httpMounts: Record<string, HttpMount>;
_exportTree: ExportTree;
};
type ComponentDefinitionData = CommonDefinitionData & {
_args: PropertyValidators;
_name: string;
_onInitCallbacks: Record<string, (argsStr: string) => string>;
};
type AppDefinitionData = CommonDefinitionData;
/**
* Used to refer to an already-installed component.
*/
class InstalledComponent<Definition extends ComponentDefinition<any, any>> {
/**
* @internal
*/
_definition: Definition;
/**
* @internal
*/
_name: string;
/**
* @internal
*/
[toReferencePath]: string;
constructor(definition: Definition, name: string) {
this._definition = definition;
this._name = name;
this[toReferencePath] = `_reference/childComponent/${name}`;
}
get exports(): ComponentDefinitionExports<Definition> {
return createExports(this._name, []);
}
}
function createExports(name: string, pathParts: string[]): any {
const handler: ProxyHandler<any> = {
get(_, prop: string | symbol) {
if (typeof prop === "string") {
const newParts = [...pathParts, prop];
return createExports(name, newParts);
} else if (prop === toReferencePath) {
let reference = `_reference/childComponent/${name}`;
for (const part of pathParts) {
reference += `/${part}`;
}
return reference;
} else {
return undefined;
}
},
};
return new Proxy({}, handler);
}
function install<Definition extends ComponentDefinition<any>>(
this: CommonDefinitionData,
definition: Definition,
options: {
name?: string;
args?: Infer<ComponentDefinitionArgs<Definition>>;
} = {},
): InstalledComponent<Definition> {
// At runtime an imported component will have this shape.
const importedComponentDefinition =
definition as unknown as ImportedComponentDefinition;
if (typeof importedComponentDefinition.componentDefinitionPath !== "string") {
throw new Error(
"Component definition does not have the required componentDefinitionPath property. This code only works in Convex runtime.",
);
}
const name =
options.name ||
importedComponentDefinition.componentDefinitionPath.split("/").pop()!;
this._childComponents.push([
name,
importedComponentDefinition,
options.args ?? {},
]);
return new InstalledComponent(definition, name);
}
function installWithInit<Definition extends ComponentDefinition<any>>(
this: ComponentDefinitionData,
definition: Definition,
options: {
name?: string;
onInit: (ctx: InitCtx, args: any) => any;
},
): InstalledComponent<Definition> {
// At runtime an imported component will have this shape.
const importedComponentDefinition =
definition as unknown as ImportedComponentDefinition;
if (typeof importedComponentDefinition.componentDefinitionPath !== "string") {
throw new Error(
"Component definition does not have the required componentDefinitionPath property. This code only works in Convex runtime.",
);
}
const name =
options.name ||
importedComponentDefinition.componentDefinitionPath.split("/").pop()!;
this._childComponents.push([name, importedComponentDefinition, null]);
this._onInitCallbacks[name] = (s) => invokeOnInit(s, options.onInit);
return new InstalledComponent(definition, name);
}
function invokeOnInit(
argsStr: string,
onInit: (ctx: InitCtx, args: any) => any,
): string {
const argsJson = JSON.parse(argsStr);
const args = jsonToConvex(argsJson);
const result = onInit({}, args);
return JSON.stringify(convexToJson(result));
}
function mount(this: CommonDefinitionData, exports: any) {
function visit(definition: CommonDefinitionData, path: string[], value: any) {
const valueReference = value[toReferencePath];
if (valueReference) {
if (!path.length) {
throw new Error("Empty export path");
}
let current = definition._exportTree;
for (const part of path.slice(0, -1)) {
let next = current[part];
if (typeof next === "string") {
throw new Error(
`Mount path ${path.join(".")} collides with existing export`,
);
}
if (!next) {
next = {};
current[part] = next;
}
current = next;
}
const last = path[path.length - 1];
if (current[last]) {
throw new Error(
`Mount path ${path.join(".")} collides with existing export`,
);
}
current[last] = valueReference;
} else {
for (const [key, child] of Object.entries(value)) {
visit(definition, [...path, key], child);
}
}
}
if (exports[toReferencePath]) {
throw new Error(`Cannot mount another component's exports at the root`);
}
visit(this, [], exports);
}
function mountHttp(
this: CommonDefinitionData,
pathPrefix: string,
component: InstalledComponent<any>,
) {
if (!pathPrefix.startsWith("/")) {
throw new Error(`Path prefix '${pathPrefix}' does not start with a /`);
}
if (!pathPrefix.endsWith("/")) {
throw new Error(`Path prefix '${pathPrefix}' must end with a /`);
}
if (this._httpMounts[pathPrefix]) {
throw new Error(`Path '${pathPrefix}' is already mounted.`);
}
const path = extractReferencePath(component);
if (!path) {
throw new Error("`mountHttp` must be called with an `InstalledComponent`.");
}
this._httpMounts[pathPrefix] = path;
}
// At runtime when you import a ComponentDefinition, this is all it is
/**
* @internal
*/
export type ImportedComponentDefinition = {
componentDefinitionPath: string;
};
function exportAppForAnalysis(
this: ComponentDefinition<any> & AppDefinitionData,
): AppDefinitionAnalysis {
const definitionType = { type: "app" as const };
const childComponents = serializeChildComponents(this._childComponents);
return {
definitionType,
childComponents: childComponents as any,
httpMounts: this._httpMounts,
exports: serializeExportTree(this._exportTree),
};
}
function serializeExportTree(tree: ExportTree): any {
const branch: any[] = [];
for (const [key, child] of Object.entries(tree)) {
let node;
if (typeof child === "string") {
node = { type: "leaf", leaf: child };
} else {
node = serializeExportTree(child);
}
branch.push([key, node]);
}
return { type: "branch", branch };
}
function serializeChildComponents(
childComponents: [
string,
ImportedComponentDefinition,
Record<string, any> | null,
][],
): {
name: string;
path: string;
args: [string, { type: "value"; value: string }][] | null;
}[] {
return childComponents.map(([name, definition, p]) => {
let args: [string, { type: "value"; value: string }][] | null = null;
if (p !== null) {
args = [];
for (const [name, value] of Object.entries(p)) {
if (value !== undefined) {
args.push([
name,
{ type: "value", value: JSON.stringify(convexToJson(value)) },
]);
}
}
}
// we know that components carry this extra information
const path = definition.componentDefinitionPath;
if (!path)
throw new Error(
"no .componentPath for component definition " +
JSON.stringify(definition, null, 2),
);
return {
name: name!,
path: path!,
args,
};
});
}
function exportComponentForAnalysis(
this: ComponentDefinition<any> & ComponentDefinitionData,
): ComponentDefinitionAnalysis {
const args: [string, { type: "value"; value: string }][] = Object.entries(
this._args,
).map(([name, validator]) => [
name,
{
type: "value",
value: JSON.stringify(validator.json),
},
]);
const definitionType: ComponentDefinitionType = {
type: "childComponent" as const,
name: this._name,
args,
};
const childComponents = serializeChildComponents(this._childComponents);
return {
name: this._name,
definitionType,
childComponents: childComponents as any,
httpMounts: this._httpMounts,
exports: serializeExportTree(this._exportTree),
};
}
// This is what is actually contained in a ComponentDefinition.
type RuntimeComponentDefinition = Omit<
ComponentDefinition<any, any>,
"__args" | "__exports"
> &
ComponentDefinitionData & {
export: () => ComponentDefinitionAnalysis;
};
type RuntimeAppDefinition = AppDefinition &
AppDefinitionData & {
export: () => AppDefinitionAnalysis;
};
/**
* @internal
*/
// eslint-disable-next-line @typescript-eslint/ban-types
export function defineComponent<
Args extends PropertyValidators = EmptyObject,
Exports extends ComponentExports = any,
>(
name: string,
options: { args?: Args } = {},
): ComponentDefinition<Args, Exports> {
const ret: RuntimeComponentDefinition = {
_isRoot: false,
_name: name,
_args: options.args || {},
_childComponents: [],
_httpMounts: {},
_exportTree: {},
_onInitCallbacks: {},
export: exportComponentForAnalysis,
install,
installWithInit,
mount,
mountHttp,
// pretend to conform to ComponentDefinition, which temporarily expects __args
...({} as { __args: any; __exports: any }),
};
return ret as any as ComponentDefinition<Args, Exports>;
}
/**
* Experimental - DO NOT USE.
*/
// TODO Make this not experimental.
export function defineApp(): AppDefinition {
const ret: RuntimeAppDefinition = {
_isRoot: true,
_childComponents: [],
_httpMounts: {},
_exportTree: {},
export: exportAppForAnalysis,
install,
mount,
mountHttp,
};
return ret as AppDefinition;
}
type AnyInterfaceType = {
[key: string]: AnyInterfaceType;
} & AnyFunctionReference;
export type AnyComponentReference = Record<string, AnyInterfaceType>;
type AnyChildComponents = Record<string, AnyComponentReference>;
/**
* @internal
*/
export function currentSystemUdfInComponent(
componentId: string,
): AnyComponentReference {
return {
[toReferencePath]: `_reference/currentSystemUdfInComponent/${componentId}`,
};
}
function createChildComponents(
root: string,
pathParts: string[],
): AnyChildComponents {
const handler: ProxyHandler<object> = {
get(_, prop: string | symbol) {
if (typeof prop === "string") {
const newParts = [...pathParts, prop];
return createChildComponents(root, newParts);
} else if (prop === toReferencePath) {
if (pathParts.length < 1) {
const found = [root, ...pathParts].join(".");
throw new Error(
`API path is expected to be of the form \`${root}.childComponent.functionName\`. Found: \`${found}\``,
);
}
return `_reference/childComponent/` + pathParts.join("/");
} else {
return undefined;
}
},
};
return new Proxy({}, handler);
}
/**
*
* @internal
*/
export function createComponentArg(): (ctx: any, name: string) => any {
return (ctx: any, name: string) => {
const result = performSyscall("1.0/componentArgument", {
name,
});
return (jsonToConvex(result) as any).value;
};
}
/**
* @internal
*/
export const componentsGeneric = () => createChildComponents("components", []);
/**
* @internal
*/
export type AnyComponents = AnyChildComponents;