import { PropertyValidators } from "../../values/index.js"; import { version } from "../../index.js"; import { AnyFunctionReference, FunctionReference, FunctionType, } from "../api.js"; import { performAsyncSyscall } from "../impl/syscall.js"; import { DefaultFunctionArgs } from "../registration.js"; import { AppDefinitionAnalysis, ComponentDefinitionAnalysis, ComponentDefinitionType, } from "./definition.js"; import { getFunctionAddress, setReferencePath, toReferencePath, } from "./paths.js"; import type { Validator, VLiteral, VOptional, VString, VUnion, } from "../../values/validators.js"; import type { Infer } from "../../values/validator.js"; import { isValidator } from "../../values/validator.js"; import type { Expand } from "../../type_utils.js"; export { getFunctionAddress } from "./paths.js"; /** * A serializable reference to a Convex function. * Passing a this reference to another component allows that component to call this * function during the current function execution or at any later time. * Function handles are used like `api.folder.function` FunctionReferences, * e.g. `ctx.scheduler.runAfter(0, functionReference, args)`. * * A function reference is stable across code pushes but it's possible * the Convex function it refers to might no longer exist. * * This is a feature of components, which are in beta. * This API is unstable and may change in subsequent releases. */ export type FunctionHandle< Type extends FunctionType, Args extends DefaultFunctionArgs = any, ReturnType = any, > = string & FunctionReference; /** * Create a serializable reference to a Convex function. * Passing a this reference to another component allows that component to call this * function during the current function execution or at any later time. * Function handles are used like `api.folder.function` FunctionReferences, * e.g. `ctx.scheduler.runAfter(0, functionReference, args)`. * * A function reference is stable across code pushes but it's possible * the Convex function it refers to might no longer exist. * * This is a feature of components, which are in beta. * This API is unstable and may change in subsequent releases. */ export async function createFunctionHandle< Type extends FunctionType, Args extends DefaultFunctionArgs, ReturnType, >( functionReference: FunctionReference< Type, "public" | "internal", Args, ReturnType >, ): Promise> { const address = getFunctionAddress(functionReference); return await performAsyncSyscall("1.0/createFunctionHandle", { ...address, version, }); } interface ComponentExports { [key: string]: FunctionReference | ComponentExports; } /** * An object of this type should be the default export of a * convex.config.ts file in a component definition directory. * * This is a feature of components, which are in beta. * This API is unstable and may change in subsequent releases. */ export type ComponentDefinition< Exports extends ComponentExports = any, Env extends EnvDefinition = {}, > = { /** * Install a component with the given definition in this component definition. * * Takes a component definition and an optional name. * * For editor tooling this method expects a {@link ComponentDefinition} * but at runtime the object that is imported will be a {@link ImportedComponentDefinition} */ use>( definition: Definition, options?: UseOptions, ): InstalledComponent; /** * Internal type-only property tracking exports provided. * * @deprecated This is a type-only property, don't use it. */ __exports: Exports; /** * References to this component's declared env vars. Pass one of these in * `app.use(child, { env: { ... } })` to bind a child's env var by * reference to this component's env var. */ env: EnvRefFromDefinition; /** * Internal type-only property tracking env definition. * * @deprecated This is a type-only property, don't use it. */ __env: Env; }; type ComponentDefinitionExports> = T["__exports"]; type ComponentDefinitionEnv> = T["__env"]; /** * Options for installing a component via `app.use()` or `component.use()`. * * If the component declares required env vars, the `env` property is required. */ type UseOptions> = keyof ComponentDefinitionEnv extends never ? { name?: string; httpPrefix?: string } : { name?: string; httpPrefix?: string; env: UseOptionsEnv>; }; type UseOptionsEnv = Expand< { [K in keyof E as E[K] extends VOptional ? never : K]: | Infer | EnvRef; } & { [K in keyof E as E[K] extends VOptional ? K : never]?: | Infer | EnvRef | undefined; } >; /** * A string-like validator: `v.string()`, a string `v.literal("...")`, or a * `v.union(...)` of those (recursively). Component env vars are serialized * as strings on the wire, so only string-typed validators are allowed. * * @public */ export type StringLikeValidator = | VString | VLiteral | VUnion[], "required">; /** * A definition of environment variables for the app. * * Maps environment variable names to string-like validators. Use * `v.string()` for a plain string, `v.literal("a")` for an enum value, or * `v.union(v.literal("a"), v.literal("b"))` for an enum. Wrap in * `v.optional(...)` for optional vars. * * @example * ```typescript * import { defineApp } from "convex/server"; * import { v } from "convex/values"; * * const app = defineApp({ * env: { * OPENAI_API_KEY: v.string(), * DEBUG_MODE: v.optional(v.string()), * }, * }); * ``` * * @public */ export type EnvDefinition = Record< string, StringLikeValidator | VOptional >; /** * Compute the typed environment object from an {@link EnvDefinition}. * * Required entries get the validator's inferred string type; optional * entries are `T | undefined`. * * @public */ export type EnvFromDefinition = Expand< { [K in keyof E as E[K] extends VOptional ? never : K]: Infer; } & { [K in keyof E as E[K] extends VOptional ? K : never]?: | Infer | undefined; } >; /** * A reference to a parent-declared env var, produced by `app.env.` or * `component.env.`. Pass this in `use(child, { env: { ... } })` to * bind a child's declared env var to the parent's env var by reference * instead of snapshotting its current value. * * @public */ export type EnvRef = { __envVarRef: K }; /** * Compute the typed `env` namespace object from an {@link EnvDefinition}. * Each declared name maps to an {@link EnvRef} for that name. * * @public */ export type EnvRefFromDefinition = { [K in keyof E & string]: EnvRef; }; /** * Extract the typed environment from an {@link AppDefinition}. * * @public */ export type EnvFromAppDefinition = A extends AppDefinition ? EnvFromDefinition : Record; /** * An object of this type should be the default export of a * convex.config.ts file in a component-aware convex directory. * * This is a feature of components, which are in beta. * This API is unstable and may change in subsequent releases. */ export type AppDefinition = { /** * Install a component with the given definition in this component definition. * * Takes a component definition and an optional name. * * For editor tooling this method expects a {@link ComponentDefinition} * but at runtime the object that is imported will be a {@link ImportedComponentDefinition} */ use>( definition: Definition, options?: UseOptions, ): InstalledComponent; /** * References to this app's declared env vars. Pass one of these in * `app.use(child, { env: { ... } })` to bind a child's env var by * reference to this app's env var. */ env: EnvRefFromDefinition; /** * Internal type-only property tracking env definition. * * @deprecated This is a type-only property, don't use it. */ __env: Env; }; interface ExportTree { // Tree with serialized `Reference`s as leaves. [key: string]: string | ExportTree; } type CommonDefinitionData = { _isRoot: boolean; _childComponents: [ string, ImportedComponentDefinition, Record | null, string | undefined, ][]; _exportTree: ExportTree; }; type ComponentDefinitionData = CommonDefinitionData & { _env: PropertyValidators; _name: string; _onInitCallbacks: Record string>; }; type AppDefinitionData = CommonDefinitionData & { _httpPrefix?: string; _env?: EnvDefinition; }; /** * Used to refer to an already-installed component. */ class InstalledComponent> { /** * @internal */ _definition: Definition; /** * @internal */ _name: string; constructor(definition: Definition, name: string) { this._definition = definition; this._name = name; setReferencePath(this, `_reference/childComponent/${name}`); } get exports(): ComponentDefinitionExports { return createExports(this._name, []); } } function createExports(name: string, pathParts: string[]): any { const handler: ProxyHandler = { 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 createEnvRefs( ownerLabel: string, declared: Record | undefined, ): any { const handler: ProxyHandler = { get(_, prop: string | symbol) { if (typeof prop !== "string") { return undefined; } if (!declared || !Object.prototype.hasOwnProperty.call(declared, prop)) { throw new Error( `Env var "${prop}" is not declared on ${ownerLabel}. Add it to the \`env\` option of ${ownerLabel === "this app" ? "defineApp" : "defineComponent"}.`, ); } return { __envVarRef: prop }; }, }; return new Proxy({}, handler); } function isEnvRef(value: unknown): value is EnvRef { return ( typeof value === "object" && value !== null && typeof (value as EnvRef).__envVarRef === "string" ); } function use>( this: CommonDefinitionData, definition: Definition, options?: { name?: string; httpPrefix?: string; env?: Record; }, ): InstalledComponent { // 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 ?? // added recently importedComponentDefinition.defaultName ?? // can be removed once backend is out importedComponentDefinition.componentDefinitionPath.split("/").pop()!; if (typeof name !== "string") { throw new Error( `Component name must be a string. Received: ${typeof name}`, ); } if (name.length === 0) { // "" is used internally as the name for the root component, so // users shouldn't try to define child components with an empty name. throw new Error("Component name cannot be empty."); } const httpPrefix = options?.httpPrefix; if (httpPrefix !== undefined) { if (!httpPrefix.startsWith("/")) { throw new Error( `httpPrefix must start with "/". Received: "${httpPrefix}"`, ); } } const envValues: Record = {}; if (options?.env) { for (const [key, value] of Object.entries(options.env)) { if (value !== undefined) { envValues[key] = value; } } } this._childComponents.push([ name, importedComponentDefinition, envValues, httpPrefix, ]); return new InstalledComponent(definition, name); } /** * The runtime type of a ComponentDefinition. TypeScript will claim * the default export of a module like "cool-component/convex.config.js" * is a `@link ComponentDefinition}, but during component definition evaluation * this is its type instead. * * This is a feature of components, which are in beta. * This API is unstable and may change in subsequent releases. */ export type ImportedComponentDefinition = { componentDefinitionPath: string; defaultName: string; }; function exportAppForAnalysis( this: ComponentDefinition & AppDefinitionData, ): AppDefinitionAnalysis { const definitionType = { type: "app" as const }; const childComponents = serializeChildComponents(this._childComponents); const httpMounts = buildHttpMounts(this._childComponents); const envVars = this._env ? Object.entries(this._env).map( ([name, validator]) => [ name, { type: "value" as const, value: JSON.stringify(validator.json), ...(validator.isOptional === "optional" ? { optional: true } : {}), }, ] as [string, { type: "value"; value: string; optional?: boolean }], ) : undefined; return { definitionType, ...(this._httpPrefix !== undefined ? { httpPrefix: normalizeHttpPrefix(this._httpPrefix) } : {}), childComponents: childComponents as any, httpMounts, exports: serializeExportTree(this._exportTree), ...(envVars !== undefined ? { envVars } : {}), }; } 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 normalizeHttpPrefix(prefix: string): string { // Ensure the prefix ends with "/" as required by HttpMountPath in Rust. return prefix.endsWith("/") ? prefix : prefix + "/"; } function buildHttpMounts( childComponents: [ string, ImportedComponentDefinition, Record | null, string | undefined, ][], ): Record { const httpMounts: Record = {}; for (const [name, , , httpPrefix] of childComponents) { if (httpPrefix !== undefined) { const normalized = normalizeHttpPrefix(httpPrefix); httpMounts[normalized] = `_reference/childComponent/${name}`; } } return httpMounts; } type SerializedEnvArg = | { type: "value"; value: string } | { type: "envVar"; name: string }; function serializeChildComponents( childComponents: [ string, ImportedComponentDefinition, Record | null, string | undefined, ][], ): { name: string; path: string; env: [string, SerializedEnvArg][] | null; }[] { return childComponents.map(([name, definition, p]) => { // Note: httpPrefix (4th element) is used separately in buildHttpMounts() let env: [string, SerializedEnvArg][] | null = null; if (p !== null) { env = []; for (const [name, value] of Object.entries(p)) { if (value === undefined) { continue; } if (isEnvRef(value)) { env.push([name, { type: "envVar", name: value.__envVarRef }]); } else if (typeof value === "string") { env.push([name, { type: "value", value }]); } else { throw new Error( `Env var "${name}" must be a string or an env var reference. ` + `Received: ${typeof 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: [], env, }; }); } function exportComponentForAnalysis( this: ComponentDefinition & ComponentDefinitionData, ): ComponentDefinitionAnalysis { const envVars = Object.entries(this._env).map( ([name, validator]) => [ name, { type: "value" as const, value: JSON.stringify((validator as any).json), ...((validator as any).isOptional === "optional" ? { optional: true } : {}), }, ] as [string, { type: "value"; value: string; optional?: boolean }], ); const definitionType: ComponentDefinitionType = { type: "childComponent" as const, name: this._name, args: [], }; const childComponents = serializeChildComponents(this._childComponents); const httpMounts = buildHttpMounts(this._childComponents); return { name: this._name, definitionType, childComponents: childComponents as any, httpMounts, exports: serializeExportTree(this._exportTree), ...(envVars.length > 0 ? { envVars } : {}), }; } // This is what is actually contained in a ComponentDefinition. type RuntimeComponentDefinition = Omit< ComponentDefinition, "__exports" | "__env" > & ComponentDefinitionData & { export: () => ComponentDefinitionAnalysis; }; type RuntimeAppDefinition = Omit, "__env"> & AppDefinitionData & { export: () => AppDefinitionAnalysis; }; /** * Define a component, a piece of a Convex deployment with namespaced resources. * * Optionally define typed environment variables that will be available via * the `env` export from `_generated/server` in all Convex functions within * this component. Values are passed by the parent via * `app.use(component, { env: { ... } })`. * * @param name Name must be alphanumeric plus underscores. Typically these are * lowercase with underscores like `"onboarding_flow_tracker"`. * * This is a feature of components, which are in beta. * This API is unstable and may change in subsequent releases. */ export function defineComponent< Exports extends ComponentExports = any, const Env extends EnvDefinition = {}, >( name: string, options?: { env?: Env; }, ): ComponentDefinition { const envValidators: PropertyValidators = {}; if (options?.env) { for (const [key, decl] of Object.entries(options.env)) { if (decl !== null && decl !== undefined && isValidator(decl)) { envValidators[key] = decl as any; } else { throw new Error( `Environment variable "${key}" must be defined with a validator (e.g. v.string()).`, ); } } } const ret: RuntimeComponentDefinition = { _isRoot: false, _name: name, _env: envValidators, _childComponents: [], _exportTree: {}, _onInitCallbacks: {}, env: createEnvRefs(`component "${name}"`, options?.env), export: exportComponentForAnalysis, use, ...({} as { __exports: any; __env: any }), }; return ret as any as ComponentDefinition; } /** * Attach components, reuseable pieces of a Convex deployment, to this Convex app. * * Optionally define typed environment variables that will be available via * the `env` export from `_generated/server` in all Convex functions. * * @example * ```typescript * import { defineApp } from "convex/server"; * import { v } from "convex/values"; * * const app = defineApp({ * env: { * OPENAI_API_KEY: v.string(), * DEBUG_MODE: v.optional(v.string()), * }, * }); * export default app; * ``` * * This is a feature of components, which are in beta. * This API is unstable and may change in subsequent releases. */ export function defineApp(options?: { httpPrefix?: string; env?: Env; }): AppDefinition { const httpPrefix = options?.httpPrefix; if (httpPrefix !== undefined && !httpPrefix.startsWith("/")) { throw new Error( `httpPrefix must start with "/". Received: "${httpPrefix}"`, ); } const env = options?.env; if (env !== undefined) { for (const [name, validator] of Object.entries(env)) { if (!isValidator(validator)) { throw new Error( `Environment variable "${name}" must be defined with a validator (e.g. v.string()).`, ); } } } const ret: RuntimeAppDefinition = { _isRoot: true, _childComponents: [], _exportTree: {}, ...(httpPrefix !== undefined ? { _httpPrefix: httpPrefix } : {}), ...(env !== undefined ? { _env: env } : {}), env: createEnvRefs("this app", env), export: exportAppForAnalysis, use, }; return ret as unknown as AppDefinition; } type AnyInterfaceType = { [key: string]: AnyInterfaceType; } & AnyFunctionReference; export type AnyComponentReference = Record; export type AnyChildComponents = Record; /** * @internal */ export function currentSystemUdfInComponent( componentId: string, ): AnyComponentReference { return { [toReferencePath]: `_reference/currentSystemUdfInComponent/${componentId}`, }; } function createChildComponents( root: string, pathParts: string[], ): AnyChildComponents { const handler: ProxyHandler = { 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); } export const componentsGeneric = () => createChildComponents("components", []); export type AnyComponents = AnyChildComponents;