From a07b12b53262eece2ad72104e333dc23ab398150 Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 30 Jun 2026 11:21:56 +0300 Subject: [PATCH 01/22] feat(realtime): add realtime-handler resource and CLI commands Co-Authored-By: Claude Sonnet 4.6 --- .../cli/src/cli/commands/project/deploy.ts | 7 +- .../cli/src/cli/commands/realtime/deploy.ts | 119 ++++++++++++++++++ .../cli/src/cli/commands/realtime/index.ts | 10 ++ packages/cli/src/cli/commands/realtime/new.ts | 54 ++++++++ packages/cli/src/cli/program.ts | 4 + packages/cli/src/core/project/config.ts | 45 +++++-- packages/cli/src/core/project/deploy.ts | 6 + packages/cli/src/core/project/schema.ts | 1 + packages/cli/src/core/project/types.ts | 2 + .../core/resources/realtime-handler/api.ts | 41 ++++++ .../core/resources/realtime-handler/config.ts | 68 ++++++++++ .../core/resources/realtime-handler/deploy.ts | 69 ++++++++++ .../core/resources/realtime-handler/index.ts | 5 + .../resources/realtime-handler/resource.ts | 9 ++ .../core/resources/realtime-handler/schema.ts | 24 ++++ 15 files changed, 452 insertions(+), 12 deletions(-) create mode 100644 packages/cli/src/cli/commands/realtime/deploy.ts create mode 100644 packages/cli/src/cli/commands/realtime/index.ts create mode 100644 packages/cli/src/cli/commands/realtime/new.ts create mode 100644 packages/cli/src/core/resources/realtime-handler/api.ts create mode 100644 packages/cli/src/core/resources/realtime-handler/config.ts create mode 100644 packages/cli/src/core/resources/realtime-handler/deploy.ts create mode 100644 packages/cli/src/core/resources/realtime-handler/index.ts create mode 100644 packages/cli/src/core/resources/realtime-handler/resource.ts create mode 100644 packages/cli/src/core/resources/realtime-handler/schema.ts diff --git a/packages/cli/src/cli/commands/project/deploy.ts b/packages/cli/src/cli/commands/project/deploy.ts index 986c01ffc..b68568071 100644 --- a/packages/cli/src/cli/commands/project/deploy.ts +++ b/packages/cli/src/cli/commands/project/deploy.ts @@ -48,7 +48,7 @@ export async function deployAction( }; } - const { project, entities, functions, agents, connectors, authConfig } = + const { project, entities, functions, realtimeHandlers, agents, connectors, authConfig } = projectData; // Build summary of what will be deployed @@ -63,6 +63,11 @@ export async function deployAction( ` - ${functions.length} ${functions.length === 1 ? "function" : "functions"}`, ); } + if (realtimeHandlers.length > 0) { + summaryLines.push( + ` - ${realtimeHandlers.length} ${realtimeHandlers.length === 1 ? "realtime handler" : "realtime handlers"}`, + ); + } if (agents.length > 0) { summaryLines.push( ` - ${agents.length} ${agents.length === 1 ? "agent" : "agents"}`, diff --git a/packages/cli/src/cli/commands/realtime/deploy.ts b/packages/cli/src/cli/commands/realtime/deploy.ts new file mode 100644 index 000000000..cbc57a33d --- /dev/null +++ b/packages/cli/src/cli/commands/realtime/deploy.ts @@ -0,0 +1,119 @@ +import type { Logger } from "@base44-cli/logger"; +import type { Command } from "commander"; +import { CLIExitError } from "@/cli/errors.js"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { Base44Command, theme } from "@/cli/utils/index.js"; +import { InvalidInputError } from "@/core/errors.js"; +import { readProjectConfig } from "@/core/index.js"; +import { + deployRealtimeHandlersSequentially, + type SingleRealtimeHandlerDeployResult, +} from "@/core/resources/realtime-handler/deploy.js"; +import type { RealtimeHandler } from "@/core/resources/realtime-handler/schema.js"; + +function parseNames(args: string[]): string[] { + return args + .flatMap((arg) => arg.split(",")) + .map((n) => n.trim()) + .filter(Boolean); +} + +function resolveHandlersToDeploy( + names: string[], + allHandlers: RealtimeHandler[], +): RealtimeHandler[] { + if (names.length === 0) return allHandlers; + + const notFound = names.filter((n) => !allHandlers.some((h) => h.name === n)); + if (notFound.length > 0) { + throw new InvalidInputError( + `Realtime handler${notFound.length > 1 ? "s" : ""} not found in project: ${notFound.join(", ")}`, + ); + } + return allHandlers.filter((h) => names.includes(h.name)); +} + +function formatDeployResult( + result: SingleRealtimeHandlerDeployResult, + log: Logger, +): void { + const label = result.name.padEnd(25); + if (result.status === "deployed") { + const timing = result.durationMs + ? theme.styles.dim(` (${(result.durationMs / 1000).toFixed(1)}s)`) + : ""; + log.success(`${label} deployed${timing}`); + } else if (result.status === "unchanged") { + log.success(`${label} unchanged`); + } else { + log.error(`${label} error: ${result.error}`); + } +} + +function buildDeploySummary(results: SingleRealtimeHandlerDeployResult[]): string { + const deployed = results.filter((r) => r.status === "deployed").length; + const unchanged = results.filter((r) => r.status === "unchanged").length; + const failed = results.filter((r) => r.status === "error").length; + + const parts: string[] = []; + if (deployed > 0) parts.push(`${deployed} deployed`); + if (unchanged > 0) parts.push(`${unchanged} unchanged`); + if (failed > 0) parts.push(`${failed} error${failed !== 1 ? "s" : ""}`); + return parts.join(", ") || "No realtime handlers deployed"; +} + +async function deployRealtimeAction( + { log }: CLIContext, + names: string[], +): Promise { + const { realtimeHandlers } = await readProjectConfig(); + const toDeploy = resolveHandlersToDeploy(names, realtimeHandlers); + + if (toDeploy.length === 0) { + return { + outroMessage: + "No realtime handlers found. Create handlers in the 'realtime' directory.", + }; + } + + log.info( + `Found ${toDeploy.length} ${toDeploy.length === 1 ? "realtime handler" : "realtime handlers"} to deploy`, + ); + + let completed = 0; + const total = toDeploy.length; + + const results = await deployRealtimeHandlersSequentially(toDeploy, { + onStart: (startNames) => { + const label = + startNames.length === 1 + ? startNames[0] + : `${startNames.length} realtime handlers`; + log.step( + theme.styles.dim(`[${completed + 1}/${total}] Deploying ${label}...`), + ); + }, + onResult: (result) => { + completed++; + formatDeployResult(result, log); + }, + }); + + const hasFailures = results.some((r) => r.status === "error"); + if (hasFailures) { + log.message(buildDeploySummary(results)); + throw new CLIExitError(1); + } + + return { outroMessage: buildDeploySummary(results) }; +} + +export function getDeployCommand(): Command { + return new Base44Command("deploy") + .description("Deploy realtime handlers to Base44") + .argument("[names...]", "Handler names to deploy (deploys all if omitted)") + .action(async (ctx: CLIContext, rawNames: string[]) => { + const names = parseNames(rawNames); + return deployRealtimeAction(ctx, names); + }); +} diff --git a/packages/cli/src/cli/commands/realtime/index.ts b/packages/cli/src/cli/commands/realtime/index.ts new file mode 100644 index 000000000..171356a52 --- /dev/null +++ b/packages/cli/src/cli/commands/realtime/index.ts @@ -0,0 +1,10 @@ +import { Command } from "commander"; +import { getDeployCommand } from "./deploy.js"; +import { getNewCommand } from "./new.js"; + +export function getRealtimeCommand(): Command { + return new Command("realtime") + .description("Manage realtime handlers") + .addCommand(getNewCommand()) + .addCommand(getDeployCommand()); +} diff --git a/packages/cli/src/cli/commands/realtime/new.ts b/packages/cli/src/cli/commands/realtime/new.ts new file mode 100644 index 000000000..ad94f4839 --- /dev/null +++ b/packages/cli/src/cli/commands/realtime/new.ts @@ -0,0 +1,54 @@ +import { join } from "node:path"; +import type { Command } from "commander"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { Base44Command } from "@/cli/utils/index.js"; +import { InvalidInputError } from "@/core/errors.js"; +import { readProjectConfig } from "@/core/index.js"; +import { pathExists, writeFile } from "@/core/utils/fs.js"; + +function buildHandlerScaffold(handlerName: string): string { + return `import { RealtimeHandler, type Conn } from "base44"; + +export class ${handlerName} extends RealtimeHandler { + handleConnect(conn: Conn) { + console.log("Connected:", conn.userId); + } + handleMessage(conn: Conn, msg: unknown) { + console.log("Message:", msg); + } + handleTick() {} + handleClose(conn: Conn) {} +} +`; +} + +async function newRealtimeHandlerAction( + _ctx: CLIContext, + handlerName: string, +): Promise { + const { project } = await readProjectConfig(); + const realtimeDir = join(project.root, project.realtimeDir); + const handlerDir = join(realtimeDir, handlerName); + + if (await pathExists(handlerDir)) { + throw new InvalidInputError( + `Realtime handler "${handlerName}" already exists at ${handlerDir}`, + ); + } + + const entryPath = join(handlerDir, "entry.ts"); + await writeFile(entryPath, buildHandlerScaffold(handlerName)); + + return { + outroMessage: `Created realtime handler "${handlerName}" at ${entryPath}`, + }; +} + +export function getNewCommand(): Command { + return new Base44Command("new") + .description("Create a new realtime handler scaffold") + .argument("", "Name of the realtime handler class") + .action(async (ctx: CLIContext, handlerName: string) => { + return newRealtimeHandlerAction(ctx, handlerName); + }); +} diff --git a/packages/cli/src/cli/program.ts b/packages/cli/src/cli/program.ts index fe8bc8f6a..857ee6e34 100644 --- a/packages/cli/src/cli/program.ts +++ b/packages/cli/src/cli/program.ts @@ -16,6 +16,7 @@ import { getLinkCommand } from "@/cli/commands/project/link.js"; import { getLogsCommand } from "@/cli/commands/project/logs.js"; import { getScaffoldCommand } from "@/cli/commands/project/scaffold.js"; import { getVisibilityCommand } from "@/cli/commands/project/visibility.js"; +import { getRealtimeCommand } from "@/cli/commands/realtime/index.js"; import { getSandboxCommand } from "@/cli/commands/sandbox/index.js"; import { getSecretsCommand } from "@/cli/commands/secrets/index.js"; import { getSiteCommand } from "@/cli/commands/site/index.js"; @@ -95,6 +96,9 @@ export function createProgram(context: CLIContext): Command { // Register functions commands program.addCommand(getFunctionsCommand()); + // Register realtime commands + program.addCommand(getRealtimeCommand()); + // Register workflows commands program.addCommand(getWorkflowsCommand()); diff --git a/packages/cli/src/core/project/config.ts b/packages/cli/src/core/project/config.ts index 4f32856c6..a94a472c4 100644 --- a/packages/cli/src/core/project/config.ts +++ b/packages/cli/src/core/project/config.ts @@ -33,6 +33,10 @@ import { type BackendFunction, functionResource, } from "@/core/resources/function/index.js"; +import { + type RealtimeHandler, + realtimeHandlerResource, +} from "@/core/resources/realtime-handler/index.js"; import { readJsonFile } from "@/core/utils/fs.js"; type ProjectResources = Omit; @@ -72,6 +76,7 @@ class ProjectConfigReader { project, entities, functions, + realtimeHandlers: localResources.realtimeHandlers, agents: localResources.agents, agentSkills: localResources.agentSkills, connectors: localResources.connectors, @@ -118,17 +123,33 @@ class ProjectConfigReader { project: ProjectConfig, ): Promise { const configDir = dirname(configPath); - const [entities, functions, agents, agentSkills, connectors, authConfig] = - await Promise.all([ - entityResource.readAll(join(configDir, project.entitiesDir)), - functionResource.readAll(join(configDir, project.functionsDir)), - agentResource.readAll(join(configDir, project.agentsDir)), - agentSkillResource.readAll(join(configDir, project.agentSkillsDir)), - connectorResource.readAll(join(configDir, project.connectorsDir)), - authConfigResource.readAll(join(configDir, project.authDir)), - ]); - - return { entities, functions, agents, agentSkills, connectors, authConfig }; + const [ + entities, + functions, + realtimeHandlers, + agents, + agentSkills, + connectors, + authConfig, + ] = await Promise.all([ + entityResource.readAll(join(configDir, project.entitiesDir)), + functionResource.readAll(join(configDir, project.functionsDir)), + realtimeHandlerResource.readAll(join(configDir, project.realtimeDir)), + agentResource.readAll(join(configDir, project.agentsDir)), + agentSkillResource.readAll(join(configDir, project.agentSkillsDir)), + connectorResource.readAll(join(configDir, project.connectorsDir)), + authConfigResource.readAll(join(configDir, project.authDir)), + ]); + + return { + entities, + functions, + realtimeHandlers, + agents, + agentSkills, + connectors, + authConfig, + }; } private assertPluginProjectDoesNotLoadPlugins( @@ -198,6 +219,7 @@ class ProjectConfigReader { return { entities: markPluginEntities(resources.entities, namespace), functions: namespacePluginFunctions(resources.functions, namespace), + realtimeHandlers: [], agents: [], agentSkills: [], connectors: [], @@ -255,6 +277,7 @@ class ProjectConfigReader { return { entities, functions, + realtimeHandlers: [], agents: [], agentSkills: [], connectors: [], diff --git a/packages/cli/src/core/project/deploy.ts b/packages/cli/src/core/project/deploy.ts index 99ff0ef95..ea0356b2e 100644 --- a/packages/cli/src/core/project/deploy.ts +++ b/packages/cli/src/core/project/deploy.ts @@ -15,6 +15,7 @@ import { deployFunctionsSequentially, type SingleFunctionDeployResult, } from "@/core/resources/function/deploy.js"; +import { deployRealtimeHandlersSequentially } from "@/core/resources/realtime-handler/deploy.js"; import { deploySite } from "@/core/site/index.js"; /** @@ -28,6 +29,7 @@ export function hasResourcesToDeploy(projectData: ProjectData): boolean { project, entities, functions, + realtimeHandlers, agents, agentSkills, connectors, @@ -36,6 +38,7 @@ export function hasResourcesToDeploy(projectData: ProjectData): boolean { const hasSite = Boolean(project.site?.outputDirectory); const hasEntities = entities.length > 0; const hasFunctions = functions.length > 0; + const hasRealtimeHandlers = realtimeHandlers.length > 0; const hasAgents = agents.length > 0; const hasAgentSkills = agentSkills.length > 0; const hasConnectors = connectors.length > 0; @@ -45,6 +48,7 @@ export function hasResourcesToDeploy(projectData: ProjectData): boolean { return ( hasEntities || hasFunctions || + hasRealtimeHandlers || hasAgents || hasAgentSkills || hasConnectors || @@ -89,6 +93,7 @@ export async function deployAll( project, entities, functions, + realtimeHandlers, agents, agentSkills, connectors, @@ -104,6 +109,7 @@ export async function deployAll( onStart: options?.onFunctionStart, onResult: options?.onFunctionResult, }); + await deployRealtimeHandlersSequentially(realtimeHandlers); await agentSkillResource.push(agentSkills); await agentResource.push(agents); await authConfigResource.push(authConfig); diff --git a/packages/cli/src/core/project/schema.ts b/packages/cli/src/core/project/schema.ts index 6d4412f3b..29046e0f5 100644 --- a/packages/cli/src/core/project/schema.ts +++ b/packages/cli/src/core/project/schema.ts @@ -49,6 +49,7 @@ export const ProjectConfigSchema = z.object({ site: SiteConfigSchema.optional(), entitiesDir: z.string().optional().default("entities"), functionsDir: z.string().optional().default("functions"), + realtimeDir: z.string().optional().default("realtime"), agentsDir: z.string().optional().default("agents"), agentSkillsDir: z.string().optional().default("agent-skills"), connectorsDir: z.string().optional().default("connectors"), diff --git a/packages/cli/src/core/project/types.ts b/packages/cli/src/core/project/types.ts index b69b14682..a2574107c 100644 --- a/packages/cli/src/core/project/types.ts +++ b/packages/cli/src/core/project/types.ts @@ -5,6 +5,7 @@ import type { AuthConfig } from "@/core/resources/auth-config/index.js"; import type { ConnectorResource } from "@/core/resources/connector/index.js"; import type { Entity } from "@/core/resources/entity/index.js"; import type { BackendFunction } from "@/core/resources/function/index.js"; +import type { RealtimeHandler } from "@/core/resources/realtime-handler/index.js"; export interface ProjectWithPaths extends ProjectConfig { root: string; @@ -20,6 +21,7 @@ export interface ProjectData { project: ProjectWithPaths; entities: Entity[]; functions: BackendFunction[]; + realtimeHandlers: RealtimeHandler[]; agents: AgentConfig[]; agentSkills: AgentSkill[]; connectors: ConnectorResource[]; diff --git a/packages/cli/src/core/resources/realtime-handler/api.ts b/packages/cli/src/core/resources/realtime-handler/api.ts new file mode 100644 index 000000000..c13abda2c --- /dev/null +++ b/packages/cli/src/core/resources/realtime-handler/api.ts @@ -0,0 +1,41 @@ +import type { KyResponse } from "ky"; +import { getAppClient } from "@/core/clients/index.js"; +import { ApiError, SchemaValidationError } from "@/core/errors.js"; +import type { + DeployRealtimeHandlerResponse, +} from "@/core/resources/realtime-handler/schema.js"; +import { + DeployRealtimeHandlerResponseSchema, +} from "@/core/resources/realtime-handler/schema.js"; +import type { FunctionFile } from "@/core/resources/function/schema.js"; + +export async function deploySingleRealtimeHandler( + name: string, + payload: { entry: string; files: FunctionFile[] }, +): Promise { + const appClient = getAppClient(); + + let response: KyResponse; + try { + response = await appClient.put( + `backend-functions/${encodeURIComponent(name)}`, + { json: payload, timeout: false }, + ); + } catch (error) { + throw await ApiError.fromHttpError( + error, + `deploying realtime handler "${name}"`, + ); + } + + const result = DeployRealtimeHandlerResponseSchema.safeParse( + await response.json(), + ); + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error, + ); + } + return result.data; +} diff --git a/packages/cli/src/core/resources/realtime-handler/config.ts b/packages/cli/src/core/resources/realtime-handler/config.ts new file mode 100644 index 000000000..7d42b7e28 --- /dev/null +++ b/packages/cli/src/core/resources/realtime-handler/config.ts @@ -0,0 +1,68 @@ +import { basename, dirname, join, relative } from "node:path"; +import { globby } from "globby"; +import { ENTRY_FILE_GLOB, ENTRY_IGNORE_DOT_PATHS } from "@/core/consts.js"; +import { InvalidInputError } from "@/core/errors.js"; +import type { RealtimeHandler } from "@/core/resources/realtime-handler/schema.js"; +import { pathExists } from "@/core/utils/fs.js"; + +async function readRealtimeHandler(entryFile: string, realtimeDir: string): Promise { + const handlerDir = dirname(entryFile); + const filePaths = await globby("**/*.ts", { + cwd: handlerDir, + absolute: true, + }); + + const name = relative(realtimeDir, handlerDir).split(/[/\\]/).join("/"); + if (!name) { + throw new InvalidInputError( + "entry.ts found directly in the realtime directory — it must be inside a named subfolder", + { + hints: [ + { + message: `Move ${entryFile} into a subfolder (e.g. realtime/myHandler/entry.ts)`, + }, + ], + }, + ); + } + + const entry = basename(entryFile); + + return { + name, + entry, + entryPath: entryFile, + filePaths, + source: { type: "project" }, + }; +} + +export async function readAllRealtimeHandlers( + realtimeDir: string, +): Promise { + if (!(await pathExists(realtimeDir))) { + return []; + } + + const entryFiles = await globby(ENTRY_FILE_GLOB, { + cwd: realtimeDir, + absolute: true, + ignore: ENTRY_IGNORE_DOT_PATHS, + }); + + const handlers = await Promise.all( + entryFiles.map((entryFile) => readRealtimeHandler(entryFile, realtimeDir)), + ); + + const names = new Set(); + for (const handler of handlers) { + if (names.has(handler.name)) { + throw new InvalidInputError( + `Duplicate realtime handler name "${handler.name}" in ${realtimeDir}`, + ); + } + names.add(handler.name); + } + + return handlers; +} diff --git a/packages/cli/src/core/resources/realtime-handler/deploy.ts b/packages/cli/src/core/resources/realtime-handler/deploy.ts new file mode 100644 index 000000000..4afd2c75e --- /dev/null +++ b/packages/cli/src/core/resources/realtime-handler/deploy.ts @@ -0,0 +1,69 @@ +import { dirname, relative } from "node:path"; +import { deploySingleRealtimeHandler } from "@/core/resources/realtime-handler/api.js"; +import type { RealtimeHandler } from "@/core/resources/realtime-handler/schema.js"; +import type { FunctionFile } from "@/core/resources/function/schema.js"; +import { readTextFile } from "@/core/utils/fs.js"; + +async function loadHandlerCode( + handler: RealtimeHandler, +): Promise<{ name: string; entry: string; files: FunctionFile[] }> { + const handlerDir = dirname(handler.entryPath); + const resolvedFiles: FunctionFile[] = await Promise.all( + handler.filePaths.map(async (filePath) => { + const content = await readTextFile(filePath); + const path = relative(handlerDir, filePath).split(/[/\\]/).join("/"); + return { path, content }; + }), + ); + return { name: handler.name, entry: handler.entry, files: resolvedFiles }; +} + +export interface SingleRealtimeHandlerDeployResult { + name: string; + status: "deployed" | "unchanged" | "error"; + error?: string | null; + durationMs?: number; +} + +async function deployOne( + handler: RealtimeHandler, +): Promise { + const start = Date.now(); + try { + const loaded = await loadHandlerCode(handler); + const response = await deploySingleRealtimeHandler(loaded.name, { + entry: loaded.entry, + files: loaded.files, + }); + return { + name: loaded.name, + status: response.status, + durationMs: Date.now() - start, + }; + } catch (error) { + return { + name: handler.name, + status: "error", + error: error instanceof Error ? error.message : String(error), + }; + } +} + +export async function deployRealtimeHandlersSequentially( + handlers: RealtimeHandler[], + options?: { + onStart?: (names: string[]) => void; + onResult?: (result: SingleRealtimeHandlerDeployResult) => void; + }, +): Promise { + if (handlers.length === 0) return []; + + const results: SingleRealtimeHandlerDeployResult[] = []; + for (const handler of handlers) { + options?.onStart?.([handler.name]); + const result = await deployOne(handler); + results.push(result); + options?.onResult?.(result); + } + return results; +} diff --git a/packages/cli/src/core/resources/realtime-handler/index.ts b/packages/cli/src/core/resources/realtime-handler/index.ts new file mode 100644 index 000000000..90b197a7b --- /dev/null +++ b/packages/cli/src/core/resources/realtime-handler/index.ts @@ -0,0 +1,5 @@ +export * from "./api.js"; +export * from "./config.js"; +export * from "./deploy.js"; +export * from "./resource.js"; +export * from "./schema.js"; diff --git a/packages/cli/src/core/resources/realtime-handler/resource.ts b/packages/cli/src/core/resources/realtime-handler/resource.ts new file mode 100644 index 000000000..9a61f37c4 --- /dev/null +++ b/packages/cli/src/core/resources/realtime-handler/resource.ts @@ -0,0 +1,9 @@ +import { readAllRealtimeHandlers } from "@/core/resources/realtime-handler/config.js"; +import { deployRealtimeHandlersSequentially } from "@/core/resources/realtime-handler/deploy.js"; +import type { RealtimeHandler } from "@/core/resources/realtime-handler/schema.js"; +import type { Resource } from "@/core/resources/types.js"; + +export const realtimeHandlerResource: Resource = { + readAll: readAllRealtimeHandlers, + push: (handlers) => deployRealtimeHandlersSequentially(handlers), +}; diff --git a/packages/cli/src/core/resources/realtime-handler/schema.ts b/packages/cli/src/core/resources/realtime-handler/schema.ts new file mode 100644 index 000000000..df002b4c1 --- /dev/null +++ b/packages/cli/src/core/resources/realtime-handler/schema.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; +import { ResourceSourceSchema } from "@/core/resources/types.js"; + +export const RealtimeHandlerConfigSchema = z.object({ + name: z.string().min(1), + entry: z.string().min(1), +}); + +export const DeployRealtimeHandlerResponseSchema = z.object({ + status: z.enum(["deployed", "unchanged"]), + handler_name: z.string().optional(), +}); + +const RealtimeHandlerSchema = RealtimeHandlerConfigSchema.extend({ + entryPath: z.string().min(1), + filePaths: z.array(z.string()).min(1), + source: ResourceSourceSchema, +}); + +export type RealtimeHandlerConfig = z.infer; +export type RealtimeHandler = z.infer; +export type DeployRealtimeHandlerResponse = z.infer< + typeof DeployRealtimeHandlerResponseSchema +>; From d800c64357aee1bbaf50e21405271eb91ed643f7 Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 30 Jun 2026 13:54:46 +0300 Subject: [PATCH 02/22] fix(lint): apply biome formatting and unused import fixes Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/cli/commands/project/deploy.ts | 11 +++++++++-- packages/cli/src/cli/commands/realtime/deploy.ts | 4 +++- packages/cli/src/core/project/config.ts | 5 +---- .../cli/src/core/resources/realtime-handler/api.ts | 8 ++------ .../cli/src/core/resources/realtime-handler/config.ts | 7 +++++-- .../cli/src/core/resources/realtime-handler/deploy.ts | 2 +- .../cli/src/core/resources/realtime-handler/schema.ts | 4 ++-- 7 files changed, 23 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/cli/commands/project/deploy.ts b/packages/cli/src/cli/commands/project/deploy.ts index b68568071..996a77cb5 100644 --- a/packages/cli/src/cli/commands/project/deploy.ts +++ b/packages/cli/src/cli/commands/project/deploy.ts @@ -48,8 +48,15 @@ export async function deployAction( }; } - const { project, entities, functions, realtimeHandlers, agents, connectors, authConfig } = - projectData; + const { + project, + entities, + functions, + realtimeHandlers, + agents, + connectors, + authConfig, + } = projectData; // Build summary of what will be deployed const summaryLines: string[] = []; diff --git a/packages/cli/src/cli/commands/realtime/deploy.ts b/packages/cli/src/cli/commands/realtime/deploy.ts index cbc57a33d..7a434e516 100644 --- a/packages/cli/src/cli/commands/realtime/deploy.ts +++ b/packages/cli/src/cli/commands/realtime/deploy.ts @@ -50,7 +50,9 @@ function formatDeployResult( } } -function buildDeploySummary(results: SingleRealtimeHandlerDeployResult[]): string { +function buildDeploySummary( + results: SingleRealtimeHandlerDeployResult[], +): string { const deployed = results.filter((r) => r.status === "deployed").length; const unchanged = results.filter((r) => r.status === "unchanged").length; const failed = results.filter((r) => r.status === "error").length; diff --git a/packages/cli/src/core/project/config.ts b/packages/cli/src/core/project/config.ts index a94a472c4..2fdb30fea 100644 --- a/packages/cli/src/core/project/config.ts +++ b/packages/cli/src/core/project/config.ts @@ -33,10 +33,7 @@ import { type BackendFunction, functionResource, } from "@/core/resources/function/index.js"; -import { - type RealtimeHandler, - realtimeHandlerResource, -} from "@/core/resources/realtime-handler/index.js"; +import { realtimeHandlerResource } from "@/core/resources/realtime-handler/index.js"; import { readJsonFile } from "@/core/utils/fs.js"; type ProjectResources = Omit; diff --git a/packages/cli/src/core/resources/realtime-handler/api.ts b/packages/cli/src/core/resources/realtime-handler/api.ts index c13abda2c..71c7df403 100644 --- a/packages/cli/src/core/resources/realtime-handler/api.ts +++ b/packages/cli/src/core/resources/realtime-handler/api.ts @@ -1,13 +1,9 @@ import type { KyResponse } from "ky"; import { getAppClient } from "@/core/clients/index.js"; import { ApiError, SchemaValidationError } from "@/core/errors.js"; -import type { - DeployRealtimeHandlerResponse, -} from "@/core/resources/realtime-handler/schema.js"; -import { - DeployRealtimeHandlerResponseSchema, -} from "@/core/resources/realtime-handler/schema.js"; import type { FunctionFile } from "@/core/resources/function/schema.js"; +import type { DeployRealtimeHandlerResponse } from "@/core/resources/realtime-handler/schema.js"; +import { DeployRealtimeHandlerResponseSchema } from "@/core/resources/realtime-handler/schema.js"; export async function deploySingleRealtimeHandler( name: string, diff --git a/packages/cli/src/core/resources/realtime-handler/config.ts b/packages/cli/src/core/resources/realtime-handler/config.ts index 7d42b7e28..42adc18e9 100644 --- a/packages/cli/src/core/resources/realtime-handler/config.ts +++ b/packages/cli/src/core/resources/realtime-handler/config.ts @@ -1,11 +1,14 @@ -import { basename, dirname, join, relative } from "node:path"; +import { basename, dirname, relative } from "node:path"; import { globby } from "globby"; import { ENTRY_FILE_GLOB, ENTRY_IGNORE_DOT_PATHS } from "@/core/consts.js"; import { InvalidInputError } from "@/core/errors.js"; import type { RealtimeHandler } from "@/core/resources/realtime-handler/schema.js"; import { pathExists } from "@/core/utils/fs.js"; -async function readRealtimeHandler(entryFile: string, realtimeDir: string): Promise { +async function readRealtimeHandler( + entryFile: string, + realtimeDir: string, +): Promise { const handlerDir = dirname(entryFile); const filePaths = await globby("**/*.ts", { cwd: handlerDir, diff --git a/packages/cli/src/core/resources/realtime-handler/deploy.ts b/packages/cli/src/core/resources/realtime-handler/deploy.ts index 4afd2c75e..64e78650e 100644 --- a/packages/cli/src/core/resources/realtime-handler/deploy.ts +++ b/packages/cli/src/core/resources/realtime-handler/deploy.ts @@ -1,7 +1,7 @@ import { dirname, relative } from "node:path"; +import type { FunctionFile } from "@/core/resources/function/schema.js"; import { deploySingleRealtimeHandler } from "@/core/resources/realtime-handler/api.js"; import type { RealtimeHandler } from "@/core/resources/realtime-handler/schema.js"; -import type { FunctionFile } from "@/core/resources/function/schema.js"; import { readTextFile } from "@/core/utils/fs.js"; async function loadHandlerCode( diff --git a/packages/cli/src/core/resources/realtime-handler/schema.ts b/packages/cli/src/core/resources/realtime-handler/schema.ts index df002b4c1..78dee24b7 100644 --- a/packages/cli/src/core/resources/realtime-handler/schema.ts +++ b/packages/cli/src/core/resources/realtime-handler/schema.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { ResourceSourceSchema } from "@/core/resources/types.js"; -export const RealtimeHandlerConfigSchema = z.object({ +const RealtimeHandlerConfigSchema = z.object({ name: z.string().min(1), entry: z.string().min(1), }); @@ -17,7 +17,7 @@ const RealtimeHandlerSchema = RealtimeHandlerConfigSchema.extend({ source: ResourceSourceSchema, }); -export type RealtimeHandlerConfig = z.infer; +type RealtimeHandlerConfig = z.infer; export type RealtimeHandler = z.infer; export type DeployRealtimeHandlerResponse = z.infer< typeof DeployRealtimeHandlerResponseSchema From 6703e73a58d2fd2ef0aee878b3a2f7df0007e20b Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 30 Jun 2026 14:38:28 +0300 Subject: [PATCH 03/22] fix(realtime): create handler inside base44/ dir, not project root new.ts used project.root but readAllRealtimeHandlers uses dirname(configPath), causing handlers to be created at realtime/ instead of base44/realtime/. Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/cli/commands/realtime/new.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/cli/commands/realtime/new.ts b/packages/cli/src/cli/commands/realtime/new.ts index ad94f4839..816b0e2f1 100644 --- a/packages/cli/src/cli/commands/realtime/new.ts +++ b/packages/cli/src/cli/commands/realtime/new.ts @@ -1,4 +1,4 @@ -import { join } from "node:path"; +import { dirname, join } from "node:path"; import type { Command } from "commander"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { Base44Command } from "@/cli/utils/index.js"; @@ -27,7 +27,7 @@ async function newRealtimeHandlerAction( handlerName: string, ): Promise { const { project } = await readProjectConfig(); - const realtimeDir = join(project.root, project.realtimeDir); + const realtimeDir = join(dirname(project.configPath), project.realtimeDir); const handlerDir = join(realtimeDir, handlerName); if (await pathExists(handlerDir)) { From bbfc7006faa1837f9c925d5a01fc80ad06a2d5b2 Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 30 Jun 2026 14:39:29 +0300 Subject: [PATCH 04/22] fix(realtime): scaffold imports RealtimeHandler from @base44/sdk 'base44' is the CLI package name and has no exported types. @base44/sdk now exports RealtimeHandler and Conn for type-checking, and the bundler rewrites the import to the CF shim at deploy time. Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/cli/commands/realtime/new.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/cli/commands/realtime/new.ts b/packages/cli/src/cli/commands/realtime/new.ts index 816b0e2f1..455a716b2 100644 --- a/packages/cli/src/cli/commands/realtime/new.ts +++ b/packages/cli/src/cli/commands/realtime/new.ts @@ -7,7 +7,7 @@ import { readProjectConfig } from "@/core/index.js"; import { pathExists, writeFile } from "@/core/utils/fs.js"; function buildHandlerScaffold(handlerName: string): string { - return `import { RealtimeHandler, type Conn } from "base44"; + return `import { RealtimeHandler, type Conn } from "@base44/sdk"; export class ${handlerName} extends RealtimeHandler { handleConnect(conn: Conn) { From 1c78357f27c48af54aa032d7bb0f2b1d1fae7273 Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 30 Jun 2026 14:40:44 +0300 Subject: [PATCH 05/22] fix(realtime): scaffold includes State/Message generic type parameters Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/cli/commands/realtime/new.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/cli/commands/realtime/new.ts b/packages/cli/src/cli/commands/realtime/new.ts index 455a716b2..52d23c416 100644 --- a/packages/cli/src/cli/commands/realtime/new.ts +++ b/packages/cli/src/cli/commands/realtime/new.ts @@ -9,11 +9,19 @@ import { pathExists, writeFile } from "@/core/utils/fs.js"; function buildHandlerScaffold(handlerName: string): string { return `import { RealtimeHandler, type Conn } from "@base44/sdk"; -export class ${handlerName} extends RealtimeHandler { +interface State { + // shared state broadcast to all clients +} + +interface Message { + // messages sent from clients +} + +export class ${handlerName} extends RealtimeHandler { handleConnect(conn: Conn) { console.log("Connected:", conn.userId); } - handleMessage(conn: Conn, msg: unknown) { + handleMessage(conn: Conn, msg: Message) { console.log("Message:", msg); } handleTick() {} From 4789b628137b8576f0c0bd41efd7cb5f6ec358f8 Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 30 Jun 2026 15:10:06 +0300 Subject: [PATCH 06/22] feat(types): auto-generate RealtimeHandlerRegistry from schema.jsonc - base44 types generate now includes realtime handlers in types.d.ts - RealtimeHandlerNameRegistry: auto-registers handler names (no manual declare needed) - RealtimeHandlerRegistry: compiled from schema.jsonc inbound/outbound JSON schemas - Add schema.jsonc support to realtime-handler resource reader - Update test fixture with ChatRoom schema and assertions Co-Authored-By: Claude Sonnet 4.6 --- .../cli/src/cli/commands/types/generate.ts | 3 +- .../core/resources/realtime-handler/config.ts | 21 ++++++- .../core/resources/realtime-handler/schema.ts | 15 ++++- packages/cli/src/core/types/generator.ts | 59 ++++++++++++++++--- packages/cli/tests/cli/types_generate.spec.ts | 10 +++- .../base44/realtime/ChatRoom/entry.ts | 8 +++ .../base44/realtime/ChatRoom/schema.jsonc | 19 ++++++ 7 files changed, 122 insertions(+), 13 deletions(-) create mode 100644 packages/cli/tests/fixtures/with-types-resources/base44/realtime/ChatRoom/entry.ts create mode 100644 packages/cli/tests/fixtures/with-types-resources/base44/realtime/ChatRoom/schema.jsonc diff --git a/packages/cli/src/cli/commands/types/generate.ts b/packages/cli/src/cli/commands/types/generate.ts index f74545af1..973fd3183 100644 --- a/packages/cli/src/cli/commands/types/generate.ts +++ b/packages/cli/src/cli/commands/types/generate.ts @@ -9,7 +9,7 @@ const TYPES_FILE_PATH = "base44/.types/types.d.ts"; async function generateTypesAction({ runTask, }: CLIContext): Promise { - const { entities, functions, agents, connectors, project } = + const { entities, functions, agents, connectors, realtimeHandlers, project } = await readProjectConfig(); await runTask("Generating types", async () => { @@ -19,6 +19,7 @@ async function generateTypesAction({ functions, agents, connectors, + realtimeHandlers, }); }); diff --git a/packages/cli/src/core/resources/realtime-handler/config.ts b/packages/cli/src/core/resources/realtime-handler/config.ts index 42adc18e9..3466c6dab 100644 --- a/packages/cli/src/core/resources/realtime-handler/config.ts +++ b/packages/cli/src/core/resources/realtime-handler/config.ts @@ -1,9 +1,10 @@ -import { basename, dirname, relative } from "node:path"; +import { basename, dirname, join, relative } from "node:path"; import { globby } from "globby"; import { ENTRY_FILE_GLOB, ENTRY_IGNORE_DOT_PATHS } from "@/core/consts.js"; import { InvalidInputError } from "@/core/errors.js"; -import type { RealtimeHandler } from "@/core/resources/realtime-handler/schema.js"; -import { pathExists } from "@/core/utils/fs.js"; +import type { RealtimeHandler, RealtimeMessageSchema } from "@/core/resources/realtime-handler/schema.js"; +import { RealtimeHandlerSchemaFileSchema } from "@/core/resources/realtime-handler/schema.js"; +import { pathExists, readJsonFile } from "@/core/utils/fs.js"; async function readRealtimeHandler( entryFile: string, @@ -31,12 +32,26 @@ async function readRealtimeHandler( const entry = basename(entryFile); + const schemaPath = join(handlerDir, "schema.jsonc"); + let messageSchema: RealtimeMessageSchema | undefined = undefined; + if (await pathExists(schemaPath)) { + const parsed = await readJsonFile(schemaPath); + const result = RealtimeHandlerSchemaFileSchema.safeParse(parsed); + if (result.success) { + messageSchema = { + inbound: result.data.inbound as Record | undefined, + outbound: result.data.outbound as Record | undefined, + }; + } + } + return { name, entry, entryPath: entryFile, filePaths, source: { type: "project" }, + messageSchema, }; } diff --git a/packages/cli/src/core/resources/realtime-handler/schema.ts b/packages/cli/src/core/resources/realtime-handler/schema.ts index 78dee24b7..b41ec8cd9 100644 --- a/packages/cli/src/core/resources/realtime-handler/schema.ts +++ b/packages/cli/src/core/resources/realtime-handler/schema.ts @@ -6,6 +6,11 @@ const RealtimeHandlerConfigSchema = z.object({ entry: z.string().min(1), }); +export const RealtimeHandlerSchemaFileSchema = z.object({ + inbound: z.unknown().optional(), + outbound: z.unknown().optional(), +}); + export const DeployRealtimeHandlerResponseSchema = z.object({ status: z.enum(["deployed", "unchanged"]), handler_name: z.string().optional(), @@ -15,10 +20,18 @@ const RealtimeHandlerSchema = RealtimeHandlerConfigSchema.extend({ entryPath: z.string().min(1), filePaths: z.array(z.string()).min(1), source: ResourceSourceSchema, + messageSchema: z.unknown().optional(), }); +export interface RealtimeMessageSchema { + inbound?: Record; + outbound?: Record; +} + type RealtimeHandlerConfig = z.infer; -export type RealtimeHandler = z.infer; +export type RealtimeHandler = Omit, "messageSchema"> & { + messageSchema?: RealtimeMessageSchema; +}; export type DeployRealtimeHandlerResponse = z.infer< typeof DeployRealtimeHandlerResponseSchema >; diff --git a/packages/cli/src/core/types/generator.ts b/packages/cli/src/core/types/generator.ts index 6297f9c1b..2248f0671 100644 --- a/packages/cli/src/core/types/generator.ts +++ b/packages/cli/src/core/types/generator.ts @@ -7,6 +7,7 @@ import type { AgentConfig } from "@/core/resources/agent/index.js"; import type { ConnectorResource } from "@/core/resources/connector/index.js"; import type { Entity } from "@/core/resources/entity/index.js"; import type { BackendFunction } from "@/core/resources/function/index.js"; +import type { RealtimeHandler } from "@/core/resources/realtime-handler/schema.js"; import { writeFile } from "@/core/utils/fs.js"; interface GenerateTypesInput { @@ -15,6 +16,7 @@ interface GenerateTypesInput { functions: BackendFunction[]; agents: AgentConfig[]; connectors: ConnectorResource[]; + realtimeHandlers: RealtimeHandler[]; } const HEADER = stripIndent` @@ -26,8 +28,8 @@ const EMPTY_TEMPLATE = stripIndent` // Auto-generated by Base44 CLI - DO NOT EDIT // Regenerate with: base44 types // - // No entities, functions, agents, or connectors found in project. - // Add resources to base44/entities/, base44/functions/, base44/agents/, or base44/connectors/ + // No entities, functions, agents, connectors, or realtime handlers found in project. + // Add resources to base44/entities/, base44/functions/, base44/agents/, base44/connectors/, or base44/realtime/ // and run \`base44 types generate\` again. declare module '@base44/sdk' { @@ -46,20 +48,22 @@ export async function generateTypesFile( } async function generateContent(input: GenerateTypesInput): Promise { - const { entities, functions, agents, connectors } = input; + const { entities, functions, agents, connectors, realtimeHandlers } = input; if ( !entities.length && !functions.length && !agents.length && - !connectors.length + !connectors.length && + !realtimeHandlers.length ) { return EMPTY_TEMPLATE; } - const entityInterfaces = await Promise.all( - entities.map((e) => compileEntity(e)), - ); + const [entityInterfaces, realtimeRegistryEntries] = await Promise.all([ + Promise.all(entities.map((e) => compileEntity(e))), + Promise.all(realtimeHandlers.map((h) => compileRealtimeHandler(h))), + ]); // Build registry entries const registryEntries: [string, string[]][] = [ @@ -70,6 +74,19 @@ async function generateContent(input: GenerateTypesInput): Promise { ["FunctionNameRegistry", functions.map((f) => `"${f.name}": true;`)], ["AgentNameRegistry", agents.map((a) => `"${a.name}": true;`)], ["ConnectorTypeRegistry", connectors.map((c) => `"${c.type}": true;`)], + [ + "RealtimeHandlerNameRegistry", + realtimeHandlers.map((h) => `"${h.name}": true;`), + ], + [ + "RealtimeHandlerRegistry", + realtimeHandlers + .filter((h) => h.messageSchema) + .map((h, _, arr) => { + const idx = realtimeHandlers.indexOf(h); + return `"${h.name}": ${realtimeRegistryEntries[idx]};`; + }), + ], ]; // Generate registries (only for non-empty entries) @@ -115,6 +132,34 @@ async function compileEntity(entity: Entity): Promise { } } +async function compileRealtimeHandler(handler: RealtimeHandler): Promise { + const { messageSchema } = handler; + if (!messageSchema) return "{ inbound: unknown; outbound: unknown }"; + + const compileSchema = async (schema: Record | undefined, typeName: string): Promise => { + if (!schema) return "unknown"; + try { + const ts = await compile(schema as JSONSchema4, typeName, { + bannerComment: "", + additionalProperties: false, + strictIndexSignatures: true, + }); + // extract just the interface body, not the full `interface X { ... }` declaration + const match = ts.match(/\{([^]*)\}/); + return match ? `{\n${match[1]}}` : "unknown"; + } catch { + return "unknown"; + } + }; + + const [inbound, outbound] = await Promise.all([ + compileSchema(messageSchema.inbound as Record | undefined, `${handler.name}Inbound`), + compileSchema(messageSchema.outbound as Record | undefined, `${handler.name}Outbound`), + ]); + + return `{ inbound: ${inbound}; outbound: ${outbound} }`; +} + function registry(name: string, entries: string[]): string { return source` interface ${name} { diff --git a/packages/cli/tests/cli/types_generate.spec.ts b/packages/cli/tests/cli/types_generate.spec.ts index 9232d9e13..98acbc3e1 100644 --- a/packages/cli/tests/cli/types_generate.spec.ts +++ b/packages/cli/tests/cli/types_generate.spec.ts @@ -45,6 +45,14 @@ describe("types generate command", () => { // Contains the ConnectorTypeRegistry with the connector type expect(typesContent).toContain("ConnectorTypeRegistry"); expect(typesContent).toContain(`"slack": true`); + + // Contains the RealtimeHandlerNameRegistry with the handler name + expect(typesContent).toContain("RealtimeHandlerNameRegistry"); + expect(typesContent).toContain(`"ChatRoom": true`); + + // Contains the RealtimeHandlerRegistry with typed inbound/outbound (from schema.jsonc) + expect(typesContent).toContain("RealtimeHandlerRegistry"); + expect(typesContent).toContain(`"ChatRoom"`); }); it("updates tsconfig.json to include types path", async () => { @@ -103,7 +111,7 @@ describe("types generate command", () => { const typesContent = await t.readProjectFile("base44/.types/types.d.ts"); expect(typesContent).not.toBeNull(); expect(typesContent).toContain( - "No entities, functions, agents, or connectors found", + "No entities, functions, agents, connectors, or realtime handlers found", ); }); diff --git a/packages/cli/tests/fixtures/with-types-resources/base44/realtime/ChatRoom/entry.ts b/packages/cli/tests/fixtures/with-types-resources/base44/realtime/ChatRoom/entry.ts new file mode 100644 index 000000000..91a9c29a2 --- /dev/null +++ b/packages/cli/tests/fixtures/with-types-resources/base44/realtime/ChatRoom/entry.ts @@ -0,0 +1,8 @@ +import { RealtimeHandler, type Conn } from "@base44/sdk"; + +export class ChatRoom extends RealtimeHandler { + handleConnect(_conn: Conn) {} + handleMessage(_conn: Conn, _msg: unknown) {} + handleTick() {} + handleClose(_conn: Conn) {} +} diff --git a/packages/cli/tests/fixtures/with-types-resources/base44/realtime/ChatRoom/schema.jsonc b/packages/cli/tests/fixtures/with-types-resources/base44/realtime/ChatRoom/schema.jsonc new file mode 100644 index 000000000..760269e56 --- /dev/null +++ b/packages/cli/tests/fixtures/with-types-resources/base44/realtime/ChatRoom/schema.jsonc @@ -0,0 +1,19 @@ +{ + "inbound": { + "type": "object", + "properties": { + "type": { "type": "string", "enum": ["joined", "left", "message"] }, + "userId": { "type": "string" }, + "from": { "type": "string" }, + "text": { "type": "string" } + }, + "required": ["type"] + }, + "outbound": { + "type": "object", + "properties": { + "text": { "type": "string" } + }, + "required": ["text"] + } +} From 39ec1cb622da6b285c821efda18f78c5c364f2f2 Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 30 Jun 2026 15:16:39 +0300 Subject: [PATCH 07/22] fix(types): detect SDK package name and use module context in types.d.ts - Detect @base44/sdk vs @base44-preview/sdk from project's package.json so declare module targets the correct package name - Add export {} to generated types.d.ts to ensure module context, preventing ambient module from shadowing the SDK package types Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/core/types/generator.ts | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/core/types/generator.ts b/packages/cli/src/core/types/generator.ts index 2248f0671..6ecdfe005 100644 --- a/packages/cli/src/core/types/generator.ts +++ b/packages/cli/src/core/types/generator.ts @@ -1,3 +1,4 @@ +import { join } from "node:path"; import { source, stripIndent } from "common-tags"; import type { JSONSchema4 } from "json-schema"; import { compile } from "json-schema-to-typescript"; @@ -8,7 +9,7 @@ import type { ConnectorResource } from "@/core/resources/connector/index.js"; import type { Entity } from "@/core/resources/entity/index.js"; import type { BackendFunction } from "@/core/resources/function/index.js"; import type { RealtimeHandler } from "@/core/resources/realtime-handler/schema.js"; -import { writeFile } from "@/core/utils/fs.js"; +import { pathExists, readJsonFile, writeFile } from "@/core/utils/fs.js"; interface GenerateTypesInput { projectRoot: string; @@ -37,6 +38,22 @@ const EMPTY_TEMPLATE = stripIndent` } `; +const SDK_PACKAGE_NAMES = ["@base44/sdk", "@base44-preview/sdk"] as const; +type SdkPackageName = (typeof SDK_PACKAGE_NAMES)[number]; + +async function detectSdkPackageName(projectRoot: string): Promise { + try { + const pkg = await readJsonFile(join(projectRoot, "package.json")) as Record; + const deps = { ...(pkg.dependencies as object), ...(pkg.devDependencies as object) }; + for (const name of SDK_PACKAGE_NAMES) { + if (name in deps) return name; + } + } catch { + // ignore + } + return "@base44/sdk"; +} + /** * Generate and write types.d.ts file. */ @@ -49,6 +66,7 @@ export async function generateTypesFile( async function generateContent(input: GenerateTypesInput): Promise { const { entities, functions, agents, connectors, realtimeHandlers } = input; + const sdkPackage = await detectSdkPackageName(input.projectRoot); if ( !entities.length && @@ -96,9 +114,10 @@ async function generateContent(input: GenerateTypesInput): Promise { return [ HEADER, + "export {};", // module context — ensures declare module augments rather than replaces the SDK package entityInterfaces.join("\n\n"), source` - declare module '@base44/sdk' { + declare module '${sdkPackage}' { ${registries.join("\n\n")} } `, From 3512a4dbd820ab76fb89bbf28ab6fd7e2799297b Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 30 Jun 2026 15:19:24 +0300 Subject: [PATCH 08/22] fix(lint): resolve Biome errors in realtime handler types - Remove unused RealtimeHandlerConfig type alias - Replace [^]* regex with [\s\S]* (Biome noEmptyCharacterClassInRegex) - Auto-format long lines per Biome formatter rules Co-Authored-By: Claude Sonnet 4.6 --- .../core/resources/realtime-handler/config.ts | 7 +++- .../core/resources/realtime-handler/schema.ts | 6 ++- packages/cli/src/core/types/generator.ts | 38 ++++++++++++++----- 3 files changed, 37 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/core/resources/realtime-handler/config.ts b/packages/cli/src/core/resources/realtime-handler/config.ts index 3466c6dab..bf4002eb8 100644 --- a/packages/cli/src/core/resources/realtime-handler/config.ts +++ b/packages/cli/src/core/resources/realtime-handler/config.ts @@ -2,7 +2,10 @@ import { basename, dirname, join, relative } from "node:path"; import { globby } from "globby"; import { ENTRY_FILE_GLOB, ENTRY_IGNORE_DOT_PATHS } from "@/core/consts.js"; import { InvalidInputError } from "@/core/errors.js"; -import type { RealtimeHandler, RealtimeMessageSchema } from "@/core/resources/realtime-handler/schema.js"; +import type { + RealtimeHandler, + RealtimeMessageSchema, +} from "@/core/resources/realtime-handler/schema.js"; import { RealtimeHandlerSchemaFileSchema } from "@/core/resources/realtime-handler/schema.js"; import { pathExists, readJsonFile } from "@/core/utils/fs.js"; @@ -33,7 +36,7 @@ async function readRealtimeHandler( const entry = basename(entryFile); const schemaPath = join(handlerDir, "schema.jsonc"); - let messageSchema: RealtimeMessageSchema | undefined = undefined; + let messageSchema: RealtimeMessageSchema | undefined; if (await pathExists(schemaPath)) { const parsed = await readJsonFile(schemaPath); const result = RealtimeHandlerSchemaFileSchema.safeParse(parsed); diff --git a/packages/cli/src/core/resources/realtime-handler/schema.ts b/packages/cli/src/core/resources/realtime-handler/schema.ts index b41ec8cd9..fe0027cb4 100644 --- a/packages/cli/src/core/resources/realtime-handler/schema.ts +++ b/packages/cli/src/core/resources/realtime-handler/schema.ts @@ -28,8 +28,10 @@ export interface RealtimeMessageSchema { outbound?: Record; } -type RealtimeHandlerConfig = z.infer; -export type RealtimeHandler = Omit, "messageSchema"> & { +export type RealtimeHandler = Omit< + z.infer, + "messageSchema" +> & { messageSchema?: RealtimeMessageSchema; }; export type DeployRealtimeHandlerResponse = z.infer< diff --git a/packages/cli/src/core/types/generator.ts b/packages/cli/src/core/types/generator.ts index 6ecdfe005..47f86ac93 100644 --- a/packages/cli/src/core/types/generator.ts +++ b/packages/cli/src/core/types/generator.ts @@ -9,7 +9,7 @@ import type { ConnectorResource } from "@/core/resources/connector/index.js"; import type { Entity } from "@/core/resources/entity/index.js"; import type { BackendFunction } from "@/core/resources/function/index.js"; import type { RealtimeHandler } from "@/core/resources/realtime-handler/schema.js"; -import { pathExists, readJsonFile, writeFile } from "@/core/utils/fs.js"; +import { readJsonFile, writeFile } from "@/core/utils/fs.js"; interface GenerateTypesInput { projectRoot: string; @@ -41,10 +41,17 @@ const EMPTY_TEMPLATE = stripIndent` const SDK_PACKAGE_NAMES = ["@base44/sdk", "@base44-preview/sdk"] as const; type SdkPackageName = (typeof SDK_PACKAGE_NAMES)[number]; -async function detectSdkPackageName(projectRoot: string): Promise { +async function detectSdkPackageName( + projectRoot: string, +): Promise { try { - const pkg = await readJsonFile(join(projectRoot, "package.json")) as Record; - const deps = { ...(pkg.dependencies as object), ...(pkg.devDependencies as object) }; + const pkg = (await readJsonFile( + join(projectRoot, "package.json"), + )) as Record; + const deps = { + ...(pkg.dependencies as object), + ...(pkg.devDependencies as object), + }; for (const name of SDK_PACKAGE_NAMES) { if (name in deps) return name; } @@ -100,7 +107,7 @@ async function generateContent(input: GenerateTypesInput): Promise { "RealtimeHandlerRegistry", realtimeHandlers .filter((h) => h.messageSchema) - .map((h, _, arr) => { + .map((h, _, _arr) => { const idx = realtimeHandlers.indexOf(h); return `"${h.name}": ${realtimeRegistryEntries[idx]};`; }), @@ -151,11 +158,16 @@ async function compileEntity(entity: Entity): Promise { } } -async function compileRealtimeHandler(handler: RealtimeHandler): Promise { +async function compileRealtimeHandler( + handler: RealtimeHandler, +): Promise { const { messageSchema } = handler; if (!messageSchema) return "{ inbound: unknown; outbound: unknown }"; - const compileSchema = async (schema: Record | undefined, typeName: string): Promise => { + const compileSchema = async ( + schema: Record | undefined, + typeName: string, + ): Promise => { if (!schema) return "unknown"; try { const ts = await compile(schema as JSONSchema4, typeName, { @@ -164,7 +176,7 @@ async function compileRealtimeHandler(handler: RealtimeHandler): Promise strictIndexSignatures: true, }); // extract just the interface body, not the full `interface X { ... }` declaration - const match = ts.match(/\{([^]*)\}/); + const match = ts.match(/\{([\s\S]*)\}/); return match ? `{\n${match[1]}}` : "unknown"; } catch { return "unknown"; @@ -172,8 +184,14 @@ async function compileRealtimeHandler(handler: RealtimeHandler): Promise }; const [inbound, outbound] = await Promise.all([ - compileSchema(messageSchema.inbound as Record | undefined, `${handler.name}Inbound`), - compileSchema(messageSchema.outbound as Record | undefined, `${handler.name}Outbound`), + compileSchema( + messageSchema.inbound as Record | undefined, + `${handler.name}Inbound`, + ), + compileSchema( + messageSchema.outbound as Record | undefined, + `${handler.name}Outbound`, + ), ]); return `{ inbound: ${inbound}; outbound: ${outbound} }`; From 3a0c923085b9f5625e9b3ada9b751991e48eb01b Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 30 Jun 2026 16:14:38 +0300 Subject: [PATCH 09/22] fix(realtime): use /realtime-handlers endpoint for handler deploy The dedicated endpoint calls ensure_cfw_backend and uses force_per_function so the bundler runs applyRealtimeCompat instead of the per-app path. Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/core/resources/realtime-handler/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/core/resources/realtime-handler/api.ts b/packages/cli/src/core/resources/realtime-handler/api.ts index 71c7df403..b185539c3 100644 --- a/packages/cli/src/core/resources/realtime-handler/api.ts +++ b/packages/cli/src/core/resources/realtime-handler/api.ts @@ -14,7 +14,7 @@ export async function deploySingleRealtimeHandler( let response: KyResponse; try { response = await appClient.put( - `backend-functions/${encodeURIComponent(name)}`, + `realtime-handlers/${encodeURIComponent(name)}`, { json: payload, timeout: false }, ); } catch (error) { From ef7c8f9ebed443edc11350c53e55e1ec091f86e7 Mon Sep 17 00:00:00 2001 From: imrik Date: Sun, 5 Jul 2026 13:54:15 +0300 Subject: [PATCH 10/22] fix(types): compile realtime messages as a named catalog, drop the regex schema.jsonc is now a catalog of named messages (inbound/outbound maps of message-name -> full JSON Schema, like entities) plus optional shared `types`. compileRealtimeHandler emits one named interface per message (direction- and handler-prefixed to avoid collisions) + shared types, and composes the inbound/outbound unions in the registry. Removes the /\{([\s\S]*)\}/ body-scrape, which produced invalid TS whenever json-schema-to-typescript emitted more than one declaration (unions with $defs). Because every message is a single flat object, that multi-declaration case can no longer arise. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../core/resources/realtime-handler/config.ts | 1 + .../core/resources/realtime-handler/schema.ts | 9 +- packages/cli/src/core/types/generator.ts | 170 ++++++++++++++---- .../cli/tests/core/types-realtime.spec.ts | 90 ++++++++++ .../base44/realtime/ChatRoom/schema.jsonc | 33 ++-- 5 files changed, 256 insertions(+), 47 deletions(-) create mode 100644 packages/cli/tests/core/types-realtime.spec.ts diff --git a/packages/cli/src/core/resources/realtime-handler/config.ts b/packages/cli/src/core/resources/realtime-handler/config.ts index bf4002eb8..bb5df4e89 100644 --- a/packages/cli/src/core/resources/realtime-handler/config.ts +++ b/packages/cli/src/core/resources/realtime-handler/config.ts @@ -42,6 +42,7 @@ async function readRealtimeHandler( const result = RealtimeHandlerSchemaFileSchema.safeParse(parsed); if (result.success) { messageSchema = { + types: result.data.types as Record | undefined, inbound: result.data.inbound as Record | undefined, outbound: result.data.outbound as Record | undefined, }; diff --git a/packages/cli/src/core/resources/realtime-handler/schema.ts b/packages/cli/src/core/resources/realtime-handler/schema.ts index fe0027cb4..b8e953742 100644 --- a/packages/cli/src/core/resources/realtime-handler/schema.ts +++ b/packages/cli/src/core/resources/realtime-handler/schema.ts @@ -6,9 +6,13 @@ const RealtimeHandlerConfigSchema = z.object({ entry: z.string().min(1), }); +// A handler's schema.jsonc is a catalog of named messages: `inbound`/`outbound` +// each map a message name to its (type-less) object schema, and optional `types` +// holds shared shapes referenced via `#/types/`. See the type generator. export const RealtimeHandlerSchemaFileSchema = z.object({ - inbound: z.unknown().optional(), - outbound: z.unknown().optional(), + types: z.record(z.string(), z.unknown()).optional(), + inbound: z.record(z.string(), z.unknown()).optional(), + outbound: z.record(z.string(), z.unknown()).optional(), }); export const DeployRealtimeHandlerResponseSchema = z.object({ @@ -24,6 +28,7 @@ const RealtimeHandlerSchema = RealtimeHandlerConfigSchema.extend({ }); export interface RealtimeMessageSchema { + types?: Record; inbound?: Record; outbound?: Record; } diff --git a/packages/cli/src/core/types/generator.ts b/packages/cli/src/core/types/generator.ts index 47f86ac93..d701abdd0 100644 --- a/packages/cli/src/core/types/generator.ts +++ b/packages/cli/src/core/types/generator.ts @@ -71,7 +71,9 @@ export async function generateTypesFile( await writeFile(getTypesOutputPath(input.projectRoot), content); } -async function generateContent(input: GenerateTypesInput): Promise { +export async function generateContent( + input: GenerateTypesInput, +): Promise { const { entities, functions, agents, connectors, realtimeHandlers } = input; const sdkPackage = await detectSdkPackageName(input.projectRoot); @@ -85,7 +87,7 @@ async function generateContent(input: GenerateTypesInput): Promise { return EMPTY_TEMPLATE; } - const [entityInterfaces, realtimeRegistryEntries] = await Promise.all([ + const [entityInterfaces, realtimeResults] = await Promise.all([ Promise.all(entities.map((e) => compileEntity(e))), Promise.all(realtimeHandlers.map((h) => compileRealtimeHandler(h))), ]); @@ -107,9 +109,9 @@ async function generateContent(input: GenerateTypesInput): Promise { "RealtimeHandlerRegistry", realtimeHandlers .filter((h) => h.messageSchema) - .map((h, _, _arr) => { + .map((h) => { const idx = realtimeHandlers.indexOf(h); - return `"${h.name}": ${realtimeRegistryEntries[idx]};`; + return `"${h.name}": ${realtimeResults[idx].entry};`; }), ], ]; @@ -119,10 +121,15 @@ async function generateContent(input: GenerateTypesInput): Promise { .filter(([, entries]) => entries.length > 0) .map(([name, entries]) => registry(name, entries)); + const realtimeInterfaces = realtimeResults + .map((r) => r.decls) + .filter(Boolean); + return [ HEADER, "export {};", // module context — ensures declare module augments rather than replaces the SDK package entityInterfaces.join("\n\n"), + realtimeInterfaces.join("\n\n"), source` declare module '${sdkPackage}' { ${registries.join("\n\n")} @@ -158,43 +165,140 @@ async function compileEntity(entity: Entity): Promise { } } +interface RealtimeCompileResult { + /** Top-level `export` declarations: one interface per message + shared types. */ + decls: string; + /** The registry value, e.g. `{ inbound: FooInit | FooTick; outbound: FooJoin }`. */ + entry: string; +} + +/** + * A handler's `schema.jsonc` is a *catalog* of named messages: + * { types?: { Pt, Snake, … }, inbound: { init, tick, … }, outbound: { join, … } } + * Each message is a flat object schema (no `type` field — the generator injects + * `type: ""` as the discriminant). We compile the whole catalog in ONE pass + * so json-schema-to-typescript emits a named interface per message plus the shared + * types, then assemble the inbound/outbound unions from those names. This avoids + * scraping the compiler output (the old regex broke on unions and `$defs`), and + * because every message is a single flat object, the fragile multi-declaration + * case never arises. + */ async function compileRealtimeHandler( handler: RealtimeHandler, -): Promise { +): Promise { const { messageSchema } = handler; - if (!messageSchema) return "{ inbound: unknown; outbound: unknown }"; - - const compileSchema = async ( - schema: Record | undefined, - typeName: string, - ): Promise => { - if (!schema) return "unknown"; - try { - const ts = await compile(schema as JSONSchema4, typeName, { + if (!messageSchema) { + return { decls: "", entry: "{ inbound: unknown; outbound: unknown }" }; + } + + const prefix = toPascalCase(handler.name); + const types = (messageSchema.types ?? {}) as Record; + const inbound = (messageSchema.inbound ?? {}) as Record; + const outbound = (messageSchema.outbound ?? {}) as Record; + + // Shared types are prefixed with the handler name so names (Pt, Snake, …) can't + // collide across handlers or with entity interfaces. Messages additionally carry + // their direction, since the same name (e.g. "message") may appear both inbound + // and outbound. + const typeName = (key: string) => `${prefix}${toPascalCase(key)}`; + const msgName = (dir: "Inbound" | "Outbound", key: string) => + `${prefix}${dir}${toPascalCase(key)}`; + + const defs: Record = {}; + const add = (name: string, schema: JSONSchema4) => { + if (name in defs) { + throw new TypeGenerationError( + `Duplicate generated type "${name}" in realtime handler "${handler.name}" — a shared type and a message resolve to the same name.`, + handler.name, + ); + } + defs[name] = { ...schema, title: name }; + }; + + // Shared types are emitted as-is (author writes `type: "object"` etc.); their + // author-facing `#/types/X` refs are rewritten to the prefixed `#/$defs/` names. + for (const [key, schema] of Object.entries(types)) { + add(typeName(key), rewriteTypeRefs(schema, typeName) as JSONSchema4); + } + + // Each message → one flat object (a full JSON Schema, like an entity) with the + // `type` discriminant injected from its key. + const compileMessages = ( + msgs: Record, + dir: "Inbound" | "Outbound", + ): string[] => + Object.entries(msgs).map(([key, schema]) => { + const name = msgName(dir, key); + const rewritten = rewriteTypeRefs(schema, typeName) as JSONSchema4; + add(name, { + type: "object", + ...rewritten, + properties: { type: { const: key }, ...(rewritten.properties ?? {}) }, + required: ["type", ...((rewritten.required as string[] | undefined) ?? [])], + additionalProperties: false, + }); + return name; + }); + + const inboundNames = compileMessages(inbound, "Inbound"); + const outboundNames = compileMessages(outbound, "Outbound"); + + // Root union over every message keeps all defs reachable so the compiler emits + // them; we keep its whole output verbatim (no scraping). + const allNames = [...inboundNames, ...outboundNames]; + const rootName = `${prefix}Message`; + const rootSchema = { + title: rootName, + $defs: defs, + oneOf: allNames.map((n) => ({ $ref: `#/$defs/${n}` })), + } as unknown as JSONSchema4; + + let decls = ""; + try { + decls = ( + await compile(rootSchema, rootName, { bannerComment: "", additionalProperties: false, strictIndexSignatures: true, - }); - // extract just the interface body, not the full `interface X { ... }` declaration - const match = ts.match(/\{([\s\S]*)\}/); - return match ? `{\n${match[1]}}` : "unknown"; - } catch { - return "unknown"; - } - }; + }) + ).trim(); + } catch (error) { + throw new TypeGenerationError( + `Failed to generate types for realtime handler "${handler.name}"`, + handler.name, + error, + ); + } - const [inbound, outbound] = await Promise.all([ - compileSchema( - messageSchema.inbound as Record | undefined, - `${handler.name}Inbound`, - ), - compileSchema( - messageSchema.outbound as Record | undefined, - `${handler.name}Outbound`, - ), - ]); + const union = (names: string[]) => (names.length ? names.join(" | ") : "never"); + return { + decls, + entry: `{ inbound: ${union(inboundNames)}; outbound: ${union(outboundNames)} }`, + }; +} - return `{ inbound: ${inbound}; outbound: ${outbound} }`; +/** Rewrite author-facing `#/types/X` refs to the prefixed `#/$defs/`. */ +function rewriteTypeRefs( + node: unknown, + defName: (key: string) => string, +): unknown { + if (Array.isArray(node)) { + return node.map((n) => rewriteTypeRefs(n, defName)); + } + if (node && typeof node === "object") { + const out: Record = {}; + for (const [key, value] of Object.entries(node)) { + const match = + key === "$ref" && typeof value === "string" + ? value.match(/^#\/types\/(.+)$/) + : null; + out[key] = match + ? `#/$defs/${defName(match[1])}` + : rewriteTypeRefs(value, defName); + } + return out; + } + return node; } function registry(name: string, entries: string[]): string { diff --git a/packages/cli/tests/core/types-realtime.spec.ts b/packages/cli/tests/core/types-realtime.spec.ts new file mode 100644 index 000000000..08b9bb667 --- /dev/null +++ b/packages/cli/tests/core/types-realtime.spec.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import type { RealtimeHandler } from "@/core/resources/realtime-handler/schema.js"; +import { generateContent } from "@/core/types/generator.js"; + +const EMPTY = { + projectRoot: "/tmp/does-not-matter", // only read for package.json detect; falls back to @base44/sdk + entities: [], + functions: [], + agents: [], + connectors: [], +}; + +function handler(messageSchema: RealtimeHandler["messageSchema"]): RealtimeHandler { + return { + name: "GameRoom", + entry: "entry.ts", + entryPath: "base44/realtime/GameRoom/entry.ts", + filePaths: ["base44/realtime/GameRoom/entry.ts"], + source: { type: "project" }, + messageSchema, + }; +} + +describe("realtime handler type generation", () => { + it("compiles a named-message catalog into a discriminated union with shared types", async () => { + const out = await generateContent({ + ...EMPTY, + realtimeHandlers: [ + handler({ + types: { + Pt: { + type: "object", + properties: { x: { type: "number" }, y: { type: "number" } }, + required: ["x", "y"], + additionalProperties: false, + }, + }, + inbound: { + init: { + properties: { food: { type: "array", items: { $ref: "#/types/Pt" } } }, + required: ["food"], + }, + died: { + properties: { id: { type: "string" }, score: { type: "number" } }, + required: ["id", "score"], + }, + }, + outbound: { + dir: { properties: { angle: { type: "number" } }, required: ["angle"] }, + }, + }), + ], + }); + + // `type` discriminant is injected from the message key (author omits it). + expect(out).toContain('type: "init"'); + expect(out).toContain('type: "died"'); + expect(out).toContain('type: "dir"'); + // Shared type is emitted once, prefixed with the handler name (collision-safe), + // and referenced by name — not re-inlined. + expect(out).toContain("export interface GameRoomPt"); + expect(out).toContain("food: GameRoomPt[]"); + // Message interfaces carry their direction (so the same name can appear both + // inbound and outbound); the registry composes the unions from them. + expect(out).toContain( + '"GameRoom": { inbound: GameRoomInboundInit | GameRoomInboundDied; outbound: GameRoomOutboundDir }', + ); + // Output is valid TS: no `export interface` spliced inside a type literal + // (the failure mode of the old regex-based extraction). + expect(out).not.toMatch(/\{[^}]*export interface/); + }); + + it("throws on a name collision instead of silently clobbering", async () => { + await expect( + generateContent({ + ...EMPTY, + realtimeHandlers: [ + handler({ + // Both keys PascalCase to the same GameRoomInboundUserJoined. + inbound: { + "user-joined": { properties: { a: { type: "string" } } }, + userJoined: { properties: { b: { type: "string" } } }, + }, + outbound: {}, + }), + ], + }), + ).rejects.toThrow(/Duplicate generated type "GameRoomInboundUserJoined"/); + }); +}); diff --git a/packages/cli/tests/fixtures/with-types-resources/base44/realtime/ChatRoom/schema.jsonc b/packages/cli/tests/fixtures/with-types-resources/base44/realtime/ChatRoom/schema.jsonc index 760269e56..2a077651a 100644 --- a/packages/cli/tests/fixtures/with-types-resources/base44/realtime/ChatRoom/schema.jsonc +++ b/packages/cli/tests/fixtures/with-types-resources/base44/realtime/ChatRoom/schema.jsonc @@ -1,19 +1,28 @@ { + // Message catalog: each entry is a full JSON Schema (like an entity), keyed by + // message name. The generator injects `type: ""` as the discriminant. "inbound": { - "type": "object", - "properties": { - "type": { "type": "string", "enum": ["joined", "left", "message"] }, - "userId": { "type": "string" }, - "from": { "type": "string" }, - "text": { "type": "string" } + "joined": { + "type": "object", + "properties": { "userId": { "type": "string" } }, + "required": ["userId"] }, - "required": ["type"] + "left": { + "type": "object", + "properties": { "userId": { "type": "string" } }, + "required": ["userId"] + }, + "message": { + "type": "object", + "properties": { "from": { "type": "string" }, "text": { "type": "string" } }, + "required": ["from", "text"] + } }, "outbound": { - "type": "object", - "properties": { - "text": { "type": "string" } - }, - "required": ["text"] + "message": { + "type": "object", + "properties": { "text": { "type": "string" } }, + "required": ["text"] + } } } From 5efe4c3322c203b2388ace20d82452b127cb835c Mon Sep 17 00:00:00 2001 From: imrik Date: Mon, 6 Jul 2026 00:30:03 +0300 Subject: [PATCH 11/22] feat(types)!: rename realtime schema sections inbound/outbound -> toClient/toServer The old names were written from the client's perspective, so handler code read backwards (InMsg = Reg["outbound"]) and every reader had to do the double-negative. toClient/toServer read correctly from both sides: Reg["toServer"] is what the handler receives, Reg["toClient"] is what it sends. Generated interface prefixes follow (GameRoomToClientInit). Breaking for schema.jsonc files and the generated registry shape; done now while there are zero external users. Co-Authored-By: Claude Fable 5 --- .../core/resources/realtime-handler/config.ts | 4 +-- .../core/resources/realtime-handler/schema.ts | 15 +++++----- packages/cli/src/core/types/generator.ts | 28 +++++++++---------- .../cli/tests/core/types-realtime.spec.ts | 18 ++++++------ .../base44/realtime/ChatRoom/schema.jsonc | 4 +-- 5 files changed, 35 insertions(+), 34 deletions(-) diff --git a/packages/cli/src/core/resources/realtime-handler/config.ts b/packages/cli/src/core/resources/realtime-handler/config.ts index bb5df4e89..3b9244401 100644 --- a/packages/cli/src/core/resources/realtime-handler/config.ts +++ b/packages/cli/src/core/resources/realtime-handler/config.ts @@ -43,8 +43,8 @@ async function readRealtimeHandler( if (result.success) { messageSchema = { types: result.data.types as Record | undefined, - inbound: result.data.inbound as Record | undefined, - outbound: result.data.outbound as Record | undefined, + toClient: result.data.toClient as Record | undefined, + toServer: result.data.toServer as Record | undefined, }; } } diff --git a/packages/cli/src/core/resources/realtime-handler/schema.ts b/packages/cli/src/core/resources/realtime-handler/schema.ts index b8e953742..9fb6c129b 100644 --- a/packages/cli/src/core/resources/realtime-handler/schema.ts +++ b/packages/cli/src/core/resources/realtime-handler/schema.ts @@ -6,13 +6,14 @@ const RealtimeHandlerConfigSchema = z.object({ entry: z.string().min(1), }); -// A handler's schema.jsonc is a catalog of named messages: `inbound`/`outbound` -// each map a message name to its (type-less) object schema, and optional `types` -// holds shared shapes referenced via `#/types/`. See the type generator. +// A handler's schema.jsonc is a catalog of named messages: `toClient` (server → +// client) and `toServer` (client → server) each map a message name to its (type-less) +// object schema, and optional `types` holds shared shapes referenced via +// `#/types/`. See the type generator. export const RealtimeHandlerSchemaFileSchema = z.object({ types: z.record(z.string(), z.unknown()).optional(), - inbound: z.record(z.string(), z.unknown()).optional(), - outbound: z.record(z.string(), z.unknown()).optional(), + toClient: z.record(z.string(), z.unknown()).optional(), + toServer: z.record(z.string(), z.unknown()).optional(), }); export const DeployRealtimeHandlerResponseSchema = z.object({ @@ -29,8 +30,8 @@ const RealtimeHandlerSchema = RealtimeHandlerConfigSchema.extend({ export interface RealtimeMessageSchema { types?: Record; - inbound?: Record; - outbound?: Record; + toClient?: Record; + toServer?: Record; } export type RealtimeHandler = Omit< diff --git a/packages/cli/src/core/types/generator.ts b/packages/cli/src/core/types/generator.ts index d701abdd0..3451c7ea7 100644 --- a/packages/cli/src/core/types/generator.ts +++ b/packages/cli/src/core/types/generator.ts @@ -168,17 +168,17 @@ async function compileEntity(entity: Entity): Promise { interface RealtimeCompileResult { /** Top-level `export` declarations: one interface per message + shared types. */ decls: string; - /** The registry value, e.g. `{ inbound: FooInit | FooTick; outbound: FooJoin }`. */ + /** The registry value, e.g. `{ toClient: FooInit | FooTick; toServer: FooJoin }`. */ entry: string; } /** * A handler's `schema.jsonc` is a *catalog* of named messages: - * { types?: { Pt, Snake, … }, inbound: { init, tick, … }, outbound: { join, … } } + * { types?: { Pt, Snake, … }, toClient: { init, tick, … }, toServer: { join, … } } * Each message is a flat object schema (no `type` field — the generator injects * `type: ""` as the discriminant). We compile the whole catalog in ONE pass * so json-schema-to-typescript emits a named interface per message plus the shared - * types, then assemble the inbound/outbound unions from those names. This avoids + * types, then assemble the toClient/toServer unions from those names. This avoids * scraping the compiler output (the old regex broke on unions and `$defs`), and * because every message is a single flat object, the fragile multi-declaration * case never arises. @@ -188,20 +188,20 @@ async function compileRealtimeHandler( ): Promise { const { messageSchema } = handler; if (!messageSchema) { - return { decls: "", entry: "{ inbound: unknown; outbound: unknown }" }; + return { decls: "", entry: "{ toClient: unknown; toServer: unknown }" }; } const prefix = toPascalCase(handler.name); const types = (messageSchema.types ?? {}) as Record; - const inbound = (messageSchema.inbound ?? {}) as Record; - const outbound = (messageSchema.outbound ?? {}) as Record; + const toClient = (messageSchema.toClient ?? {}) as Record; + const toServer = (messageSchema.toServer ?? {}) as Record; // Shared types are prefixed with the handler name so names (Pt, Snake, …) can't // collide across handlers or with entity interfaces. Messages additionally carry - // their direction, since the same name (e.g. "message") may appear both inbound - // and outbound. + // their direction, since the same name (e.g. "message") may appear in both + // directions. const typeName = (key: string) => `${prefix}${toPascalCase(key)}`; - const msgName = (dir: "Inbound" | "Outbound", key: string) => + const msgName = (dir: "ToClient" | "ToServer", key: string) => `${prefix}${dir}${toPascalCase(key)}`; const defs: Record = {}; @@ -225,7 +225,7 @@ async function compileRealtimeHandler( // `type` discriminant injected from its key. const compileMessages = ( msgs: Record, - dir: "Inbound" | "Outbound", + dir: "ToClient" | "ToServer", ): string[] => Object.entries(msgs).map(([key, schema]) => { const name = msgName(dir, key); @@ -240,12 +240,12 @@ async function compileRealtimeHandler( return name; }); - const inboundNames = compileMessages(inbound, "Inbound"); - const outboundNames = compileMessages(outbound, "Outbound"); + const toClientNames = compileMessages(toClient, "ToClient"); + const toServerNames = compileMessages(toServer, "ToServer"); // Root union over every message keeps all defs reachable so the compiler emits // them; we keep its whole output verbatim (no scraping). - const allNames = [...inboundNames, ...outboundNames]; + const allNames = [...toClientNames, ...toServerNames]; const rootName = `${prefix}Message`; const rootSchema = { title: rootName, @@ -273,7 +273,7 @@ async function compileRealtimeHandler( const union = (names: string[]) => (names.length ? names.join(" | ") : "never"); return { decls, - entry: `{ inbound: ${union(inboundNames)}; outbound: ${union(outboundNames)} }`, + entry: `{ toClient: ${union(toClientNames)}; toServer: ${union(toServerNames)} }`, }; } diff --git a/packages/cli/tests/core/types-realtime.spec.ts b/packages/cli/tests/core/types-realtime.spec.ts index 08b9bb667..fcb97ae67 100644 --- a/packages/cli/tests/core/types-realtime.spec.ts +++ b/packages/cli/tests/core/types-realtime.spec.ts @@ -35,7 +35,7 @@ describe("realtime handler type generation", () => { additionalProperties: false, }, }, - inbound: { + toClient: { init: { properties: { food: { type: "array", items: { $ref: "#/types/Pt" } } }, required: ["food"], @@ -45,7 +45,7 @@ describe("realtime handler type generation", () => { required: ["id", "score"], }, }, - outbound: { + toServer: { dir: { properties: { angle: { type: "number" } }, required: ["angle"] }, }, }), @@ -60,10 +60,10 @@ describe("realtime handler type generation", () => { // and referenced by name — not re-inlined. expect(out).toContain("export interface GameRoomPt"); expect(out).toContain("food: GameRoomPt[]"); - // Message interfaces carry their direction (so the same name can appear both - // inbound and outbound); the registry composes the unions from them. + // Message interfaces carry their direction (so the same name can appear in both + // directions); the registry composes the unions from them. expect(out).toContain( - '"GameRoom": { inbound: GameRoomInboundInit | GameRoomInboundDied; outbound: GameRoomOutboundDir }', + '"GameRoom": { toClient: GameRoomToClientInit | GameRoomToClientDied; toServer: GameRoomToServerDir }', ); // Output is valid TS: no `export interface` spliced inside a type literal // (the failure mode of the old regex-based extraction). @@ -76,15 +76,15 @@ describe("realtime handler type generation", () => { ...EMPTY, realtimeHandlers: [ handler({ - // Both keys PascalCase to the same GameRoomInboundUserJoined. - inbound: { + // Both keys PascalCase to the same GameRoomToClientUserJoined. + toClient: { "user-joined": { properties: { a: { type: "string" } } }, userJoined: { properties: { b: { type: "string" } } }, }, - outbound: {}, + toServer: {}, }), ], }), - ).rejects.toThrow(/Duplicate generated type "GameRoomInboundUserJoined"/); + ).rejects.toThrow(/Duplicate generated type "GameRoomToClientUserJoined"/); }); }); diff --git a/packages/cli/tests/fixtures/with-types-resources/base44/realtime/ChatRoom/schema.jsonc b/packages/cli/tests/fixtures/with-types-resources/base44/realtime/ChatRoom/schema.jsonc index 2a077651a..4696dd169 100644 --- a/packages/cli/tests/fixtures/with-types-resources/base44/realtime/ChatRoom/schema.jsonc +++ b/packages/cli/tests/fixtures/with-types-resources/base44/realtime/ChatRoom/schema.jsonc @@ -1,7 +1,7 @@ { // Message catalog: each entry is a full JSON Schema (like an entity), keyed by // message name. The generator injects `type: ""` as the discriminant. - "inbound": { + "toClient": { "joined": { "type": "object", "properties": { "userId": { "type": "string" } }, @@ -18,7 +18,7 @@ "required": ["from", "text"] } }, - "outbound": { + "toServer": { "message": { "type": "object", "properties": { "text": { "type": "string" } }, From b9f9391026cb37d4faed8a440dd1ad2158d1e888 Mon Sep 17 00:00:00 2001 From: imrik Date: Thu, 9 Jul 2026 16:27:39 +0300 Subject: [PATCH 12/22] refactor(cli): rename realtime -> actor (RealtimeHandler -> Actor) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename the Durable-Object abstraction and its CLI surface to the actor model: - command group `base44 realtime ` -> `base44 actor ` - resource dir src/core/resources/realtime-handler/ -> resources/actor/; commands/realtime/ -> commands/actor/ - project config key realtimeDir("realtime") -> actorsDir("actors"); ProjectData.realtimeHandlers -> actors - project directory convention base44/realtime// -> base44/actors// (resource discovery + generated actor message types) - builder deploy route PUT realtime-handlers/ -> PUT actors/ - scaffold template emits `import { Actor } ... extends Actor` The entity live-update Socket.IO dev-server (dev-server/realtime.ts, createRealtimeServer) is intentionally left as "realtime" — it's the entity-change feature, not the Actor DO. Co-Authored-By: Claude Opus 4.8 --- .../commands/{realtime => actor}/deploy.ts | 52 +++++------- .../cli/commands/{realtime => actor}/index.ts | 6 +- .../cli/commands/{realtime => actor}/new.ts | 32 ++++---- .../cli/src/cli/commands/project/deploy.ts | 6 +- .../cli/src/cli/commands/types/generate.ts | 4 +- packages/cli/src/cli/program.ts | 6 +- packages/cli/src/core/project/config.ts | 14 ++-- packages/cli/src/core/project/deploy.ts | 12 +-- packages/cli/src/core/project/schema.ts | 2 +- packages/cli/src/core/project/types.ts | 4 +- packages/cli/src/core/resources/actor/api.ts | 32 ++++++++ .../{realtime-handler => actor}/config.ts | 51 ++++++------ .../cli/src/core/resources/actor/deploy.ts | 67 +++++++++++++++ .../{realtime-handler => actor}/index.ts | 0 .../cli/src/core/resources/actor/resource.ts | 9 +++ .../{realtime-handler => actor}/schema.ts | 23 +++--- .../core/resources/realtime-handler/api.ts | 37 --------- .../core/resources/realtime-handler/deploy.ts | 69 ---------------- .../resources/realtime-handler/resource.ts | 9 --- packages/cli/src/core/types/generator.ts | 81 ++++++++++--------- packages/cli/tests/cli/types_generate.spec.ts | 10 +-- ...s-realtime.spec.ts => types-actor.spec.ts} | 27 ++++--- .../{realtime => actors}/ChatRoom/entry.ts | 4 +- .../ChatRoom/schema.jsonc | 0 24 files changed, 270 insertions(+), 287 deletions(-) rename packages/cli/src/cli/commands/{realtime => actor}/deploy.ts (60%) rename packages/cli/src/cli/commands/{realtime => actor}/index.ts (61%) rename packages/cli/src/cli/commands/{realtime => actor}/new.ts (50%) create mode 100644 packages/cli/src/core/resources/actor/api.ts rename packages/cli/src/core/resources/{realtime-handler => actor}/config.ts (50%) create mode 100644 packages/cli/src/core/resources/actor/deploy.ts rename packages/cli/src/core/resources/{realtime-handler => actor}/index.ts (100%) create mode 100644 packages/cli/src/core/resources/actor/resource.ts rename packages/cli/src/core/resources/{realtime-handler => actor}/schema.ts (59%) delete mode 100644 packages/cli/src/core/resources/realtime-handler/api.ts delete mode 100644 packages/cli/src/core/resources/realtime-handler/deploy.ts delete mode 100644 packages/cli/src/core/resources/realtime-handler/resource.ts rename packages/cli/tests/core/{types-realtime.spec.ts => types-actor.spec.ts} (81%) rename packages/cli/tests/fixtures/with-types-resources/base44/{realtime => actors}/ChatRoom/entry.ts (55%) rename packages/cli/tests/fixtures/with-types-resources/base44/{realtime => actors}/ChatRoom/schema.jsonc (100%) diff --git a/packages/cli/src/cli/commands/realtime/deploy.ts b/packages/cli/src/cli/commands/actor/deploy.ts similarity index 60% rename from packages/cli/src/cli/commands/realtime/deploy.ts rename to packages/cli/src/cli/commands/actor/deploy.ts index 7a434e516..5232f5cc2 100644 --- a/packages/cli/src/cli/commands/realtime/deploy.ts +++ b/packages/cli/src/cli/commands/actor/deploy.ts @@ -6,10 +6,10 @@ import { Base44Command, theme } from "@/cli/utils/index.js"; import { InvalidInputError } from "@/core/errors.js"; import { readProjectConfig } from "@/core/index.js"; import { - deployRealtimeHandlersSequentially, - type SingleRealtimeHandlerDeployResult, -} from "@/core/resources/realtime-handler/deploy.js"; -import type { RealtimeHandler } from "@/core/resources/realtime-handler/schema.js"; + deployActorsSequentially, + type SingleActorDeployResult, +} from "@/core/resources/actor/deploy.js"; +import type { Actor } from "@/core/resources/actor/schema.js"; function parseNames(args: string[]): string[] { return args @@ -18,23 +18,20 @@ function parseNames(args: string[]): string[] { .filter(Boolean); } -function resolveHandlersToDeploy( - names: string[], - allHandlers: RealtimeHandler[], -): RealtimeHandler[] { - if (names.length === 0) return allHandlers; +function resolveActorsToDeploy(names: string[], allActors: Actor[]): Actor[] { + if (names.length === 0) return allActors; - const notFound = names.filter((n) => !allHandlers.some((h) => h.name === n)); + const notFound = names.filter((n) => !allActors.some((a) => a.name === n)); if (notFound.length > 0) { throw new InvalidInputError( - `Realtime handler${notFound.length > 1 ? "s" : ""} not found in project: ${notFound.join(", ")}`, + `Actor${notFound.length > 1 ? "s" : ""} not found in project: ${notFound.join(", ")}`, ); } - return allHandlers.filter((h) => names.includes(h.name)); + return allActors.filter((a) => names.includes(a.name)); } function formatDeployResult( - result: SingleRealtimeHandlerDeployResult, + result: SingleActorDeployResult, log: Logger, ): void { const label = result.name.padEnd(25); @@ -50,9 +47,7 @@ function formatDeployResult( } } -function buildDeploySummary( - results: SingleRealtimeHandlerDeployResult[], -): string { +function buildDeploySummary(results: SingleActorDeployResult[]): string { const deployed = results.filter((r) => r.status === "deployed").length; const unchanged = results.filter((r) => r.status === "unchanged").length; const failed = results.filter((r) => r.status === "error").length; @@ -61,36 +56,33 @@ function buildDeploySummary( if (deployed > 0) parts.push(`${deployed} deployed`); if (unchanged > 0) parts.push(`${unchanged} unchanged`); if (failed > 0) parts.push(`${failed} error${failed !== 1 ? "s" : ""}`); - return parts.join(", ") || "No realtime handlers deployed"; + return parts.join(", ") || "No actors deployed"; } -async function deployRealtimeAction( +async function deployActorAction( { log }: CLIContext, names: string[], ): Promise { - const { realtimeHandlers } = await readProjectConfig(); - const toDeploy = resolveHandlersToDeploy(names, realtimeHandlers); + const { actors } = await readProjectConfig(); + const toDeploy = resolveActorsToDeploy(names, actors); if (toDeploy.length === 0) { return { - outroMessage: - "No realtime handlers found. Create handlers in the 'realtime' directory.", + outroMessage: "No actors found. Create actors in the 'actors' directory.", }; } log.info( - `Found ${toDeploy.length} ${toDeploy.length === 1 ? "realtime handler" : "realtime handlers"} to deploy`, + `Found ${toDeploy.length} ${toDeploy.length === 1 ? "actor" : "actors"} to deploy`, ); let completed = 0; const total = toDeploy.length; - const results = await deployRealtimeHandlersSequentially(toDeploy, { + const results = await deployActorsSequentially(toDeploy, { onStart: (startNames) => { const label = - startNames.length === 1 - ? startNames[0] - : `${startNames.length} realtime handlers`; + startNames.length === 1 ? startNames[0] : `${startNames.length} actors`; log.step( theme.styles.dim(`[${completed + 1}/${total}] Deploying ${label}...`), ); @@ -112,10 +104,10 @@ async function deployRealtimeAction( export function getDeployCommand(): Command { return new Base44Command("deploy") - .description("Deploy realtime handlers to Base44") - .argument("[names...]", "Handler names to deploy (deploys all if omitted)") + .description("Deploy actors to Base44") + .argument("[names...]", "Actor names to deploy (deploys all if omitted)") .action(async (ctx: CLIContext, rawNames: string[]) => { const names = parseNames(rawNames); - return deployRealtimeAction(ctx, names); + return deployActorAction(ctx, names); }); } diff --git a/packages/cli/src/cli/commands/realtime/index.ts b/packages/cli/src/cli/commands/actor/index.ts similarity index 61% rename from packages/cli/src/cli/commands/realtime/index.ts rename to packages/cli/src/cli/commands/actor/index.ts index 171356a52..6be9a1495 100644 --- a/packages/cli/src/cli/commands/realtime/index.ts +++ b/packages/cli/src/cli/commands/actor/index.ts @@ -2,9 +2,9 @@ import { Command } from "commander"; import { getDeployCommand } from "./deploy.js"; import { getNewCommand } from "./new.js"; -export function getRealtimeCommand(): Command { - return new Command("realtime") - .description("Manage realtime handlers") +export function getActorCommand(): Command { + return new Command("actor") + .description("Manage actors") .addCommand(getNewCommand()) .addCommand(getDeployCommand()); } diff --git a/packages/cli/src/cli/commands/realtime/new.ts b/packages/cli/src/cli/commands/actor/new.ts similarity index 50% rename from packages/cli/src/cli/commands/realtime/new.ts rename to packages/cli/src/cli/commands/actor/new.ts index 52d23c416..a5f4c6595 100644 --- a/packages/cli/src/cli/commands/realtime/new.ts +++ b/packages/cli/src/cli/commands/actor/new.ts @@ -6,8 +6,8 @@ import { InvalidInputError } from "@/core/errors.js"; import { readProjectConfig } from "@/core/index.js"; import { pathExists, writeFile } from "@/core/utils/fs.js"; -function buildHandlerScaffold(handlerName: string): string { - return `import { RealtimeHandler, type Conn } from "@base44/sdk"; +function buildActorScaffold(actorName: string): string { + return `import { Actor, type Conn } from "@base44/sdk"; interface State { // shared state broadcast to all clients @@ -17,7 +17,7 @@ interface Message { // messages sent from clients } -export class ${handlerName} extends RealtimeHandler { +export class ${actorName} extends Actor { handleConnect(conn: Conn) { console.log("Connected:", conn.userId); } @@ -30,33 +30,33 @@ export class ${handlerName} extends RealtimeHandler { `; } -async function newRealtimeHandlerAction( +async function newActorAction( _ctx: CLIContext, - handlerName: string, + actorName: string, ): Promise { const { project } = await readProjectConfig(); - const realtimeDir = join(dirname(project.configPath), project.realtimeDir); - const handlerDir = join(realtimeDir, handlerName); + const actorsDir = join(dirname(project.configPath), project.actorsDir); + const actorDir = join(actorsDir, actorName); - if (await pathExists(handlerDir)) { + if (await pathExists(actorDir)) { throw new InvalidInputError( - `Realtime handler "${handlerName}" already exists at ${handlerDir}`, + `Actor "${actorName}" already exists at ${actorDir}`, ); } - const entryPath = join(handlerDir, "entry.ts"); - await writeFile(entryPath, buildHandlerScaffold(handlerName)); + const entryPath = join(actorDir, "entry.ts"); + await writeFile(entryPath, buildActorScaffold(actorName)); return { - outroMessage: `Created realtime handler "${handlerName}" at ${entryPath}`, + outroMessage: `Created actor "${actorName}" at ${entryPath}`, }; } export function getNewCommand(): Command { return new Base44Command("new") - .description("Create a new realtime handler scaffold") - .argument("", "Name of the realtime handler class") - .action(async (ctx: CLIContext, handlerName: string) => { - return newRealtimeHandlerAction(ctx, handlerName); + .description("Create a new actor scaffold") + .argument("", "Name of the actor class") + .action(async (ctx: CLIContext, actorName: string) => { + return newActorAction(ctx, actorName); }); } diff --git a/packages/cli/src/cli/commands/project/deploy.ts b/packages/cli/src/cli/commands/project/deploy.ts index 996a77cb5..3e281d673 100644 --- a/packages/cli/src/cli/commands/project/deploy.ts +++ b/packages/cli/src/cli/commands/project/deploy.ts @@ -52,7 +52,7 @@ export async function deployAction( project, entities, functions, - realtimeHandlers, + actors, agents, connectors, authConfig, @@ -70,9 +70,9 @@ export async function deployAction( ` - ${functions.length} ${functions.length === 1 ? "function" : "functions"}`, ); } - if (realtimeHandlers.length > 0) { + if (actors.length > 0) { summaryLines.push( - ` - ${realtimeHandlers.length} ${realtimeHandlers.length === 1 ? "realtime handler" : "realtime handlers"}`, + ` - ${actors.length} ${actors.length === 1 ? "actor" : "actors"}`, ); } if (agents.length > 0) { diff --git a/packages/cli/src/cli/commands/types/generate.ts b/packages/cli/src/cli/commands/types/generate.ts index 973fd3183..e06de2b45 100644 --- a/packages/cli/src/cli/commands/types/generate.ts +++ b/packages/cli/src/cli/commands/types/generate.ts @@ -9,7 +9,7 @@ const TYPES_FILE_PATH = "base44/.types/types.d.ts"; async function generateTypesAction({ runTask, }: CLIContext): Promise { - const { entities, functions, agents, connectors, realtimeHandlers, project } = + const { entities, functions, agents, connectors, actors, project } = await readProjectConfig(); await runTask("Generating types", async () => { @@ -19,7 +19,7 @@ async function generateTypesAction({ functions, agents, connectors, - realtimeHandlers, + actors, }); }); diff --git a/packages/cli/src/cli/program.ts b/packages/cli/src/cli/program.ts index 857ee6e34..dba69f0f1 100644 --- a/packages/cli/src/cli/program.ts +++ b/packages/cli/src/cli/program.ts @@ -1,4 +1,5 @@ import { Command, Option } from "commander"; +import { getActorCommand } from "@/cli/commands/actor/index.js"; import { getAgentSkillsCommand } from "@/cli/commands/agent-skills/index.js"; import { getAgentsCommand } from "@/cli/commands/agents/index.js"; import { getAuthCommand } from "@/cli/commands/auth/index.js"; @@ -16,7 +17,6 @@ import { getLinkCommand } from "@/cli/commands/project/link.js"; import { getLogsCommand } from "@/cli/commands/project/logs.js"; import { getScaffoldCommand } from "@/cli/commands/project/scaffold.js"; import { getVisibilityCommand } from "@/cli/commands/project/visibility.js"; -import { getRealtimeCommand } from "@/cli/commands/realtime/index.js"; import { getSandboxCommand } from "@/cli/commands/sandbox/index.js"; import { getSecretsCommand } from "@/cli/commands/secrets/index.js"; import { getSiteCommand } from "@/cli/commands/site/index.js"; @@ -96,8 +96,8 @@ export function createProgram(context: CLIContext): Command { // Register functions commands program.addCommand(getFunctionsCommand()); - // Register realtime commands - program.addCommand(getRealtimeCommand()); + // Register actor commands + program.addCommand(getActorCommand()); // Register workflows commands program.addCommand(getWorkflowsCommand()); diff --git a/packages/cli/src/core/project/config.ts b/packages/cli/src/core/project/config.ts index 2fdb30fea..c27ec9f02 100644 --- a/packages/cli/src/core/project/config.ts +++ b/packages/cli/src/core/project/config.ts @@ -22,6 +22,7 @@ import type { ProjectRoot, ProjectWithPaths, } from "@/core/project/types.js"; +import { actorResource } from "@/core/resources/actor/index.js"; import { agentResource } from "@/core/resources/agent/index.js"; import { agentSkillResource } from "@/core/resources/agent-skill/index.js"; import { authConfigResource } from "@/core/resources/auth-config/index.js"; @@ -33,7 +34,6 @@ import { type BackendFunction, functionResource, } from "@/core/resources/function/index.js"; -import { realtimeHandlerResource } from "@/core/resources/realtime-handler/index.js"; import { readJsonFile } from "@/core/utils/fs.js"; type ProjectResources = Omit; @@ -73,7 +73,7 @@ class ProjectConfigReader { project, entities, functions, - realtimeHandlers: localResources.realtimeHandlers, + actors: localResources.actors, agents: localResources.agents, agentSkills: localResources.agentSkills, connectors: localResources.connectors, @@ -123,7 +123,7 @@ class ProjectConfigReader { const [ entities, functions, - realtimeHandlers, + actors, agents, agentSkills, connectors, @@ -131,7 +131,7 @@ class ProjectConfigReader { ] = await Promise.all([ entityResource.readAll(join(configDir, project.entitiesDir)), functionResource.readAll(join(configDir, project.functionsDir)), - realtimeHandlerResource.readAll(join(configDir, project.realtimeDir)), + actorResource.readAll(join(configDir, project.actorsDir)), agentResource.readAll(join(configDir, project.agentsDir)), agentSkillResource.readAll(join(configDir, project.agentSkillsDir)), connectorResource.readAll(join(configDir, project.connectorsDir)), @@ -141,7 +141,7 @@ class ProjectConfigReader { return { entities, functions, - realtimeHandlers, + actors, agents, agentSkills, connectors, @@ -216,7 +216,7 @@ class ProjectConfigReader { return { entities: markPluginEntities(resources.entities, namespace), functions: namespacePluginFunctions(resources.functions, namespace), - realtimeHandlers: [], + actors: [], agents: [], agentSkills: [], connectors: [], @@ -274,7 +274,7 @@ class ProjectConfigReader { return { entities, functions, - realtimeHandlers: [], + actors: [], agents: [], agentSkills: [], connectors: [], diff --git a/packages/cli/src/core/project/deploy.ts b/packages/cli/src/core/project/deploy.ts index ea0356b2e..57b7ba0aa 100644 --- a/packages/cli/src/core/project/deploy.ts +++ b/packages/cli/src/core/project/deploy.ts @@ -3,6 +3,7 @@ import { hasWorkspaceApiKeyAuth } from "@/core/auth/config.js"; import { setAppVisibility } from "@/core/project/api.js"; import type { Visibility } from "@/core/project/schema.js"; import type { ProjectData } from "@/core/project/types.js"; +import { deployActorsSequentially } from "@/core/resources/actor/deploy.js"; import { agentResource } from "@/core/resources/agent/index.js"; import { agentSkillResource } from "@/core/resources/agent-skill/index.js"; import { authConfigResource } from "@/core/resources/auth-config/index.js"; @@ -15,7 +16,6 @@ import { deployFunctionsSequentially, type SingleFunctionDeployResult, } from "@/core/resources/function/deploy.js"; -import { deployRealtimeHandlersSequentially } from "@/core/resources/realtime-handler/deploy.js"; import { deploySite } from "@/core/site/index.js"; /** @@ -29,7 +29,7 @@ export function hasResourcesToDeploy(projectData: ProjectData): boolean { project, entities, functions, - realtimeHandlers, + actors, agents, agentSkills, connectors, @@ -38,7 +38,7 @@ export function hasResourcesToDeploy(projectData: ProjectData): boolean { const hasSite = Boolean(project.site?.outputDirectory); const hasEntities = entities.length > 0; const hasFunctions = functions.length > 0; - const hasRealtimeHandlers = realtimeHandlers.length > 0; + const hasActors = actors.length > 0; const hasAgents = agents.length > 0; const hasAgentSkills = agentSkills.length > 0; const hasConnectors = connectors.length > 0; @@ -48,7 +48,7 @@ export function hasResourcesToDeploy(projectData: ProjectData): boolean { return ( hasEntities || hasFunctions || - hasRealtimeHandlers || + hasActors || hasAgents || hasAgentSkills || hasConnectors || @@ -93,7 +93,7 @@ export async function deployAll( project, entities, functions, - realtimeHandlers, + actors, agents, agentSkills, connectors, @@ -109,7 +109,7 @@ export async function deployAll( onStart: options?.onFunctionStart, onResult: options?.onFunctionResult, }); - await deployRealtimeHandlersSequentially(realtimeHandlers); + await deployActorsSequentially(actors); await agentSkillResource.push(agentSkills); await agentResource.push(agents); await authConfigResource.push(authConfig); diff --git a/packages/cli/src/core/project/schema.ts b/packages/cli/src/core/project/schema.ts index 29046e0f5..42041acfb 100644 --- a/packages/cli/src/core/project/schema.ts +++ b/packages/cli/src/core/project/schema.ts @@ -49,7 +49,7 @@ export const ProjectConfigSchema = z.object({ site: SiteConfigSchema.optional(), entitiesDir: z.string().optional().default("entities"), functionsDir: z.string().optional().default("functions"), - realtimeDir: z.string().optional().default("realtime"), + actorsDir: z.string().optional().default("actors"), agentsDir: z.string().optional().default("agents"), agentSkillsDir: z.string().optional().default("agent-skills"), connectorsDir: z.string().optional().default("connectors"), diff --git a/packages/cli/src/core/project/types.ts b/packages/cli/src/core/project/types.ts index a2574107c..f25f4c5d3 100644 --- a/packages/cli/src/core/project/types.ts +++ b/packages/cli/src/core/project/types.ts @@ -1,11 +1,11 @@ import type { ProjectConfig } from "@/core/project/schema.js"; +import type { Actor } from "@/core/resources/actor/index.js"; import type { AgentConfig } from "@/core/resources/agent/index.js"; import type { AgentSkill } from "@/core/resources/agent-skill/index.js"; import type { AuthConfig } from "@/core/resources/auth-config/index.js"; import type { ConnectorResource } from "@/core/resources/connector/index.js"; import type { Entity } from "@/core/resources/entity/index.js"; import type { BackendFunction } from "@/core/resources/function/index.js"; -import type { RealtimeHandler } from "@/core/resources/realtime-handler/index.js"; export interface ProjectWithPaths extends ProjectConfig { root: string; @@ -21,7 +21,7 @@ export interface ProjectData { project: ProjectWithPaths; entities: Entity[]; functions: BackendFunction[]; - realtimeHandlers: RealtimeHandler[]; + actors: Actor[]; agents: AgentConfig[]; agentSkills: AgentSkill[]; connectors: ConnectorResource[]; diff --git a/packages/cli/src/core/resources/actor/api.ts b/packages/cli/src/core/resources/actor/api.ts new file mode 100644 index 000000000..83e3d760b --- /dev/null +++ b/packages/cli/src/core/resources/actor/api.ts @@ -0,0 +1,32 @@ +import type { KyResponse } from "ky"; +import { getAppClient } from "@/core/clients/index.js"; +import { ApiError, SchemaValidationError } from "@/core/errors.js"; +import type { DeployActorResponse } from "@/core/resources/actor/schema.js"; +import { DeployActorResponseSchema } from "@/core/resources/actor/schema.js"; +import type { FunctionFile } from "@/core/resources/function/schema.js"; + +export async function deploySingleActor( + name: string, + payload: { entry: string; files: FunctionFile[] }, +): Promise { + const appClient = getAppClient(); + + let response: KyResponse; + try { + response = await appClient.put(`actors/${encodeURIComponent(name)}`, { + json: payload, + timeout: false, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, `deploying actor "${name}"`); + } + + const result = DeployActorResponseSchema.safeParse(await response.json()); + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error, + ); + } + return result.data; +} diff --git a/packages/cli/src/core/resources/realtime-handler/config.ts b/packages/cli/src/core/resources/actor/config.ts similarity index 50% rename from packages/cli/src/core/resources/realtime-handler/config.ts rename to packages/cli/src/core/resources/actor/config.ts index 3b9244401..74628368e 100644 --- a/packages/cli/src/core/resources/realtime-handler/config.ts +++ b/packages/cli/src/core/resources/actor/config.ts @@ -3,30 +3,27 @@ import { globby } from "globby"; import { ENTRY_FILE_GLOB, ENTRY_IGNORE_DOT_PATHS } from "@/core/consts.js"; import { InvalidInputError } from "@/core/errors.js"; import type { - RealtimeHandler, - RealtimeMessageSchema, -} from "@/core/resources/realtime-handler/schema.js"; -import { RealtimeHandlerSchemaFileSchema } from "@/core/resources/realtime-handler/schema.js"; + Actor, + ActorMessageSchema, +} from "@/core/resources/actor/schema.js"; +import { ActorSchemaFileSchema } from "@/core/resources/actor/schema.js"; import { pathExists, readJsonFile } from "@/core/utils/fs.js"; -async function readRealtimeHandler( - entryFile: string, - realtimeDir: string, -): Promise { - const handlerDir = dirname(entryFile); +async function readActor(entryFile: string, actorsDir: string): Promise { + const actorDir = dirname(entryFile); const filePaths = await globby("**/*.ts", { - cwd: handlerDir, + cwd: actorDir, absolute: true, }); - const name = relative(realtimeDir, handlerDir).split(/[/\\]/).join("/"); + const name = relative(actorsDir, actorDir).split(/[/\\]/).join("/"); if (!name) { throw new InvalidInputError( - "entry.ts found directly in the realtime directory — it must be inside a named subfolder", + "entry.ts found directly in the actors directory — it must be inside a named subfolder", { hints: [ { - message: `Move ${entryFile} into a subfolder (e.g. realtime/myHandler/entry.ts)`, + message: `Move ${entryFile} into a subfolder (e.g. actors/MyActor/entry.ts)`, }, ], }, @@ -35,11 +32,11 @@ async function readRealtimeHandler( const entry = basename(entryFile); - const schemaPath = join(handlerDir, "schema.jsonc"); - let messageSchema: RealtimeMessageSchema | undefined; + const schemaPath = join(actorDir, "schema.jsonc"); + let messageSchema: ActorMessageSchema | undefined; if (await pathExists(schemaPath)) { const parsed = await readJsonFile(schemaPath); - const result = RealtimeHandlerSchemaFileSchema.safeParse(parsed); + const result = ActorSchemaFileSchema.safeParse(parsed); if (result.success) { messageSchema = { types: result.data.types as Record | undefined, @@ -59,32 +56,30 @@ async function readRealtimeHandler( }; } -export async function readAllRealtimeHandlers( - realtimeDir: string, -): Promise { - if (!(await pathExists(realtimeDir))) { +export async function readAllActors(actorsDir: string): Promise { + if (!(await pathExists(actorsDir))) { return []; } const entryFiles = await globby(ENTRY_FILE_GLOB, { - cwd: realtimeDir, + cwd: actorsDir, absolute: true, ignore: ENTRY_IGNORE_DOT_PATHS, }); - const handlers = await Promise.all( - entryFiles.map((entryFile) => readRealtimeHandler(entryFile, realtimeDir)), + const actors = await Promise.all( + entryFiles.map((entryFile) => readActor(entryFile, actorsDir)), ); const names = new Set(); - for (const handler of handlers) { - if (names.has(handler.name)) { + for (const actor of actors) { + if (names.has(actor.name)) { throw new InvalidInputError( - `Duplicate realtime handler name "${handler.name}" in ${realtimeDir}`, + `Duplicate actor name "${actor.name}" in ${actorsDir}`, ); } - names.add(handler.name); + names.add(actor.name); } - return handlers; + return actors; } diff --git a/packages/cli/src/core/resources/actor/deploy.ts b/packages/cli/src/core/resources/actor/deploy.ts new file mode 100644 index 000000000..0e02365c8 --- /dev/null +++ b/packages/cli/src/core/resources/actor/deploy.ts @@ -0,0 +1,67 @@ +import { dirname, relative } from "node:path"; +import { deploySingleActor } from "@/core/resources/actor/api.js"; +import type { Actor } from "@/core/resources/actor/schema.js"; +import type { FunctionFile } from "@/core/resources/function/schema.js"; +import { readTextFile } from "@/core/utils/fs.js"; + +async function loadActorCode( + actor: Actor, +): Promise<{ name: string; entry: string; files: FunctionFile[] }> { + const actorDir = dirname(actor.entryPath); + const resolvedFiles: FunctionFile[] = await Promise.all( + actor.filePaths.map(async (filePath) => { + const content = await readTextFile(filePath); + const path = relative(actorDir, filePath).split(/[/\\]/).join("/"); + return { path, content }; + }), + ); + return { name: actor.name, entry: actor.entry, files: resolvedFiles }; +} + +export interface SingleActorDeployResult { + name: string; + status: "deployed" | "unchanged" | "error"; + error?: string | null; + durationMs?: number; +} + +async function deployOne(actor: Actor): Promise { + const start = Date.now(); + try { + const loaded = await loadActorCode(actor); + const response = await deploySingleActor(loaded.name, { + entry: loaded.entry, + files: loaded.files, + }); + return { + name: loaded.name, + status: response.status, + durationMs: Date.now() - start, + }; + } catch (error) { + return { + name: actor.name, + status: "error", + error: error instanceof Error ? error.message : String(error), + }; + } +} + +export async function deployActorsSequentially( + actors: Actor[], + options?: { + onStart?: (names: string[]) => void; + onResult?: (result: SingleActorDeployResult) => void; + }, +): Promise { + if (actors.length === 0) return []; + + const results: SingleActorDeployResult[] = []; + for (const actor of actors) { + options?.onStart?.([actor.name]); + const result = await deployOne(actor); + results.push(result); + options?.onResult?.(result); + } + return results; +} diff --git a/packages/cli/src/core/resources/realtime-handler/index.ts b/packages/cli/src/core/resources/actor/index.ts similarity index 100% rename from packages/cli/src/core/resources/realtime-handler/index.ts rename to packages/cli/src/core/resources/actor/index.ts diff --git a/packages/cli/src/core/resources/actor/resource.ts b/packages/cli/src/core/resources/actor/resource.ts new file mode 100644 index 000000000..60d5a88d0 --- /dev/null +++ b/packages/cli/src/core/resources/actor/resource.ts @@ -0,0 +1,9 @@ +import { readAllActors } from "@/core/resources/actor/config.js"; +import { deployActorsSequentially } from "@/core/resources/actor/deploy.js"; +import type { Actor } from "@/core/resources/actor/schema.js"; +import type { Resource } from "@/core/resources/types.js"; + +export const actorResource: Resource = { + readAll: readAllActors, + push: (actors) => deployActorsSequentially(actors), +}; diff --git a/packages/cli/src/core/resources/realtime-handler/schema.ts b/packages/cli/src/core/resources/actor/schema.ts similarity index 59% rename from packages/cli/src/core/resources/realtime-handler/schema.ts rename to packages/cli/src/core/resources/actor/schema.ts index 9fb6c129b..9dd490e65 100644 --- a/packages/cli/src/core/resources/realtime-handler/schema.ts +++ b/packages/cli/src/core/resources/actor/schema.ts @@ -1,45 +1,40 @@ import { z } from "zod"; import { ResourceSourceSchema } from "@/core/resources/types.js"; -const RealtimeHandlerConfigSchema = z.object({ +const ActorConfigSchema = z.object({ name: z.string().min(1), entry: z.string().min(1), }); -// A handler's schema.jsonc is a catalog of named messages: `toClient` (server → +// An actor's schema.jsonc is a catalog of named messages: `toClient` (server → // client) and `toServer` (client → server) each map a message name to its (type-less) // object schema, and optional `types` holds shared shapes referenced via // `#/types/`. See the type generator. -export const RealtimeHandlerSchemaFileSchema = z.object({ +export const ActorSchemaFileSchema = z.object({ types: z.record(z.string(), z.unknown()).optional(), toClient: z.record(z.string(), z.unknown()).optional(), toServer: z.record(z.string(), z.unknown()).optional(), }); -export const DeployRealtimeHandlerResponseSchema = z.object({ +export const DeployActorResponseSchema = z.object({ status: z.enum(["deployed", "unchanged"]), handler_name: z.string().optional(), }); -const RealtimeHandlerSchema = RealtimeHandlerConfigSchema.extend({ +const ActorSchema = ActorConfigSchema.extend({ entryPath: z.string().min(1), filePaths: z.array(z.string()).min(1), source: ResourceSourceSchema, messageSchema: z.unknown().optional(), }); -export interface RealtimeMessageSchema { +export interface ActorMessageSchema { types?: Record; toClient?: Record; toServer?: Record; } -export type RealtimeHandler = Omit< - z.infer, - "messageSchema" -> & { - messageSchema?: RealtimeMessageSchema; +export type Actor = Omit, "messageSchema"> & { + messageSchema?: ActorMessageSchema; }; -export type DeployRealtimeHandlerResponse = z.infer< - typeof DeployRealtimeHandlerResponseSchema ->; +export type DeployActorResponse = z.infer; diff --git a/packages/cli/src/core/resources/realtime-handler/api.ts b/packages/cli/src/core/resources/realtime-handler/api.ts deleted file mode 100644 index b185539c3..000000000 --- a/packages/cli/src/core/resources/realtime-handler/api.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { KyResponse } from "ky"; -import { getAppClient } from "@/core/clients/index.js"; -import { ApiError, SchemaValidationError } from "@/core/errors.js"; -import type { FunctionFile } from "@/core/resources/function/schema.js"; -import type { DeployRealtimeHandlerResponse } from "@/core/resources/realtime-handler/schema.js"; -import { DeployRealtimeHandlerResponseSchema } from "@/core/resources/realtime-handler/schema.js"; - -export async function deploySingleRealtimeHandler( - name: string, - payload: { entry: string; files: FunctionFile[] }, -): Promise { - const appClient = getAppClient(); - - let response: KyResponse; - try { - response = await appClient.put( - `realtime-handlers/${encodeURIComponent(name)}`, - { json: payload, timeout: false }, - ); - } catch (error) { - throw await ApiError.fromHttpError( - error, - `deploying realtime handler "${name}"`, - ); - } - - const result = DeployRealtimeHandlerResponseSchema.safeParse( - await response.json(), - ); - if (!result.success) { - throw new SchemaValidationError( - "Invalid response from server", - result.error, - ); - } - return result.data; -} diff --git a/packages/cli/src/core/resources/realtime-handler/deploy.ts b/packages/cli/src/core/resources/realtime-handler/deploy.ts deleted file mode 100644 index 64e78650e..000000000 --- a/packages/cli/src/core/resources/realtime-handler/deploy.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { dirname, relative } from "node:path"; -import type { FunctionFile } from "@/core/resources/function/schema.js"; -import { deploySingleRealtimeHandler } from "@/core/resources/realtime-handler/api.js"; -import type { RealtimeHandler } from "@/core/resources/realtime-handler/schema.js"; -import { readTextFile } from "@/core/utils/fs.js"; - -async function loadHandlerCode( - handler: RealtimeHandler, -): Promise<{ name: string; entry: string; files: FunctionFile[] }> { - const handlerDir = dirname(handler.entryPath); - const resolvedFiles: FunctionFile[] = await Promise.all( - handler.filePaths.map(async (filePath) => { - const content = await readTextFile(filePath); - const path = relative(handlerDir, filePath).split(/[/\\]/).join("/"); - return { path, content }; - }), - ); - return { name: handler.name, entry: handler.entry, files: resolvedFiles }; -} - -export interface SingleRealtimeHandlerDeployResult { - name: string; - status: "deployed" | "unchanged" | "error"; - error?: string | null; - durationMs?: number; -} - -async function deployOne( - handler: RealtimeHandler, -): Promise { - const start = Date.now(); - try { - const loaded = await loadHandlerCode(handler); - const response = await deploySingleRealtimeHandler(loaded.name, { - entry: loaded.entry, - files: loaded.files, - }); - return { - name: loaded.name, - status: response.status, - durationMs: Date.now() - start, - }; - } catch (error) { - return { - name: handler.name, - status: "error", - error: error instanceof Error ? error.message : String(error), - }; - } -} - -export async function deployRealtimeHandlersSequentially( - handlers: RealtimeHandler[], - options?: { - onStart?: (names: string[]) => void; - onResult?: (result: SingleRealtimeHandlerDeployResult) => void; - }, -): Promise { - if (handlers.length === 0) return []; - - const results: SingleRealtimeHandlerDeployResult[] = []; - for (const handler of handlers) { - options?.onStart?.([handler.name]); - const result = await deployOne(handler); - results.push(result); - options?.onResult?.(result); - } - return results; -} diff --git a/packages/cli/src/core/resources/realtime-handler/resource.ts b/packages/cli/src/core/resources/realtime-handler/resource.ts deleted file mode 100644 index 9a61f37c4..000000000 --- a/packages/cli/src/core/resources/realtime-handler/resource.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { readAllRealtimeHandlers } from "@/core/resources/realtime-handler/config.js"; -import { deployRealtimeHandlersSequentially } from "@/core/resources/realtime-handler/deploy.js"; -import type { RealtimeHandler } from "@/core/resources/realtime-handler/schema.js"; -import type { Resource } from "@/core/resources/types.js"; - -export const realtimeHandlerResource: Resource = { - readAll: readAllRealtimeHandlers, - push: (handlers) => deployRealtimeHandlersSequentially(handlers), -}; diff --git a/packages/cli/src/core/types/generator.ts b/packages/cli/src/core/types/generator.ts index 3451c7ea7..880cf4fce 100644 --- a/packages/cli/src/core/types/generator.ts +++ b/packages/cli/src/core/types/generator.ts @@ -4,11 +4,11 @@ import type { JSONSchema4 } from "json-schema"; import { compile } from "json-schema-to-typescript"; import { getTypesOutputPath } from "@/core/config.js"; import { TypeGenerationError } from "@/core/errors.js"; +import type { Actor } from "@/core/resources/actor/schema.js"; import type { AgentConfig } from "@/core/resources/agent/index.js"; import type { ConnectorResource } from "@/core/resources/connector/index.js"; import type { Entity } from "@/core/resources/entity/index.js"; import type { BackendFunction } from "@/core/resources/function/index.js"; -import type { RealtimeHandler } from "@/core/resources/realtime-handler/schema.js"; import { readJsonFile, writeFile } from "@/core/utils/fs.js"; interface GenerateTypesInput { @@ -17,7 +17,7 @@ interface GenerateTypesInput { functions: BackendFunction[]; agents: AgentConfig[]; connectors: ConnectorResource[]; - realtimeHandlers: RealtimeHandler[]; + actors: Actor[]; } const HEADER = stripIndent` @@ -29,8 +29,8 @@ const EMPTY_TEMPLATE = stripIndent` // Auto-generated by Base44 CLI - DO NOT EDIT // Regenerate with: base44 types // - // No entities, functions, agents, connectors, or realtime handlers found in project. - // Add resources to base44/entities/, base44/functions/, base44/agents/, base44/connectors/, or base44/realtime/ + // No entities, functions, agents, connectors, or actors found in project. + // Add resources to base44/entities/, base44/functions/, base44/agents/, base44/connectors/, or base44/actors/ // and run \`base44 types generate\` again. declare module '@base44/sdk' { @@ -74,7 +74,7 @@ export async function generateTypesFile( export async function generateContent( input: GenerateTypesInput, ): Promise { - const { entities, functions, agents, connectors, realtimeHandlers } = input; + const { entities, functions, agents, connectors, actors } = input; const sdkPackage = await detectSdkPackageName(input.projectRoot); if ( @@ -82,14 +82,14 @@ export async function generateContent( !functions.length && !agents.length && !connectors.length && - !realtimeHandlers.length + !actors.length ) { return EMPTY_TEMPLATE; } - const [entityInterfaces, realtimeResults] = await Promise.all([ + const [entityInterfaces, actorResults] = await Promise.all([ Promise.all(entities.map((e) => compileEntity(e))), - Promise.all(realtimeHandlers.map((h) => compileRealtimeHandler(h))), + Promise.all(actors.map((a) => compileActor(a))), ]); // Build registry entries @@ -101,17 +101,14 @@ export async function generateContent( ["FunctionNameRegistry", functions.map((f) => `"${f.name}": true;`)], ["AgentNameRegistry", agents.map((a) => `"${a.name}": true;`)], ["ConnectorTypeRegistry", connectors.map((c) => `"${c.type}": true;`)], + ["ActorNameRegistry", actors.map((a) => `"${a.name}": true;`)], [ - "RealtimeHandlerNameRegistry", - realtimeHandlers.map((h) => `"${h.name}": true;`), - ], - [ - "RealtimeHandlerRegistry", - realtimeHandlers - .filter((h) => h.messageSchema) - .map((h) => { - const idx = realtimeHandlers.indexOf(h); - return `"${h.name}": ${realtimeResults[idx].entry};`; + "ActorRegistry", + actors + .filter((a) => a.messageSchema) + .map((a) => { + const idx = actors.indexOf(a); + return `"${a.name}": ${actorResults[idx].entry};`; }), ], ]; @@ -121,15 +118,13 @@ export async function generateContent( .filter(([, entries]) => entries.length > 0) .map(([name, entries]) => registry(name, entries)); - const realtimeInterfaces = realtimeResults - .map((r) => r.decls) - .filter(Boolean); + const actorInterfaces = actorResults.map((r) => r.decls).filter(Boolean); return [ HEADER, "export {};", // module context — ensures declare module augments rather than replaces the SDK package entityInterfaces.join("\n\n"), - realtimeInterfaces.join("\n\n"), + actorInterfaces.join("\n\n"), source` declare module '${sdkPackage}' { ${registries.join("\n\n")} @@ -165,7 +160,7 @@ async function compileEntity(entity: Entity): Promise { } } -interface RealtimeCompileResult { +interface ActorCompileResult { /** Top-level `export` declarations: one interface per message + shared types. */ decls: string; /** The registry value, e.g. `{ toClient: FooInit | FooTick; toServer: FooJoin }`. */ @@ -173,7 +168,7 @@ interface RealtimeCompileResult { } /** - * A handler's `schema.jsonc` is a *catalog* of named messages: + * An actor's `schema.jsonc` is a *catalog* of named messages: * { types?: { Pt, Snake, … }, toClient: { init, tick, … }, toServer: { join, … } } * Each message is a flat object schema (no `type` field — the generator injects * `type: ""` as the discriminant). We compile the whole catalog in ONE pass @@ -183,21 +178,25 @@ interface RealtimeCompileResult { * because every message is a single flat object, the fragile multi-declaration * case never arises. */ -async function compileRealtimeHandler( - handler: RealtimeHandler, -): Promise { - const { messageSchema } = handler; +async function compileActor(actor: Actor): Promise { + const { messageSchema } = actor; if (!messageSchema) { return { decls: "", entry: "{ toClient: unknown; toServer: unknown }" }; } - const prefix = toPascalCase(handler.name); + const prefix = toPascalCase(actor.name); const types = (messageSchema.types ?? {}) as Record; - const toClient = (messageSchema.toClient ?? {}) as Record; - const toServer = (messageSchema.toServer ?? {}) as Record; + const toClient = (messageSchema.toClient ?? {}) as Record< + string, + JSONSchema4 + >; + const toServer = (messageSchema.toServer ?? {}) as Record< + string, + JSONSchema4 + >; - // Shared types are prefixed with the handler name so names (Pt, Snake, …) can't - // collide across handlers or with entity interfaces. Messages additionally carry + // Shared types are prefixed with the actor name so names (Pt, Snake, …) can't + // collide across actors or with entity interfaces. Messages additionally carry // their direction, since the same name (e.g. "message") may appear in both // directions. const typeName = (key: string) => `${prefix}${toPascalCase(key)}`; @@ -208,8 +207,8 @@ async function compileRealtimeHandler( const add = (name: string, schema: JSONSchema4) => { if (name in defs) { throw new TypeGenerationError( - `Duplicate generated type "${name}" in realtime handler "${handler.name}" — a shared type and a message resolve to the same name.`, - handler.name, + `Duplicate generated type "${name}" in actor "${actor.name}" — a shared type and a message resolve to the same name.`, + actor.name, ); } defs[name] = { ...schema, title: name }; @@ -234,7 +233,10 @@ async function compileRealtimeHandler( type: "object", ...rewritten, properties: { type: { const: key }, ...(rewritten.properties ?? {}) }, - required: ["type", ...((rewritten.required as string[] | undefined) ?? [])], + required: [ + "type", + ...((rewritten.required as string[] | undefined) ?? []), + ], additionalProperties: false, }); return name; @@ -264,13 +266,14 @@ async function compileRealtimeHandler( ).trim(); } catch (error) { throw new TypeGenerationError( - `Failed to generate types for realtime handler "${handler.name}"`, - handler.name, + `Failed to generate types for actor "${actor.name}"`, + actor.name, error, ); } - const union = (names: string[]) => (names.length ? names.join(" | ") : "never"); + const union = (names: string[]) => + names.length ? names.join(" | ") : "never"; return { decls, entry: `{ toClient: ${union(toClientNames)}; toServer: ${union(toServerNames)} }`, diff --git a/packages/cli/tests/cli/types_generate.spec.ts b/packages/cli/tests/cli/types_generate.spec.ts index 98acbc3e1..b6bd77f3f 100644 --- a/packages/cli/tests/cli/types_generate.spec.ts +++ b/packages/cli/tests/cli/types_generate.spec.ts @@ -46,12 +46,12 @@ describe("types generate command", () => { expect(typesContent).toContain("ConnectorTypeRegistry"); expect(typesContent).toContain(`"slack": true`); - // Contains the RealtimeHandlerNameRegistry with the handler name - expect(typesContent).toContain("RealtimeHandlerNameRegistry"); + // Contains the ActorNameRegistry with the actor name + expect(typesContent).toContain("ActorNameRegistry"); expect(typesContent).toContain(`"ChatRoom": true`); - // Contains the RealtimeHandlerRegistry with typed inbound/outbound (from schema.jsonc) - expect(typesContent).toContain("RealtimeHandlerRegistry"); + // Contains the ActorRegistry with typed inbound/outbound (from schema.jsonc) + expect(typesContent).toContain("ActorRegistry"); expect(typesContent).toContain(`"ChatRoom"`); }); @@ -111,7 +111,7 @@ describe("types generate command", () => { const typesContent = await t.readProjectFile("base44/.types/types.d.ts"); expect(typesContent).not.toBeNull(); expect(typesContent).toContain( - "No entities, functions, agents, connectors, or realtime handlers found", + "No entities, functions, agents, connectors, or actors found", ); }); diff --git a/packages/cli/tests/core/types-realtime.spec.ts b/packages/cli/tests/core/types-actor.spec.ts similarity index 81% rename from packages/cli/tests/core/types-realtime.spec.ts rename to packages/cli/tests/core/types-actor.spec.ts index fcb97ae67..1edc0d8f7 100644 --- a/packages/cli/tests/core/types-realtime.spec.ts +++ b/packages/cli/tests/core/types-actor.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { RealtimeHandler } from "@/core/resources/realtime-handler/schema.js"; +import type { Actor } from "@/core/resources/actor/schema.js"; import { generateContent } from "@/core/types/generator.js"; const EMPTY = { @@ -10,23 +10,23 @@ const EMPTY = { connectors: [], }; -function handler(messageSchema: RealtimeHandler["messageSchema"]): RealtimeHandler { +function actor(messageSchema: Actor["messageSchema"]): Actor { return { name: "GameRoom", entry: "entry.ts", - entryPath: "base44/realtime/GameRoom/entry.ts", - filePaths: ["base44/realtime/GameRoom/entry.ts"], + entryPath: "base44/actors/GameRoom/entry.ts", + filePaths: ["base44/actors/GameRoom/entry.ts"], source: { type: "project" }, messageSchema, }; } -describe("realtime handler type generation", () => { +describe("actor type generation", () => { it("compiles a named-message catalog into a discriminated union with shared types", async () => { const out = await generateContent({ ...EMPTY, - realtimeHandlers: [ - handler({ + actors: [ + actor({ types: { Pt: { type: "object", @@ -37,7 +37,9 @@ describe("realtime handler type generation", () => { }, toClient: { init: { - properties: { food: { type: "array", items: { $ref: "#/types/Pt" } } }, + properties: { + food: { type: "array", items: { $ref: "#/types/Pt" } }, + }, required: ["food"], }, died: { @@ -46,7 +48,10 @@ describe("realtime handler type generation", () => { }, }, toServer: { - dir: { properties: { angle: { type: "number" } }, required: ["angle"] }, + dir: { + properties: { angle: { type: "number" } }, + required: ["angle"], + }, }, }), ], @@ -74,8 +79,8 @@ describe("realtime handler type generation", () => { await expect( generateContent({ ...EMPTY, - realtimeHandlers: [ - handler({ + actors: [ + actor({ // Both keys PascalCase to the same GameRoomToClientUserJoined. toClient: { "user-joined": { properties: { a: { type: "string" } } }, diff --git a/packages/cli/tests/fixtures/with-types-resources/base44/realtime/ChatRoom/entry.ts b/packages/cli/tests/fixtures/with-types-resources/base44/actors/ChatRoom/entry.ts similarity index 55% rename from packages/cli/tests/fixtures/with-types-resources/base44/realtime/ChatRoom/entry.ts rename to packages/cli/tests/fixtures/with-types-resources/base44/actors/ChatRoom/entry.ts index 91a9c29a2..db5ad7165 100644 --- a/packages/cli/tests/fixtures/with-types-resources/base44/realtime/ChatRoom/entry.ts +++ b/packages/cli/tests/fixtures/with-types-resources/base44/actors/ChatRoom/entry.ts @@ -1,6 +1,6 @@ -import { RealtimeHandler, type Conn } from "@base44/sdk"; +import { Actor, type Conn } from "@base44/sdk"; -export class ChatRoom extends RealtimeHandler { +export class ChatRoom extends Actor { handleConnect(_conn: Conn) {} handleMessage(_conn: Conn, _msg: unknown) {} handleTick() {} diff --git a/packages/cli/tests/fixtures/with-types-resources/base44/realtime/ChatRoom/schema.jsonc b/packages/cli/tests/fixtures/with-types-resources/base44/actors/ChatRoom/schema.jsonc similarity index 100% rename from packages/cli/tests/fixtures/with-types-resources/base44/realtime/ChatRoom/schema.jsonc rename to packages/cli/tests/fixtures/with-types-resources/base44/actors/ChatRoom/schema.jsonc From 2c9337e2f0619d6883b9464d92ff9109dc3f4c57 Mon Sep 17 00:00:00 2001 From: imrik Date: Sun, 26 Jul 2026 14:58:29 +0300 Subject: [PATCH 13/22] feat(types): emit declare module for base44:runtime/actors Actors now import their base class from the bundler-served virtual module `base44:runtime/actors` instead of `@base44/sdk`. Emit a matching ambient declaration into the generated types.d.ts (when the app has actors) that re-exports Actor / Conn / ActorRegistry from the SDK package, so the import typechecks in the editor and ActorRegistry keeps its app-specific augmentation. Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/core/types/generator.ts | 13 +++++++++++++ packages/cli/tests/core/types-actor.spec.ts | 7 +++++++ 2 files changed, 20 insertions(+) diff --git a/packages/cli/src/core/types/generator.ts b/packages/cli/src/core/types/generator.ts index 880cf4fce..b28321177 100644 --- a/packages/cli/src/core/types/generator.ts +++ b/packages/cli/src/core/types/generator.ts @@ -120,6 +120,18 @@ export async function generateContent( const actorInterfaces = actorResults.map((r) => r.decls).filter(Boolean); + // Actors import their base class from the bundler-served virtual module + // "base44:runtime/actors"; map that specifier to the SDK's actor exports so + // the import typechecks. ActorRegistry carries the app-specific augmentation + // declared for the SDK package above. + const actorRuntimeModule = actors.length + ? source` + declare module 'base44:runtime/actors' { + export { Actor, type Conn, type ActorRegistry } from '${sdkPackage}'; + } + ` + : ""; + return [ HEADER, "export {};", // module context — ensures declare module augments rather than replaces the SDK package @@ -130,6 +142,7 @@ export async function generateContent( ${registries.join("\n\n")} } `, + actorRuntimeModule, ] .filter(Boolean) .join("\n\n"); diff --git a/packages/cli/tests/core/types-actor.spec.ts b/packages/cli/tests/core/types-actor.spec.ts index 1edc0d8f7..207b69b60 100644 --- a/packages/cli/tests/core/types-actor.spec.ts +++ b/packages/cli/tests/core/types-actor.spec.ts @@ -70,6 +70,13 @@ describe("actor type generation", () => { expect(out).toContain( '"GameRoom": { toClient: GameRoomToClientInit | GameRoomToClientDied; toServer: GameRoomToServerDir }', ); + // The bundler-served virtual module is mapped to the SDK's actor exports so + // `import { Actor } from "base44:runtime/actors"` typechecks (ActorRegistry + // carries the app-specific augmentation). + expect(out).toContain("declare module 'base44:runtime/actors'"); + expect(out).toContain( + "export { Actor, type Conn, type ActorRegistry } from '@base44/sdk'", + ); // Output is valid TS: no `export interface` spliced inside a type literal // (the failure mode of the old regex-based extraction). expect(out).not.toMatch(/\{[^}]*export interface/); From e68d4ce463cbca6ab6e8bd7a02402d6ff325d865 Mon Sep 17 00:00:00 2001 From: imrik Date: Mon, 27 Jul 2026 11:36:14 +0300 Subject: [PATCH 14/22] refactor(types): base44:runtime/actors re-exports only Actor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure types (Conn, ActorRegistry) have no runtime and ActorRegistry is augmented onto the SDK, so they belong in @base44/sdk, not the bundler-served runtime virtual module. The declare module now re-exports only Actor — the one value whose runtime the bundler swaps. Authoring: `import { Actor } from "base44:runtime/actors"` + `import type { Conn, ActorRegistry } from "@base44/sdk"`. Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/core/types/generator.ts | 10 +++++----- packages/cli/tests/core/types-actor.spec.ts | 9 +++------ 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/core/types/generator.ts b/packages/cli/src/core/types/generator.ts index b28321177..b47a6ea20 100644 --- a/packages/cli/src/core/types/generator.ts +++ b/packages/cli/src/core/types/generator.ts @@ -120,14 +120,14 @@ export async function generateContent( const actorInterfaces = actorResults.map((r) => r.decls).filter(Boolean); - // Actors import their base class from the bundler-served virtual module - // "base44:runtime/actors"; map that specifier to the SDK's actor exports so - // the import typechecks. ActorRegistry carries the app-specific augmentation - // declared for the SDK package above. + // Actors import ONLY their base class from the bundler-served virtual module + // "base44:runtime/actors" (the one value whose runtime the bundler swaps). + // Pure types (Conn, ActorRegistry, ...) are imported from the SDK directly — + // they have no runtime, and ActorRegistry is augmented onto the SDK above. const actorRuntimeModule = actors.length ? source` declare module 'base44:runtime/actors' { - export { Actor, type Conn, type ActorRegistry } from '${sdkPackage}'; + export { Actor } from '${sdkPackage}'; } ` : ""; diff --git a/packages/cli/tests/core/types-actor.spec.ts b/packages/cli/tests/core/types-actor.spec.ts index 207b69b60..3dc379c4b 100644 --- a/packages/cli/tests/core/types-actor.spec.ts +++ b/packages/cli/tests/core/types-actor.spec.ts @@ -70,13 +70,10 @@ describe("actor type generation", () => { expect(out).toContain( '"GameRoom": { toClient: GameRoomToClientInit | GameRoomToClientDied; toServer: GameRoomToServerDir }', ); - // The bundler-served virtual module is mapped to the SDK's actor exports so - // `import { Actor } from "base44:runtime/actors"` typechecks (ActorRegistry - // carries the app-specific augmentation). + // The bundler-served virtual module exports ONLY the Actor base class (the + // one value whose runtime the bundler swaps); pure types come from the SDK. expect(out).toContain("declare module 'base44:runtime/actors'"); - expect(out).toContain( - "export { Actor, type Conn, type ActorRegistry } from '@base44/sdk'", - ); + expect(out).toContain("export { Actor } from '@base44/sdk'"); // Output is valid TS: no `export interface` spliced inside a type literal // (the failure mode of the old regex-based extraction). expect(out).not.toMatch(/\{[^}]*export interface/); From 3a07dc37d42d63638901021d0e08324bef1283b0 Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 28 Jul 2026 13:31:21 +0300 Subject: [PATCH 15/22] feat(actor): scaffold imports Actor from base44:runtime/actors The Actor base class is the one value the bundler swaps at deploy/dev, so it comes from the base44:runtime/actors virtual module; pure types (Conn) come from the SDK. Matches the taught authoring shape and the type-gen declare module. Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/cli/commands/actor/new.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/cli/commands/actor/new.ts b/packages/cli/src/cli/commands/actor/new.ts index a5f4c6595..aef17031f 100644 --- a/packages/cli/src/cli/commands/actor/new.ts +++ b/packages/cli/src/cli/commands/actor/new.ts @@ -7,7 +7,8 @@ import { readProjectConfig } from "@/core/index.js"; import { pathExists, writeFile } from "@/core/utils/fs.js"; function buildActorScaffold(actorName: string): string { - return `import { Actor, type Conn } from "@base44/sdk"; + return `import { Actor } from "base44:runtime/actors"; +import type { Conn } from "@base44/sdk"; interface State { // shared state broadcast to all clients From 529e2023849ca8beec2bddad7c36f8be5095000b Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 28 Jul 2026 14:08:56 +0300 Subject: [PATCH 16/22] feat(actor): regenerate types after `actor new` so the scaffolded base44:runtime/actors import resolves Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/cli/commands/actor/new.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/cli/src/cli/commands/actor/new.ts b/packages/cli/src/cli/commands/actor/new.ts index aef17031f..f4af2f8b1 100644 --- a/packages/cli/src/cli/commands/actor/new.ts +++ b/packages/cli/src/cli/commands/actor/new.ts @@ -4,6 +4,7 @@ import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { Base44Command } from "@/cli/utils/index.js"; import { InvalidInputError } from "@/core/errors.js"; import { readProjectConfig } from "@/core/index.js"; +import { generateTypesFile, updateProjectConfig } from "@/core/types/index.js"; import { pathExists, writeFile } from "@/core/utils/fs.js"; function buildActorScaffold(actorName: string): string { @@ -48,6 +49,20 @@ async function newActorAction( const entryPath = join(actorDir, "entry.ts"); await writeFile(entryPath, buildActorScaffold(actorName)); + // Regenerate types so the scaffolded `base44:runtime/actors` import resolves + // in the editor immediately (re-read to pick up the actor just written). + const { entities, functions, agents, connectors, actors } = + await readProjectConfig(); + await generateTypesFile({ + projectRoot: project.root, + entities, + functions, + agents, + connectors, + actors, + }); + await updateProjectConfig(project.root); + return { outroMessage: `Created actor "${actorName}" at ${entryPath}`, }; From 23d6b6e69b6441cf14b935acad6ba6e287b343fe Mon Sep 17 00:00:00 2001 From: imrik Date: Thu, 30 Jul 2026 09:54:48 +0300 Subject: [PATCH 17/22] fix(actor): scaffold matches the SDK Actor API Actor (was reversed as Actor), typed Conn on the handlers, and log conn.id (conn.userId is undefined in phase-1, and the SDK Conn doc steers to id). Keeps Conn imported from @base44/sdk. Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/cli/commands/actor/new.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/cli/commands/actor/new.ts b/packages/cli/src/cli/commands/actor/new.ts index f4af2f8b1..7f3abe871 100644 --- a/packages/cli/src/cli/commands/actor/new.ts +++ b/packages/cli/src/cli/commands/actor/new.ts @@ -11,23 +11,23 @@ function buildActorScaffold(actorName: string): string { return `import { Actor } from "base44:runtime/actors"; import type { Conn } from "@base44/sdk"; -interface State { - // shared state broadcast to all clients +interface Incoming { + // messages clients send to this actor (schema toServer) } -interface Message { - // messages sent from clients +interface Outgoing { + // messages this actor sends to clients (schema toClient) } -export class ${actorName} extends Actor { - handleConnect(conn: Conn) { - console.log("Connected:", conn.userId); +export class ${actorName} extends Actor { + handleConnect(conn: Conn) { + console.log("Connected:", conn.id); } - handleMessage(conn: Conn, msg: Message) { + handleMessage(conn: Conn, msg: Incoming) { console.log("Message:", msg); } handleTick() {} - handleClose(conn: Conn) {} + handleClose(conn: Conn) {} } `; } From 5a8b43bc2b331ab070b90c5b07cf2d2414992a52 Mon Sep 17 00:00:00 2001 From: imrik Date: Thu, 30 Jul 2026 10:25:59 +0300 Subject: [PATCH 18/22] fix(actor): make base44:runtime/actors actually resolve in the editor Two bugs made a scaffolded actor still report TS2307: 1. The `declare module 'base44:runtime/actors'` was emitted into types.d.ts, which is a MODULE (it has exports). There the declaration is a failed augmentation of a non-existent module, so the specifier never resolves. Emit it into its own ambient (script-context) runtime.d.ts instead. 2. updateProjectConfig only added base44/.types to tsconfig include, so actor entry files weren't in the TS program and the ambient declaration didn't apply to them. Add base44/actors/**/*.ts too. Verified end-to-end: `base44 actor new` in a fresh project + `tsc` -> zero errors, and regenerating the snake app's types -> zero errors. Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/core/config.ts | 10 ++++ packages/cli/src/core/types/generator.ts | 46 +++++++++++++------ packages/cli/src/core/types/update-project.ts | 23 ++++++---- packages/cli/tests/core/types-actor.spec.ts | 37 +++++++++++++-- 4 files changed, 88 insertions(+), 28 deletions(-) diff --git a/packages/cli/src/core/config.ts b/packages/cli/src/core/config.ts index 6e2af15b1..bca651fdd 100644 --- a/packages/cli/src/core/config.ts +++ b/packages/cli/src/core/config.ts @@ -26,6 +26,16 @@ export function getTypesOutputPath(projectRoot: string): string { return join(projectRoot, PROJECT_SUBDIR, TYPES_OUTPUT_SUBDIR, TYPES_FILENAME); } +/** + * Ambient declaration for the `base44:runtime/actors` virtual module. Kept in + * its own script-context file (no exports) so the `declare module` is an ambient + * declaration — in the module-scoped types.d.ts it would be a failed augmentation + * of a non-existent module and never resolve. + */ +export function getActorRuntimeTypesPath(projectRoot: string): string { + return join(projectRoot, PROJECT_SUBDIR, TYPES_OUTPUT_SUBDIR, "runtime.d.ts"); +} + export function getBase44ApiUrl(): string { return process.env.BASE44_API_URL || "https://app.base44.com"; } diff --git a/packages/cli/src/core/types/generator.ts b/packages/cli/src/core/types/generator.ts index b47a6ea20..668322041 100644 --- a/packages/cli/src/core/types/generator.ts +++ b/packages/cli/src/core/types/generator.ts @@ -2,14 +2,19 @@ import { join } from "node:path"; import { source, stripIndent } from "common-tags"; import type { JSONSchema4 } from "json-schema"; import { compile } from "json-schema-to-typescript"; -import { getTypesOutputPath } from "@/core/config.js"; +import { getActorRuntimeTypesPath, getTypesOutputPath } from "@/core/config.js"; import { TypeGenerationError } from "@/core/errors.js"; import type { Actor } from "@/core/resources/actor/schema.js"; import type { AgentConfig } from "@/core/resources/agent/index.js"; import type { ConnectorResource } from "@/core/resources/connector/index.js"; import type { Entity } from "@/core/resources/entity/index.js"; import type { BackendFunction } from "@/core/resources/function/index.js"; -import { readJsonFile, writeFile } from "@/core/utils/fs.js"; +import { + deleteFile, + pathExists, + readJsonFile, + writeFile, +} from "@/core/utils/fs.js"; interface GenerateTypesInput { projectRoot: string; @@ -69,6 +74,27 @@ export async function generateTypesFile( ): Promise { const content = await generateContent(input); await writeFile(getTypesOutputPath(input.projectRoot), content); + + // The base44:runtime/actors virtual module must be an AMBIENT declaration, so + // it lives in its own script-context file. types.d.ts is a module (it has + // exports), where `declare module 'base44:runtime/actors'` is a failed + // augmentation of a non-existent module and the import never resolves. + const runtimePath = getActorRuntimeTypesPath(input.projectRoot); + if (input.actors.length) { + const sdkPackage = await detectSdkPackageName(input.projectRoot); + await writeFile(runtimePath, actorRuntimeDeclaration(sdkPackage)); + } else if (await pathExists(runtimePath)) { + await deleteFile(runtimePath); + } +} + +/** Ambient declaration that makes `base44:runtime/actors` resolve pre-deploy. */ +function actorRuntimeDeclaration(sdkPackage: SdkPackageName): string { + return `${HEADER}\n\n${source` + declare module 'base44:runtime/actors' { + export { Actor } from '${sdkPackage}'; + } + `}\n`; } export async function generateContent( @@ -120,18 +146,9 @@ export async function generateContent( const actorInterfaces = actorResults.map((r) => r.decls).filter(Boolean); - // Actors import ONLY their base class from the bundler-served virtual module - // "base44:runtime/actors" (the one value whose runtime the bundler swaps). - // Pure types (Conn, ActorRegistry, ...) are imported from the SDK directly — - // they have no runtime, and ActorRegistry is augmented onto the SDK above. - const actorRuntimeModule = actors.length - ? source` - declare module 'base44:runtime/actors' { - export { Actor } from '${sdkPackage}'; - } - ` - : ""; - + // NOTE: the `base44:runtime/actors` virtual module is declared in a separate + // ambient file (see generateTypesFile) — it must NOT go here, because this + // file is a module and the declaration would be a failed augmentation. return [ HEADER, "export {};", // module context — ensures declare module augments rather than replaces the SDK package @@ -142,7 +159,6 @@ export async function generateContent( ${registries.join("\n\n")} } `, - actorRuntimeModule, ] .filter(Boolean) .join("\n\n"); diff --git a/packages/cli/src/core/types/update-project.ts b/packages/cli/src/core/types/update-project.ts index 1d88375c3..c61d429ff 100644 --- a/packages/cli/src/core/types/update-project.ts +++ b/packages/cli/src/core/types/update-project.ts @@ -3,11 +3,15 @@ import { PROJECT_SUBDIR, TYPES_OUTPUT_SUBDIR } from "@/core/consts.js"; import { pathExists, readJsonFile, writeJsonFile } from "@/core/utils/fs.js"; const TYPES_INCLUDE_PATH = `${PROJECT_SUBDIR}/${TYPES_OUTPUT_SUBDIR}/*.d.ts`; +// Actor sources must be in the TS program so the ambient base44:runtime/actors +// declaration (in base44/.types) applies to them; otherwise entry.ts still +// reports "Cannot find module 'base44:runtime/actors'". +const ACTORS_INCLUDE_PATH = `${PROJECT_SUBDIR}/actors/**/*.ts`; /** * Update project configuration files after generating types. * Currently handles: - * - tsconfig.json: adds base44/.types to the include array + * - tsconfig.json: adds base44/.types and base44/actors to the include array * * @returns true if tsconfig.json was updated, false otherwise */ @@ -30,15 +34,18 @@ export async function updateProjectConfig( tsconfig.include = []; } - // Check if already included - if (tsconfig.include.includes(TYPES_INCLUDE_PATH)) { - return false; + let changed = false; + for (const path of [TYPES_INCLUDE_PATH, ACTORS_INCLUDE_PATH]) { + if (!tsconfig.include.includes(path)) { + tsconfig.include.push(path); + changed = true; + } } - // Add to include array - tsconfig.include.push(TYPES_INCLUDE_PATH); - await writeJsonFile(tsconfigPath, tsconfig); - return true; + if (changed) { + await writeJsonFile(tsconfigPath, tsconfig); + } + return changed; } catch { // If we can't parse or update, silently fail and let user configure manually return false; diff --git a/packages/cli/tests/core/types-actor.spec.ts b/packages/cli/tests/core/types-actor.spec.ts index 3dc379c4b..6d3231f00 100644 --- a/packages/cli/tests/core/types-actor.spec.ts +++ b/packages/cli/tests/core/types-actor.spec.ts @@ -1,6 +1,10 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { getActorRuntimeTypesPath, getTypesOutputPath } from "@/core/config.js"; import type { Actor } from "@/core/resources/actor/schema.js"; -import { generateContent } from "@/core/types/generator.js"; +import { generateContent, generateTypesFile } from "@/core/types/generator.js"; const EMPTY = { projectRoot: "/tmp/does-not-matter", // only read for package.json detect; falls back to @base44/sdk @@ -70,15 +74,38 @@ describe("actor type generation", () => { expect(out).toContain( '"GameRoom": { toClient: GameRoomToClientInit | GameRoomToClientDied; toServer: GameRoomToServerDir }', ); - // The bundler-served virtual module exports ONLY the Actor base class (the - // one value whose runtime the bundler swaps); pure types come from the SDK. - expect(out).toContain("declare module 'base44:runtime/actors'"); - expect(out).toContain("export { Actor } from '@base44/sdk'"); + // The base44:runtime/actors virtual module is emitted into a SEPARATE ambient + // file (see the next test), never into this module-scoped output — here it + // would be a failed augmentation and the import would not resolve. + expect(out).not.toContain("base44:runtime/actors"); // Output is valid TS: no `export interface` spliced inside a type literal // (the failure mode of the old regex-based extraction). expect(out).not.toMatch(/\{[^}]*export interface/); }); + it("emits base44:runtime/actors as an ambient .d.ts (not the module-scoped types.d.ts)", async () => { + const root = await mkdtemp(join(tmpdir(), "b44-types-")); + try { + await generateTypesFile({ + ...EMPTY, + projectRoot: root, + actors: [actor(undefined)], + }); + const runtime = await readFile(getActorRuntimeTypesPath(root), "utf8"); + const types = await readFile(getTypesOutputPath(root), "utf8"); + + // The ambient module lives in its own script-context file... + expect(runtime).toContain("declare module 'base44:runtime/actors'"); + expect(runtime).toContain("export { Actor } from '@base44/sdk'"); + // ...with no top-level export, so it stays an ambient declaration. + expect(runtime).not.toMatch(/^export \{\};/m); + // ...and it must NOT appear in the module-scoped types.d.ts. + expect(types).not.toContain("base44:runtime/actors"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + it("throws on a name collision instead of silently clobbering", async () => { await expect( generateContent({ From 3a63ca26c9ccf0ae09cbfb408df4dd0d41d38a63 Mon Sep 17 00:00:00 2001 From: imrik Date: Thu, 30 Jul 2026 10:39:02 +0300 Subject: [PATCH 19/22] feat(actor): scaffold schema.jsonc and type the actor from ActorRegistry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `actor new` now also writes a starter schema.jsonc (toClient/toServer message catalog) and the entry.ts derives its message types from ActorRegistry[""] — the same source the client is typed from, so server and client can't drift. Replaces the local empty Incoming/Outgoing interfaces. Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/cli/commands/actor/new.ts | 46 ++++++++++++++++------ 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/cli/commands/actor/new.ts b/packages/cli/src/cli/commands/actor/new.ts index 7f3abe871..a624a1c1c 100644 --- a/packages/cli/src/cli/commands/actor/new.ts +++ b/packages/cli/src/cli/commands/actor/new.ts @@ -9,15 +9,13 @@ import { pathExists, writeFile } from "@/core/utils/fs.js"; function buildActorScaffold(actorName: string): string { return `import { Actor } from "base44:runtime/actors"; -import type { Conn } from "@base44/sdk"; +import type { ActorRegistry, Conn } from "@base44/sdk"; -interface Incoming { - // messages clients send to this actor (schema toServer) -} - -interface Outgoing { - // messages this actor sends to clients (schema toClient) -} +// Message types are generated from ./schema.jsonc by \`base44 types generate\` — +// the same source the client is typed from, so the two can't drift. +type Messages = ActorRegistry["${actorName}"]; +type Incoming = Messages["toServer"]; +type Outgoing = Messages["toClient"]; export class ${actorName} extends Actor { handleConnect(conn: Conn) { @@ -32,6 +30,30 @@ export class ${actorName} extends Actor { `; } +// Starter message catalog. Each message is a type-less object schema (the +// generator injects the \`type\` discriminant); shared shapes go under \`types\` +// and are referenced via #/types/. +function buildActorSchema(): string { + return `{ + "types": {}, + // Messages this actor sends to clients (server → client). + "toClient": { + "welcome": { + "properties": { "message": { "type": "string" } }, + "required": ["message"] + } + }, + // Messages clients send to this actor (client → server). + "toServer": { + "hello": { + "properties": { "name": { "type": "string" } }, + "required": ["name"] + } + } +} +`; +} + async function newActorAction( _ctx: CLIContext, actorName: string, @@ -48,9 +70,11 @@ async function newActorAction( const entryPath = join(actorDir, "entry.ts"); await writeFile(entryPath, buildActorScaffold(actorName)); + await writeFile(join(actorDir, "schema.jsonc"), buildActorSchema()); - // Regenerate types so the scaffolded `base44:runtime/actors` import resolves - // in the editor immediately (re-read to pick up the actor just written). + // Regenerate types so the scaffolded `base44:runtime/actors` import + the + // schema-derived ActorRegistry types resolve immediately (re-read to pick up + // the actor and its schema just written). const { entities, functions, agents, connectors, actors } = await readProjectConfig(); await generateTypesFile({ @@ -64,7 +88,7 @@ async function newActorAction( await updateProjectConfig(project.root); return { - outroMessage: `Created actor "${actorName}" at ${entryPath}`, + outroMessage: `Created actor "${actorName}" at ${entryPath} — define its messages in schema.jsonc`, }; } From 901f1c873127c65902031e712407910adacfbdf2 Mon Sep 17 00:00:00 2001 From: imrik Date: Thu, 30 Jul 2026 12:26:56 +0300 Subject: [PATCH 20/22] =?UTF-8?q?fix(actor):=20scaffold=20a=20default=20ex?= =?UTF-8?q?port=20=E2=80=94=20the=20deploy=20bundler=20needs=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundler's generated entry does `import Base44UserActor from ""`, so a named `export class ` fails to bundle ("No matching export ... for import default"). Scaffold `export default class ` instead. Verified via the full canonical flow: base44 create -> actor new -> actor deploy succeeds with the unmodified scaffold. Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/cli/commands/actor/new.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/cli/commands/actor/new.ts b/packages/cli/src/cli/commands/actor/new.ts index a624a1c1c..f7baacaae 100644 --- a/packages/cli/src/cli/commands/actor/new.ts +++ b/packages/cli/src/cli/commands/actor/new.ts @@ -17,7 +17,8 @@ type Messages = ActorRegistry["${actorName}"]; type Incoming = Messages["toServer"]; type Outgoing = Messages["toClient"]; -export class ${actorName} extends Actor { +// The deploy bundler imports the actor as the entry's default export. +export default class ${actorName} extends Actor { handleConnect(conn: Conn) { console.log("Connected:", conn.id); } From f1f8ac4bb10f0029429fabf72bf2f3b965f056ad Mon Sep 17 00:00:00 2001 From: talge-a11y Date: Mon, 10 Aug 2026 15:20:35 +0300 Subject: [PATCH 21/22] remove scaffolding, will rely on a skill --- CHANGELOG.md | 1 + docs/resources.md | 24 +- docs/testing.md | 7 + packages/cli/README.md | 1 + packages/cli/src/cli/commands/actor/new.ts | 103 -------- .../cli/commands/{actor => actors}/deploy.ts | 58 +---- .../cli/commands/{actor => actors}/index.ts | 6 +- .../cli/src/cli/commands/functions/delete.ts | 10 +- .../cli/src/cli/commands/functions/deploy.ts | 27 +- .../commands/functions/formatDeployResult.ts | 24 -- .../cli/src/cli/commands/project/deploy.ts | 18 +- packages/cli/src/cli/program.ts | 6 +- .../cli/src/cli/utils/deploy-reporting.ts | 39 +++ packages/cli/src/cli/utils/index.ts | 2 + .../parseNames.ts => utils/parse-names.ts} | 0 packages/cli/src/core/config.ts | 10 - packages/cli/src/core/consts.ts | 6 +- packages/cli/src/core/project/deploy.ts | 14 +- .../cli/src/core/resources/actor/config.ts | 41 +--- .../cli/src/core/resources/actor/deploy.ts | 8 +- .../cli/src/core/resources/actor/schema.ts | 33 +-- .../cli/src/core/resources/function/deploy.ts | 8 +- packages/cli/src/core/resources/index.ts | 1 + packages/cli/src/core/resources/types.ts | 10 + packages/cli/src/core/types/generator.ts | 230 +----------------- packages/cli/src/core/types/update-project.ts | 23 +- packages/cli/tests/cli/actors_deploy.spec.ts | 90 +++++++ packages/cli/tests/cli/deploy.spec.ts | 16 ++ .../cli/tests/cli/testkit/TestAPIServer.ts | 22 ++ packages/cli/tests/cli/types_generate.spec.ts | 4 - packages/cli/tests/core/types-actor.spec.ts | 126 ---------- .../fixtures/with-actors/base44/.app.jsonc | 4 + .../base44/actors/ChatRoom/entry.ts | 11 + .../base44/actors/ChatRoom/helper.ts | 3 + .../fixtures/with-actors/base44/config.jsonc | 3 + .../base44/actors/ChatRoom/schema.jsonc | 28 --- 36 files changed, 322 insertions(+), 695 deletions(-) delete mode 100644 packages/cli/src/cli/commands/actor/new.ts rename packages/cli/src/cli/commands/{actor => actors}/deploy.ts (56%) rename packages/cli/src/cli/commands/{actor => actors}/index.ts (51%) delete mode 100644 packages/cli/src/cli/commands/functions/formatDeployResult.ts create mode 100644 packages/cli/src/cli/utils/deploy-reporting.ts rename packages/cli/src/cli/{commands/functions/parseNames.ts => utils/parse-names.ts} (100%) create mode 100644 packages/cli/tests/cli/actors_deploy.spec.ts delete mode 100644 packages/cli/tests/core/types-actor.spec.ts create mode 100644 packages/cli/tests/fixtures/with-actors/base44/.app.jsonc create mode 100644 packages/cli/tests/fixtures/with-actors/base44/actors/ChatRoom/entry.ts create mode 100644 packages/cli/tests/fixtures/with-actors/base44/actors/ChatRoom/helper.ts create mode 100644 packages/cli/tests/fixtures/with-actors/base44/config.jsonc delete mode 100644 packages/cli/tests/fixtures/with-types-resources/base44/actors/ChatRoom/schema.jsonc diff --git a/CHANGELOG.md b/CHANGELOG.md index 836bd87c9..98d78c695 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Actors (realtime handlers): deploy from `base44/actors/` via `base44 actors deploy`, included in unified `base44 deploy`; `base44 types generate` emits `ActorNameRegistry`. - App visibility: `base44 visibility ` sets it on the server directly (accepts `--app-id` to target any app). Also configurable via `"visibility"` in `config.jsonc`, which `base44 deploy` applies. New projects scaffold `"visibility": "public"`. - `base44 build` runs the site's `buildCommand` with `VITE_BASE44_APP_ID` injected, so built bundles always carry the linked app's id. - `base44 deploy` (and `base44 site deploy`) can now build first: interactive runs ask, and `--build` / `--no-build` pre-answer the prompt. diff --git a/docs/resources.md b/docs/resources.md index 8fc9be4f7..201f4504c 100644 --- a/docs/resources.md +++ b/docs/resources.md @@ -1,8 +1,8 @@ # Working with Resources -**Keywords:** resource, entity, function, agent, agent skill, connector, push, readAll, deploy, site, tar.gz, deployAll, ProjectData +**Keywords:** resource, entity, function, actor, agent, agent skill, connector, push, readAll, deploy, site, tar.gz, deployAll, ProjectData -Resources are project-specific collections (entities, functions, agents, agent skills, connectors) that can be read from the filesystem and pushed to the Base44 API. +Resources are project-specific collections (entities, functions, actors, agents, agent skills, connectors) that can be read from the filesystem and pushed to the Base44 API. ## Resource Interface @@ -85,6 +85,17 @@ Deploy ships file contents verbatim — the source is never parsed or linted — Entry files may also import `secrets` and `waitUntil` from `base44:runtime`. Locally, `base44 dev` runs functions on workerd via Miniflare by default — each function is bundled with esbuild + `@deno/loader` (`src/cli/dev/dev-server/function-bundler.ts`), with `base44:runtime` served as a virtual module, secrets as real Worker env bindings and `waitUntil` riding `ctx.waitUntil`. A fallback runtime covers installations where workerd is unavailable (compiled binaries, `B44_DEV_FUNCTIONS_RUNTIME=deno`) and supplies `base44:runtime` via an import map. A project-level `deno.json` import map is not applied to functions — locally or deployed — since only files under `base44/` are uploaded. See [`packages/cli/backend-runtime/README.md`](../packages/cli/backend-runtime/README.md) for the local implementation and its intentional differences from production. +## Actors (project layout) + +Actors are stateful realtime handlers, read from the project's actors directory (`base44/actors/`, or `actorsDir` in `config.jsonc`). Discovery is zero-config only: a folder containing `entry.ts` (or `entry.js`) is an actor, and its name is the path from the actors root (e.g. `actors/ChatRoom/entry.ts` → name `ChatRoom`; nesting is allowed). All `**/*.{js,ts,json,jsonc}` files under that folder are included in the deploy payload, sent via `PUT /api/apps/{app_id}/actors/{name}`. The entry file must default-export the actor class — the deploy bundler imports the default export. + +Deliberate gaps (vs functions): no `base44/shared/` inclusion, no `--force` prune, no plugin actors, and no local `base44 dev` runtime. Authoring guidance (scaffolding, message typing, editor setup for the `base44:runtime/actors` virtual module) lives in the realtime skill, not the CLI. Type generation only emits `ActorNameRegistry` (actor names) into `types.d.ts`. + +```bash +base44 actors deploy # Deploy all actors +base44 actors deploy ChatRoom # Deploy specific actors by name +``` + ## Agent skills Agent skills are app-scoped instruction snippets shared across the app's agents. Unlike other resources they are stored as one markdown file per skill under the agent-skills directory (`base44/agent-skills/`, or `agentSkillsDir` in `config.jsonc`): the filename (without `.md`) is the skill name, the frontmatter `description` is the summary, and the body is the instruction text. Agents reference skills by name via `selected_skill_names`; `selected_workspace_skill_ids` (org-shared workspace skills) is not managed here and is passed through pull/push/deploy untouched. @@ -136,10 +147,11 @@ const { appUrl } = await deployAll(projectData); What it deploys (in order): 1. Entities (via `entityResource.push()`) 2. Functions (via `functionResource.push()`) -3. Agent skills (via `agentSkillResource.push()`) -4. Agents (via `agentResource.push()`) -5. Connectors (via `pushConnectors()`) -- may return OAuth redirect URLs -6. Site (if `site.outputDirectory` is configured) — the legacy tar.gz upload. The env-gated deployments-API lane is reachable only from `base44 site deploy`, not from here (see [deployments.md](deployments.md)). +3. Actors (via `deployActorsSequentially()`) +4. Agent skills (via `agentSkillResource.push()`) +5. Agents (via `agentResource.push()`) +6. Connectors (via `pushConnectors()`) -- may return OAuth redirect URLs +7. Site (if `site.outputDirectory` is configured) — the legacy tar.gz upload. The env-gated deployments-API lane is reachable only from `base44 site deploy`, not from here (see [deployments.md](deployments.md)). ```bash base44 deploy # With confirmation prompt diff --git a/docs/testing.md b/docs/testing.md index f34052dc6..77b4e449a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -210,6 +210,13 @@ t.api.mockFunctionsPush({ deployed: ["handler"], deleted: [], errors: null }); t.api.mockFunctionsPushError({ status: 400, body: { error: "Invalid" } }); ``` +### Actor Mocks + +```typescript +t.api.mockSingleActorDeploy({ status: "deployed" }); +t.api.mockSingleActorDeployError({ status: 400, body: { error: "Invalid" } }); +``` + ### Agent Mocks ```typescript diff --git a/packages/cli/README.md b/packages/cli/README.md index 4a48fb8c9..3f959f564 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -51,6 +51,7 @@ The CLI will guide you through project setup. For step-by-step tutorials, see th | [`login`](https://docs.base44.com/developers/references/cli/commands/login) | Authenticate with Base44 | | [`logout`](https://docs.base44.com/developers/references/cli/commands/logout) | Sign out and clear stored credentials | | [`whoami`](https://docs.base44.com/developers/references/cli/commands/whoami) | Display the current authenticated user | +| `actors deploy` | Deploy local actors to Base44 | | [`agents pull`](https://docs.base44.com/developers/references/cli/commands/agents-pull) | Pull agents from Base44 to local files | | [`agents push`](https://docs.base44.com/developers/references/cli/commands/agents-push) | Push local agents to Base44 | | [`connectors initiate`](https://docs.base44.com/developers/references/cli/commands/connectors-initiate) | Initialize a connector on an app and start its OAuth flow | diff --git a/packages/cli/src/cli/commands/actor/new.ts b/packages/cli/src/cli/commands/actor/new.ts deleted file mode 100644 index f7baacaae..000000000 --- a/packages/cli/src/cli/commands/actor/new.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { dirname, join } from "node:path"; -import type { Command } from "commander"; -import type { CLIContext, RunCommandResult } from "@/cli/types.js"; -import { Base44Command } from "@/cli/utils/index.js"; -import { InvalidInputError } from "@/core/errors.js"; -import { readProjectConfig } from "@/core/index.js"; -import { generateTypesFile, updateProjectConfig } from "@/core/types/index.js"; -import { pathExists, writeFile } from "@/core/utils/fs.js"; - -function buildActorScaffold(actorName: string): string { - return `import { Actor } from "base44:runtime/actors"; -import type { ActorRegistry, Conn } from "@base44/sdk"; - -// Message types are generated from ./schema.jsonc by \`base44 types generate\` — -// the same source the client is typed from, so the two can't drift. -type Messages = ActorRegistry["${actorName}"]; -type Incoming = Messages["toServer"]; -type Outgoing = Messages["toClient"]; - -// The deploy bundler imports the actor as the entry's default export. -export default class ${actorName} extends Actor { - handleConnect(conn: Conn) { - console.log("Connected:", conn.id); - } - handleMessage(conn: Conn, msg: Incoming) { - console.log("Message:", msg); - } - handleTick() {} - handleClose(conn: Conn) {} -} -`; -} - -// Starter message catalog. Each message is a type-less object schema (the -// generator injects the \`type\` discriminant); shared shapes go under \`types\` -// and are referenced via #/types/. -function buildActorSchema(): string { - return `{ - "types": {}, - // Messages this actor sends to clients (server → client). - "toClient": { - "welcome": { - "properties": { "message": { "type": "string" } }, - "required": ["message"] - } - }, - // Messages clients send to this actor (client → server). - "toServer": { - "hello": { - "properties": { "name": { "type": "string" } }, - "required": ["name"] - } - } -} -`; -} - -async function newActorAction( - _ctx: CLIContext, - actorName: string, -): Promise { - const { project } = await readProjectConfig(); - const actorsDir = join(dirname(project.configPath), project.actorsDir); - const actorDir = join(actorsDir, actorName); - - if (await pathExists(actorDir)) { - throw new InvalidInputError( - `Actor "${actorName}" already exists at ${actorDir}`, - ); - } - - const entryPath = join(actorDir, "entry.ts"); - await writeFile(entryPath, buildActorScaffold(actorName)); - await writeFile(join(actorDir, "schema.jsonc"), buildActorSchema()); - - // Regenerate types so the scaffolded `base44:runtime/actors` import + the - // schema-derived ActorRegistry types resolve immediately (re-read to pick up - // the actor and its schema just written). - const { entities, functions, agents, connectors, actors } = - await readProjectConfig(); - await generateTypesFile({ - projectRoot: project.root, - entities, - functions, - agents, - connectors, - actors, - }); - await updateProjectConfig(project.root); - - return { - outroMessage: `Created actor "${actorName}" at ${entryPath} — define its messages in schema.jsonc`, - }; -} - -export function getNewCommand(): Command { - return new Base44Command("new") - .description("Create a new actor scaffold") - .argument("", "Name of the actor class") - .action(async (ctx: CLIContext, actorName: string) => { - return newActorAction(ctx, actorName); - }); -} diff --git a/packages/cli/src/cli/commands/actor/deploy.ts b/packages/cli/src/cli/commands/actors/deploy.ts similarity index 56% rename from packages/cli/src/cli/commands/actor/deploy.ts rename to packages/cli/src/cli/commands/actors/deploy.ts index 5232f5cc2..6a11980af 100644 --- a/packages/cli/src/cli/commands/actor/deploy.ts +++ b/packages/cli/src/cli/commands/actors/deploy.ts @@ -1,23 +1,18 @@ -import type { Logger } from "@base44-cli/logger"; import type { Command } from "commander"; import { CLIExitError } from "@/cli/errors.js"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; -import { Base44Command, theme } from "@/cli/utils/index.js"; +import { + Base44Command, + buildDeploySummary, + formatDeployResult, + parseNames, + theme, +} from "@/cli/utils/index.js"; import { InvalidInputError } from "@/core/errors.js"; import { readProjectConfig } from "@/core/index.js"; -import { - deployActorsSequentially, - type SingleActorDeployResult, -} from "@/core/resources/actor/deploy.js"; +import { deployActorsSequentially } from "@/core/resources/actor/deploy.js"; import type { Actor } from "@/core/resources/actor/schema.js"; -function parseNames(args: string[]): string[] { - return args - .flatMap((arg) => arg.split(",")) - .map((n) => n.trim()) - .filter(Boolean); -} - function resolveActorsToDeploy(names: string[], allActors: Actor[]): Actor[] { if (names.length === 0) return allActors; @@ -30,36 +25,7 @@ function resolveActorsToDeploy(names: string[], allActors: Actor[]): Actor[] { return allActors.filter((a) => names.includes(a.name)); } -function formatDeployResult( - result: SingleActorDeployResult, - log: Logger, -): void { - const label = result.name.padEnd(25); - if (result.status === "deployed") { - const timing = result.durationMs - ? theme.styles.dim(` (${(result.durationMs / 1000).toFixed(1)}s)`) - : ""; - log.success(`${label} deployed${timing}`); - } else if (result.status === "unchanged") { - log.success(`${label} unchanged`); - } else { - log.error(`${label} error: ${result.error}`); - } -} - -function buildDeploySummary(results: SingleActorDeployResult[]): string { - const deployed = results.filter((r) => r.status === "deployed").length; - const unchanged = results.filter((r) => r.status === "unchanged").length; - const failed = results.filter((r) => r.status === "error").length; - - const parts: string[] = []; - if (deployed > 0) parts.push(`${deployed} deployed`); - if (unchanged > 0) parts.push(`${unchanged} unchanged`); - if (failed > 0) parts.push(`${failed} error${failed !== 1 ? "s" : ""}`); - return parts.join(", ") || "No actors deployed"; -} - -async function deployActorAction( +async function deployActorsAction( { log }: CLIContext, names: string[], ): Promise { @@ -95,11 +61,11 @@ async function deployActorAction( const hasFailures = results.some((r) => r.status === "error"); if (hasFailures) { - log.message(buildDeploySummary(results)); + log.message(buildDeploySummary(results, "actors")); throw new CLIExitError(1); } - return { outroMessage: buildDeploySummary(results) }; + return { outroMessage: buildDeploySummary(results, "actors") }; } export function getDeployCommand(): Command { @@ -108,6 +74,6 @@ export function getDeployCommand(): Command { .argument("[names...]", "Actor names to deploy (deploys all if omitted)") .action(async (ctx: CLIContext, rawNames: string[]) => { const names = parseNames(rawNames); - return deployActorAction(ctx, names); + return deployActorsAction(ctx, names); }); } diff --git a/packages/cli/src/cli/commands/actor/index.ts b/packages/cli/src/cli/commands/actors/index.ts similarity index 51% rename from packages/cli/src/cli/commands/actor/index.ts rename to packages/cli/src/cli/commands/actors/index.ts index 6be9a1495..7b256abfd 100644 --- a/packages/cli/src/cli/commands/actor/index.ts +++ b/packages/cli/src/cli/commands/actors/index.ts @@ -1,10 +1,8 @@ import { Command } from "commander"; import { getDeployCommand } from "./deploy.js"; -import { getNewCommand } from "./new.js"; -export function getActorCommand(): Command { - return new Command("actor") +export function getActorsCommand(): Command { + return new Command("actors") .description("Manage actors") - .addCommand(getNewCommand()) .addCommand(getDeployCommand()); } diff --git a/packages/cli/src/cli/commands/functions/delete.ts b/packages/cli/src/cli/commands/functions/delete.ts index 56d04789a..235f30dad 100644 --- a/packages/cli/src/cli/commands/functions/delete.ts +++ b/packages/cli/src/cli/commands/functions/delete.ts @@ -1,6 +1,6 @@ import type { Command } from "commander"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; -import { Base44Command } from "@/cli/utils/index.js"; +import { Base44Command, parseNames } from "@/cli/utils/index.js"; import { ApiError } from "@/core/errors.js"; import { deleteSingleFunction } from "@/core/resources/function/api.js"; @@ -42,14 +42,6 @@ async function deleteFunctionsAction( return { outroMessage: parts.join(", ") }; } -/** Parse names from variadic CLI args, supporting comma-separated values. */ -function parseNames(args: string[]): string[] { - return args - .flatMap((arg) => arg.split(",")) - .map((n) => n.trim()) - .filter(Boolean); -} - function validateNames(command: Command): void { const names = parseNames(command.args); if (names.length === 0) { diff --git a/packages/cli/src/cli/commands/functions/deploy.ts b/packages/cli/src/cli/commands/functions/deploy.ts index e282e6633..d2645954d 100644 --- a/packages/cli/src/cli/commands/functions/deploy.ts +++ b/packages/cli/src/cli/commands/functions/deploy.ts @@ -1,17 +1,20 @@ import type { Logger } from "@base44-cli/logger"; import type { Command } from "commander"; -import { formatDeployResult } from "@/cli/commands/functions/formatDeployResult.js"; -import { parseNames } from "@/cli/commands/functions/parseNames.js"; import { CLIExitError } from "@/cli/errors.js"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; -import { Base44Command, theme } from "@/cli/utils/index.js"; +import { + Base44Command, + buildDeploySummary, + formatDeployResult, + parseNames, + theme, +} from "@/cli/utils/index.js"; import { InvalidInputError } from "@/core/errors.js"; import { readProjectConfig } from "@/core/index.js"; import { deployFunctionsSequentially, type PruneResult, pruneRemovedFunctions, - type SingleFunctionDeployResult, } from "@/core/resources/function/deploy.js"; import type { BackendFunction } from "@/core/resources/function/schema.js"; @@ -45,18 +48,6 @@ function formatPruneSummary(pruneResults: PruneResult[], log: Logger): void { } } -function buildDeploySummary(results: SingleFunctionDeployResult[]): string { - const deployed = results.filter((r) => r.status === "deployed").length; - const unchanged = results.filter((r) => r.status === "unchanged").length; - const failed = results.filter((r) => r.status === "error").length; - - const parts: string[] = []; - if (deployed > 0) parts.push(`${deployed} deployed`); - if (unchanged > 0) parts.push(`${unchanged} unchanged`); - if (failed > 0) parts.push(`${failed} error${failed !== 1 ? "s" : ""}`); - return parts.join(", ") || "No functions deployed"; -} - async function deployFunctionsAction( { log }: CLIContext, names: string[], @@ -103,7 +94,7 @@ async function deployFunctionsAction( const hasFailures = results.some((r) => r.status === "error"); if (hasFailures) { - log.message(buildDeploySummary(results)); + log.message(buildDeploySummary(results, "functions")); throw new CLIExitError(1); } @@ -133,7 +124,7 @@ async function deployFunctionsAction( formatPruneSummary(pruneResults, log); } - return { outroMessage: buildDeploySummary(results) }; + return { outroMessage: buildDeploySummary(results, "functions") }; } export function getDeployCommand(): Command { diff --git a/packages/cli/src/cli/commands/functions/formatDeployResult.ts b/packages/cli/src/cli/commands/functions/formatDeployResult.ts deleted file mode 100644 index 39406a203..000000000 --- a/packages/cli/src/cli/commands/functions/formatDeployResult.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { Logger } from "@base44-cli/logger"; -import { theme } from "@/cli/utils/theme.js"; -import type { SingleFunctionDeployResult } from "@/core/resources/function/deploy.js"; - -function formatDuration(ms: number): string { - return `${(ms / 1000).toFixed(1)}s`; -} - -export function formatDeployResult( - result: SingleFunctionDeployResult, - log: Logger, -): void { - const label = result.name.padEnd(25); - if (result.status === "deployed") { - const timing = result.durationMs - ? theme.styles.dim(` (${formatDuration(result.durationMs)})`) - : ""; - log.success(`${label} deployed${timing}`); - } else if (result.status === "unchanged") { - log.success(`${label} unchanged`); - } else { - log.error(`${label} error: ${result.error}`); - } -} diff --git a/packages/cli/src/cli/commands/project/deploy.ts b/packages/cli/src/cli/commands/project/deploy.ts index 3e281d673..9333763d6 100644 --- a/packages/cli/src/cli/commands/project/deploy.ts +++ b/packages/cli/src/cli/commands/project/deploy.ts @@ -5,11 +5,11 @@ import { filterPendingOAuth, promptOAuthFlows, } from "@/cli/commands/connectors/oauth-prompt.js"; -import { formatDeployResult } from "@/cli/commands/functions/formatDeployResult.js"; import { maybeBuildBeforeDeploy } from "@/cli/commands/project/site-build.js"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { Base44Command, + formatDeployResult, getConnectorsUrl, getDashboardUrl, theme, @@ -114,9 +114,11 @@ export async function deployAction( await maybeBuildBeforeDeploy(ctx, project, options.build); - // Deploy resources with per-function progress + // Deploy resources with per-function and per-actor progress let functionCompleted = 0; const functionTotal = functions.length; + let actorCompleted = 0; + const actorTotal = actors.length; const result = await deployAll(projectData, { onVisibilitySet: (level) => { @@ -134,6 +136,18 @@ export async function deployAction( functionCompleted++; formatDeployResult(r, log); }, + onActorStart: (names) => { + const label = names.length === 1 ? names[0] : `${names.length} actors`; + log.step( + theme.styles.dim( + `[${actorCompleted + 1}/${actorTotal}] Deploying ${label}...`, + ), + ); + }, + onActorResult: (r) => { + actorCompleted++; + formatDeployResult(r, log); + }, }); // Handle connector-specific post-deploy flows diff --git a/packages/cli/src/cli/program.ts b/packages/cli/src/cli/program.ts index dba69f0f1..c1c64c022 100644 --- a/packages/cli/src/cli/program.ts +++ b/packages/cli/src/cli/program.ts @@ -1,5 +1,5 @@ import { Command, Option } from "commander"; -import { getActorCommand } from "@/cli/commands/actor/index.js"; +import { getActorsCommand } from "@/cli/commands/actors/index.js"; import { getAgentSkillsCommand } from "@/cli/commands/agent-skills/index.js"; import { getAgentsCommand } from "@/cli/commands/agents/index.js"; import { getAuthCommand } from "@/cli/commands/auth/index.js"; @@ -96,8 +96,8 @@ export function createProgram(context: CLIContext): Command { // Register functions commands program.addCommand(getFunctionsCommand()); - // Register actor commands - program.addCommand(getActorCommand()); + // Register actors commands + program.addCommand(getActorsCommand()); // Register workflows commands program.addCommand(getWorkflowsCommand()); diff --git a/packages/cli/src/cli/utils/deploy-reporting.ts b/packages/cli/src/cli/utils/deploy-reporting.ts new file mode 100644 index 000000000..6f13518bc --- /dev/null +++ b/packages/cli/src/cli/utils/deploy-reporting.ts @@ -0,0 +1,39 @@ +import type { Logger } from "@base44-cli/logger"; +import { theme } from "@/cli/utils/theme.js"; +import type { SingleDeployResult } from "@/core/resources/types.js"; + +function formatDuration(ms: number): string { + return `${(ms / 1000).toFixed(1)}s`; +} + +export function formatDeployResult( + result: SingleDeployResult, + log: Logger, +): void { + const label = result.name.padEnd(25); + if (result.status === "deployed") { + const timing = result.durationMs + ? theme.styles.dim(` (${formatDuration(result.durationMs)})`) + : ""; + log.success(`${label} deployed${timing}`); + } else if (result.status === "unchanged") { + log.success(`${label} unchanged`); + } else { + log.error(`${label} error: ${result.error}`); + } +} + +export function buildDeploySummary( + results: SingleDeployResult[], + noun: "functions" | "actors", +): string { + const deployed = results.filter((r) => r.status === "deployed").length; + const unchanged = results.filter((r) => r.status === "unchanged").length; + const failed = results.filter((r) => r.status === "error").length; + + const parts: string[] = []; + if (deployed > 0) parts.push(`${deployed} deployed`); + if (unchanged > 0) parts.push(`${unchanged} unchanged`); + if (failed > 0) parts.push(`${failed} error${failed !== 1 ? "s" : ""}`); + return parts.join(", ") || `No ${noun} deployed`; +} diff --git a/packages/cli/src/cli/utils/index.ts b/packages/cli/src/cli/utils/index.ts index f8313a547..43253966d 100644 --- a/packages/cli/src/cli/utils/index.ts +++ b/packages/cli/src/cli/utils/index.ts @@ -3,7 +3,9 @@ export * from "./banner.js"; export * from "./command/index.js"; export * from "./confirm-push.js"; export * from "./datetime.js"; +export * from "./deploy-reporting.js"; export * from "./json.js"; +export * from "./parse-names.js"; export * from "./prompts.js"; export * from "./runTask.js"; export * from "./secret-input.js"; diff --git a/packages/cli/src/cli/commands/functions/parseNames.ts b/packages/cli/src/cli/utils/parse-names.ts similarity index 100% rename from packages/cli/src/cli/commands/functions/parseNames.ts rename to packages/cli/src/cli/utils/parse-names.ts diff --git a/packages/cli/src/core/config.ts b/packages/cli/src/core/config.ts index bca651fdd..6e2af15b1 100644 --- a/packages/cli/src/core/config.ts +++ b/packages/cli/src/core/config.ts @@ -26,16 +26,6 @@ export function getTypesOutputPath(projectRoot: string): string { return join(projectRoot, PROJECT_SUBDIR, TYPES_OUTPUT_SUBDIR, TYPES_FILENAME); } -/** - * Ambient declaration for the `base44:runtime/actors` virtual module. Kept in - * its own script-context file (no exports) so the `declare module` is an ambient - * declaration — in the module-scoped types.d.ts it would be a failed augmentation - * of a non-existent module and never resolve. - */ -export function getActorRuntimeTypesPath(projectRoot: string): string { - return join(projectRoot, PROJECT_SUBDIR, TYPES_OUTPUT_SUBDIR, "runtime.d.ts"); -} - export function getBase44ApiUrl(): string { return process.env.BASE44_API_URL || "https://app.base44.com"; } diff --git a/packages/cli/src/core/consts.ts b/packages/cli/src/core/consts.ts index ed326e2bf..abefd699f 100644 --- a/packages/cli/src/core/consts.ts +++ b/packages/cli/src/core/consts.ts @@ -6,12 +6,12 @@ export const CONFIG_FILE_EXTENSION_GLOB = "{json,jsonc}"; /** Glob for discovering function config files at any depth under functions dir. */ export const FUNCTION_CONFIG_GLOB = `**/function.${CONFIG_FILE_EXTENSION_GLOB}`; -/** Glob for zero-config function entry files (any depth). */ +/** Glob for zero-config function and actor entry files (any depth). */ export const ENTRY_FILE_GLOB = "**/entry.{js,ts}"; /** - * Glob for source files bundled into a backend function's deploy payload — - * used for both the function directory and the shared (`base44/shared/`) dir. + * Glob for source files bundled into a function's or actor's deploy payload — + * for functions it also covers the shared (`base44/shared/`) dir. */ export const BACKEND_FILE_GLOB = "**/*.{js,ts,json,jsonc}"; diff --git a/packages/cli/src/core/project/deploy.ts b/packages/cli/src/core/project/deploy.ts index 57b7ba0aa..b474dda42 100644 --- a/packages/cli/src/core/project/deploy.ts +++ b/packages/cli/src/core/project/deploy.ts @@ -3,7 +3,10 @@ import { hasWorkspaceApiKeyAuth } from "@/core/auth/config.js"; import { setAppVisibility } from "@/core/project/api.js"; import type { Visibility } from "@/core/project/schema.js"; import type { ProjectData } from "@/core/project/types.js"; -import { deployActorsSequentially } from "@/core/resources/actor/deploy.js"; +import { + deployActorsSequentially, + type SingleActorDeployResult, +} from "@/core/resources/actor/deploy.js"; import { agentResource } from "@/core/resources/agent/index.js"; import { agentSkillResource } from "@/core/resources/agent-skill/index.js"; import { authConfigResource } from "@/core/resources/auth-config/index.js"; @@ -75,11 +78,13 @@ interface DeployAllResult { interface DeployAllOptions { onFunctionStart?: (names: string[]) => void; onFunctionResult?: (result: SingleFunctionDeployResult) => void; + onActorStart?: (names: string[]) => void; + onActorResult?: (result: SingleActorDeployResult) => void; onVisibilitySet?: (visibility: Visibility) => void; } /** - * Deploys all project resources (entities, functions, agents, connectors, and site) to Base44. + * Deploys all project resources (entities, functions, actors, agents, connectors, and site) to Base44. * * @param projectData - The project configuration and resources to deploy * @param options - Optional progress callbacks for resource deployment @@ -109,7 +114,10 @@ export async function deployAll( onStart: options?.onFunctionStart, onResult: options?.onFunctionResult, }); - await deployActorsSequentially(actors); + await deployActorsSequentially(actors, { + onStart: options?.onActorStart, + onResult: options?.onActorResult, + }); await agentSkillResource.push(agentSkills); await agentResource.push(agents); await authConfigResource.push(authConfig); diff --git a/packages/cli/src/core/resources/actor/config.ts b/packages/cli/src/core/resources/actor/config.ts index 74628368e..c35271cc9 100644 --- a/packages/cli/src/core/resources/actor/config.ts +++ b/packages/cli/src/core/resources/actor/config.ts @@ -1,17 +1,17 @@ -import { basename, dirname, join, relative } from "node:path"; +import { basename, dirname, relative } from "node:path"; import { globby } from "globby"; -import { ENTRY_FILE_GLOB, ENTRY_IGNORE_DOT_PATHS } from "@/core/consts.js"; -import { InvalidInputError } from "@/core/errors.js"; -import type { - Actor, - ActorMessageSchema, -} from "@/core/resources/actor/schema.js"; -import { ActorSchemaFileSchema } from "@/core/resources/actor/schema.js"; -import { pathExists, readJsonFile } from "@/core/utils/fs.js"; +import { + BACKEND_FILE_GLOB, + ENTRY_FILE_GLOB, + ENTRY_IGNORE_DOT_PATHS, +} from "@/core/consts.js"; +import { ConfigInvalidError, InvalidInputError } from "@/core/errors.js"; +import type { Actor } from "@/core/resources/actor/schema.js"; +import { pathExists } from "@/core/utils/fs.js"; async function readActor(entryFile: string, actorsDir: string): Promise { const actorDir = dirname(entryFile); - const filePaths = await globby("**/*.ts", { + const filePaths = await globby(BACKEND_FILE_GLOB, { cwd: actorDir, absolute: true, }); @@ -30,29 +30,12 @@ async function readActor(entryFile: string, actorsDir: string): Promise { ); } - const entry = basename(entryFile); - - const schemaPath = join(actorDir, "schema.jsonc"); - let messageSchema: ActorMessageSchema | undefined; - if (await pathExists(schemaPath)) { - const parsed = await readJsonFile(schemaPath); - const result = ActorSchemaFileSchema.safeParse(parsed); - if (result.success) { - messageSchema = { - types: result.data.types as Record | undefined, - toClient: result.data.toClient as Record | undefined, - toServer: result.data.toServer as Record | undefined, - }; - } - } - return { name, - entry, + entry: basename(entryFile), entryPath: entryFile, filePaths, source: { type: "project" }, - messageSchema, }; } @@ -74,7 +57,7 @@ export async function readAllActors(actorsDir: string): Promise { const names = new Set(); for (const actor of actors) { if (names.has(actor.name)) { - throw new InvalidInputError( + throw new ConfigInvalidError( `Duplicate actor name "${actor.name}" in ${actorsDir}`, ); } diff --git a/packages/cli/src/core/resources/actor/deploy.ts b/packages/cli/src/core/resources/actor/deploy.ts index 0e02365c8..9ddf6ecf4 100644 --- a/packages/cli/src/core/resources/actor/deploy.ts +++ b/packages/cli/src/core/resources/actor/deploy.ts @@ -2,6 +2,7 @@ import { dirname, relative } from "node:path"; import { deploySingleActor } from "@/core/resources/actor/api.js"; import type { Actor } from "@/core/resources/actor/schema.js"; import type { FunctionFile } from "@/core/resources/function/schema.js"; +import type { SingleDeployResult } from "@/core/resources/types.js"; import { readTextFile } from "@/core/utils/fs.js"; async function loadActorCode( @@ -18,12 +19,7 @@ async function loadActorCode( return { name: actor.name, entry: actor.entry, files: resolvedFiles }; } -export interface SingleActorDeployResult { - name: string; - status: "deployed" | "unchanged" | "error"; - error?: string | null; - durationMs?: number; -} +export type SingleActorDeployResult = SingleDeployResult; async function deployOne(actor: Actor): Promise { const start = Date.now(); diff --git a/packages/cli/src/core/resources/actor/schema.ts b/packages/cli/src/core/resources/actor/schema.ts index 9dd490e65..3bf86decc 100644 --- a/packages/cli/src/core/resources/actor/schema.ts +++ b/packages/cli/src/core/resources/actor/schema.ts @@ -1,40 +1,17 @@ import { z } from "zod"; import { ResourceSourceSchema } from "@/core/resources/types.js"; -const ActorConfigSchema = z.object({ +const ActorSchema = z.object({ name: z.string().min(1), entry: z.string().min(1), -}); - -// An actor's schema.jsonc is a catalog of named messages: `toClient` (server → -// client) and `toServer` (client → server) each map a message name to its (type-less) -// object schema, and optional `types` holds shared shapes referenced via -// `#/types/`. See the type generator. -export const ActorSchemaFileSchema = z.object({ - types: z.record(z.string(), z.unknown()).optional(), - toClient: z.record(z.string(), z.unknown()).optional(), - toServer: z.record(z.string(), z.unknown()).optional(), -}); - -export const DeployActorResponseSchema = z.object({ - status: z.enum(["deployed", "unchanged"]), - handler_name: z.string().optional(), -}); - -const ActorSchema = ActorConfigSchema.extend({ entryPath: z.string().min(1), filePaths: z.array(z.string()).min(1), source: ResourceSourceSchema, - messageSchema: z.unknown().optional(), }); -export interface ActorMessageSchema { - types?: Record; - toClient?: Record; - toServer?: Record; -} +export const DeployActorResponseSchema = z.object({ + status: z.enum(["deployed", "unchanged"]), +}); -export type Actor = Omit, "messageSchema"> & { - messageSchema?: ActorMessageSchema; -}; +export type Actor = z.infer; export type DeployActorResponse = z.infer; diff --git a/packages/cli/src/core/resources/function/deploy.ts b/packages/cli/src/core/resources/function/deploy.ts index c0e3f66bf..fb0434f95 100644 --- a/packages/cli/src/core/resources/function/deploy.ts +++ b/packages/cli/src/core/resources/function/deploy.ts @@ -9,6 +9,7 @@ import type { FunctionFile, FunctionWithCode, } from "@/core/resources/function/schema.js"; +import type { SingleDeployResult } from "@/core/resources/types.js"; import { readTextFile } from "@/core/utils/fs.js"; async function loadFunctionCode( @@ -25,12 +26,7 @@ async function loadFunctionCode( return { ...fn, files: resolvedFiles }; } -export interface SingleFunctionDeployResult { - name: string; - status: "deployed" | "unchanged" | "error"; - error?: string | null; - durationMs?: number; -} +export type SingleFunctionDeployResult = SingleDeployResult; async function deployOne( fn: BackendFunction, diff --git a/packages/cli/src/core/resources/index.ts b/packages/cli/src/core/resources/index.ts index a8b80eaff..74bc62530 100644 --- a/packages/cli/src/core/resources/index.ts +++ b/packages/cli/src/core/resources/index.ts @@ -1,3 +1,4 @@ +export * from "./actor/index.js"; export * from "./agent/index.js"; export * from "./auth-config/index.js"; export * from "./connector/index.js"; diff --git a/packages/cli/src/core/resources/types.ts b/packages/cli/src/core/resources/types.ts index 25c4596af..a8ad75da7 100644 --- a/packages/cli/src/core/resources/types.ts +++ b/packages/cli/src/core/resources/types.ts @@ -10,6 +10,16 @@ export const ResourceSourceSchema = z.discriminatedUnion("type", [ }), ]); +/** + * Per-item outcome of a sequential deploy (functions, actors). + */ +export interface SingleDeployResult { + name: string; + status: "deployed" | "unchanged" | "error"; + error?: string | null; + durationMs?: number; +} + /** * Base interface for all project resources (entities, functions, etc.). * Resources are project-specific collections that can be loaded from the filesystem diff --git a/packages/cli/src/core/types/generator.ts b/packages/cli/src/core/types/generator.ts index 668322041..e3a5094bf 100644 --- a/packages/cli/src/core/types/generator.ts +++ b/packages/cli/src/core/types/generator.ts @@ -1,20 +1,14 @@ -import { join } from "node:path"; import { source, stripIndent } from "common-tags"; import type { JSONSchema4 } from "json-schema"; import { compile } from "json-schema-to-typescript"; -import { getActorRuntimeTypesPath, getTypesOutputPath } from "@/core/config.js"; +import { getTypesOutputPath } from "@/core/config.js"; import { TypeGenerationError } from "@/core/errors.js"; -import type { Actor } from "@/core/resources/actor/schema.js"; +import type { Actor } from "@/core/resources/actor/index.js"; import type { AgentConfig } from "@/core/resources/agent/index.js"; import type { ConnectorResource } from "@/core/resources/connector/index.js"; import type { Entity } from "@/core/resources/entity/index.js"; import type { BackendFunction } from "@/core/resources/function/index.js"; -import { - deleteFile, - pathExists, - readJsonFile, - writeFile, -} from "@/core/utils/fs.js"; +import { writeFile } from "@/core/utils/fs.js"; interface GenerateTypesInput { projectRoot: string; @@ -43,29 +37,6 @@ const EMPTY_TEMPLATE = stripIndent` } `; -const SDK_PACKAGE_NAMES = ["@base44/sdk", "@base44-preview/sdk"] as const; -type SdkPackageName = (typeof SDK_PACKAGE_NAMES)[number]; - -async function detectSdkPackageName( - projectRoot: string, -): Promise { - try { - const pkg = (await readJsonFile( - join(projectRoot, "package.json"), - )) as Record; - const deps = { - ...(pkg.dependencies as object), - ...(pkg.devDependencies as object), - }; - for (const name of SDK_PACKAGE_NAMES) { - if (name in deps) return name; - } - } catch { - // ignore - } - return "@base44/sdk"; -} - /** * Generate and write types.d.ts file. */ @@ -74,34 +45,10 @@ export async function generateTypesFile( ): Promise { const content = await generateContent(input); await writeFile(getTypesOutputPath(input.projectRoot), content); - - // The base44:runtime/actors virtual module must be an AMBIENT declaration, so - // it lives in its own script-context file. types.d.ts is a module (it has - // exports), where `declare module 'base44:runtime/actors'` is a failed - // augmentation of a non-existent module and the import never resolves. - const runtimePath = getActorRuntimeTypesPath(input.projectRoot); - if (input.actors.length) { - const sdkPackage = await detectSdkPackageName(input.projectRoot); - await writeFile(runtimePath, actorRuntimeDeclaration(sdkPackage)); - } else if (await pathExists(runtimePath)) { - await deleteFile(runtimePath); - } -} - -/** Ambient declaration that makes `base44:runtime/actors` resolve pre-deploy. */ -function actorRuntimeDeclaration(sdkPackage: SdkPackageName): string { - return `${HEADER}\n\n${source` - declare module 'base44:runtime/actors' { - export { Actor } from '${sdkPackage}'; - } - `}\n`; } -export async function generateContent( - input: GenerateTypesInput, -): Promise { +async function generateContent(input: GenerateTypesInput): Promise { const { entities, functions, agents, connectors, actors } = input; - const sdkPackage = await detectSdkPackageName(input.projectRoot); if ( !entities.length && @@ -113,10 +60,9 @@ export async function generateContent( return EMPTY_TEMPLATE; } - const [entityInterfaces, actorResults] = await Promise.all([ - Promise.all(entities.map((e) => compileEntity(e))), - Promise.all(actors.map((a) => compileActor(a))), - ]); + const entityInterfaces = await Promise.all( + entities.map((e) => compileEntity(e)), + ); // Build registry entries const registryEntries: [string, string[]][] = [ @@ -128,15 +74,6 @@ export async function generateContent( ["AgentNameRegistry", agents.map((a) => `"${a.name}": true;`)], ["ConnectorTypeRegistry", connectors.map((c) => `"${c.type}": true;`)], ["ActorNameRegistry", actors.map((a) => `"${a.name}": true;`)], - [ - "ActorRegistry", - actors - .filter((a) => a.messageSchema) - .map((a) => { - const idx = actors.indexOf(a); - return `"${a.name}": ${actorResults[idx].entry};`; - }), - ], ]; // Generate registries (only for non-empty entries) @@ -144,18 +81,11 @@ export async function generateContent( .filter(([, entries]) => entries.length > 0) .map(([name, entries]) => registry(name, entries)); - const actorInterfaces = actorResults.map((r) => r.decls).filter(Boolean); - - // NOTE: the `base44:runtime/actors` virtual module is declared in a separate - // ambient file (see generateTypesFile) — it must NOT go here, because this - // file is a module and the declaration would be a failed augmentation. return [ HEADER, - "export {};", // module context — ensures declare module augments rather than replaces the SDK package entityInterfaces.join("\n\n"), - actorInterfaces.join("\n\n"), source` - declare module '${sdkPackage}' { + declare module '@base44/sdk' { ${registries.join("\n\n")} } `, @@ -189,150 +119,6 @@ async function compileEntity(entity: Entity): Promise { } } -interface ActorCompileResult { - /** Top-level `export` declarations: one interface per message + shared types. */ - decls: string; - /** The registry value, e.g. `{ toClient: FooInit | FooTick; toServer: FooJoin }`. */ - entry: string; -} - -/** - * An actor's `schema.jsonc` is a *catalog* of named messages: - * { types?: { Pt, Snake, … }, toClient: { init, tick, … }, toServer: { join, … } } - * Each message is a flat object schema (no `type` field — the generator injects - * `type: ""` as the discriminant). We compile the whole catalog in ONE pass - * so json-schema-to-typescript emits a named interface per message plus the shared - * types, then assemble the toClient/toServer unions from those names. This avoids - * scraping the compiler output (the old regex broke on unions and `$defs`), and - * because every message is a single flat object, the fragile multi-declaration - * case never arises. - */ -async function compileActor(actor: Actor): Promise { - const { messageSchema } = actor; - if (!messageSchema) { - return { decls: "", entry: "{ toClient: unknown; toServer: unknown }" }; - } - - const prefix = toPascalCase(actor.name); - const types = (messageSchema.types ?? {}) as Record; - const toClient = (messageSchema.toClient ?? {}) as Record< - string, - JSONSchema4 - >; - const toServer = (messageSchema.toServer ?? {}) as Record< - string, - JSONSchema4 - >; - - // Shared types are prefixed with the actor name so names (Pt, Snake, …) can't - // collide across actors or with entity interfaces. Messages additionally carry - // their direction, since the same name (e.g. "message") may appear in both - // directions. - const typeName = (key: string) => `${prefix}${toPascalCase(key)}`; - const msgName = (dir: "ToClient" | "ToServer", key: string) => - `${prefix}${dir}${toPascalCase(key)}`; - - const defs: Record = {}; - const add = (name: string, schema: JSONSchema4) => { - if (name in defs) { - throw new TypeGenerationError( - `Duplicate generated type "${name}" in actor "${actor.name}" — a shared type and a message resolve to the same name.`, - actor.name, - ); - } - defs[name] = { ...schema, title: name }; - }; - - // Shared types are emitted as-is (author writes `type: "object"` etc.); their - // author-facing `#/types/X` refs are rewritten to the prefixed `#/$defs/` names. - for (const [key, schema] of Object.entries(types)) { - add(typeName(key), rewriteTypeRefs(schema, typeName) as JSONSchema4); - } - - // Each message → one flat object (a full JSON Schema, like an entity) with the - // `type` discriminant injected from its key. - const compileMessages = ( - msgs: Record, - dir: "ToClient" | "ToServer", - ): string[] => - Object.entries(msgs).map(([key, schema]) => { - const name = msgName(dir, key); - const rewritten = rewriteTypeRefs(schema, typeName) as JSONSchema4; - add(name, { - type: "object", - ...rewritten, - properties: { type: { const: key }, ...(rewritten.properties ?? {}) }, - required: [ - "type", - ...((rewritten.required as string[] | undefined) ?? []), - ], - additionalProperties: false, - }); - return name; - }); - - const toClientNames = compileMessages(toClient, "ToClient"); - const toServerNames = compileMessages(toServer, "ToServer"); - - // Root union over every message keeps all defs reachable so the compiler emits - // them; we keep its whole output verbatim (no scraping). - const allNames = [...toClientNames, ...toServerNames]; - const rootName = `${prefix}Message`; - const rootSchema = { - title: rootName, - $defs: defs, - oneOf: allNames.map((n) => ({ $ref: `#/$defs/${n}` })), - } as unknown as JSONSchema4; - - let decls = ""; - try { - decls = ( - await compile(rootSchema, rootName, { - bannerComment: "", - additionalProperties: false, - strictIndexSignatures: true, - }) - ).trim(); - } catch (error) { - throw new TypeGenerationError( - `Failed to generate types for actor "${actor.name}"`, - actor.name, - error, - ); - } - - const union = (names: string[]) => - names.length ? names.join(" | ") : "never"; - return { - decls, - entry: `{ toClient: ${union(toClientNames)}; toServer: ${union(toServerNames)} }`, - }; -} - -/** Rewrite author-facing `#/types/X` refs to the prefixed `#/$defs/`. */ -function rewriteTypeRefs( - node: unknown, - defName: (key: string) => string, -): unknown { - if (Array.isArray(node)) { - return node.map((n) => rewriteTypeRefs(n, defName)); - } - if (node && typeof node === "object") { - const out: Record = {}; - for (const [key, value] of Object.entries(node)) { - const match = - key === "$ref" && typeof value === "string" - ? value.match(/^#\/types\/(.+)$/) - : null; - out[key] = match - ? `#/$defs/${defName(match[1])}` - : rewriteTypeRefs(value, defName); - } - return out; - } - return node; -} - function registry(name: string, entries: string[]): string { return source` interface ${name} { diff --git a/packages/cli/src/core/types/update-project.ts b/packages/cli/src/core/types/update-project.ts index c61d429ff..1d88375c3 100644 --- a/packages/cli/src/core/types/update-project.ts +++ b/packages/cli/src/core/types/update-project.ts @@ -3,15 +3,11 @@ import { PROJECT_SUBDIR, TYPES_OUTPUT_SUBDIR } from "@/core/consts.js"; import { pathExists, readJsonFile, writeJsonFile } from "@/core/utils/fs.js"; const TYPES_INCLUDE_PATH = `${PROJECT_SUBDIR}/${TYPES_OUTPUT_SUBDIR}/*.d.ts`; -// Actor sources must be in the TS program so the ambient base44:runtime/actors -// declaration (in base44/.types) applies to them; otherwise entry.ts still -// reports "Cannot find module 'base44:runtime/actors'". -const ACTORS_INCLUDE_PATH = `${PROJECT_SUBDIR}/actors/**/*.ts`; /** * Update project configuration files after generating types. * Currently handles: - * - tsconfig.json: adds base44/.types and base44/actors to the include array + * - tsconfig.json: adds base44/.types to the include array * * @returns true if tsconfig.json was updated, false otherwise */ @@ -34,18 +30,15 @@ export async function updateProjectConfig( tsconfig.include = []; } - let changed = false; - for (const path of [TYPES_INCLUDE_PATH, ACTORS_INCLUDE_PATH]) { - if (!tsconfig.include.includes(path)) { - tsconfig.include.push(path); - changed = true; - } + // Check if already included + if (tsconfig.include.includes(TYPES_INCLUDE_PATH)) { + return false; } - if (changed) { - await writeJsonFile(tsconfigPath, tsconfig); - } - return changed; + // Add to include array + tsconfig.include.push(TYPES_INCLUDE_PATH); + await writeJsonFile(tsconfigPath, tsconfig); + return true; } catch { // If we can't parse or update, silently fail and let user configure manually return false; diff --git a/packages/cli/tests/cli/actors_deploy.spec.ts b/packages/cli/tests/cli/actors_deploy.spec.ts new file mode 100644 index 000000000..de2f35047 --- /dev/null +++ b/packages/cli/tests/cli/actors_deploy.spec.ts @@ -0,0 +1,90 @@ +import { describe, it } from "vitest"; +import { fixture, setupCLITests } from "./testkit/index.js"; + +describe("actors deploy command", () => { + const t = setupCLITests(); + + it("warns when no actors found in project", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + + const result = await t.run("actors", "deploy"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("No actors found"); + }); + + it("fails when not in a project directory", async () => { + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + + const result = await t.run("actors", "deploy"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("No Base44 app ID found"); + }); + + it("deploys actors successfully", async () => { + await t.givenLoggedInWithProject(fixture("with-actors")); + t.api.mockSingleActorDeploy({ status: "deployed" }); + + const result = await t.run("actors", "deploy"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Deploying ChatRoom"); + t.expectResult(result).toContain("1 deployed"); + }); + + it("reports unchanged actor", async () => { + await t.givenLoggedInWithProject(fixture("with-actors")); + t.api.mockSingleActorDeploy({ status: "unchanged" }); + + const result = await t.run("actors", "deploy"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("unchanged"); + t.expectResult(result).toContain("1 unchanged"); + }); + + it("deploys specific actor by name", async () => { + await t.givenLoggedInWithProject(fixture("with-actors")); + t.api.mockSingleActorDeploy({ status: "deployed" }); + + const result = await t.run("actors", "deploy", "ChatRoom"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Deploying ChatRoom"); + t.expectResult(result).toContain("1 deployed"); + }); + + it("accepts comma-separated actor names", async () => { + await t.givenLoggedInWithProject(fixture("with-actors")); + t.api.mockSingleActorDeploy({ status: "deployed" }); + + const result = await t.run("actors", "deploy", "ChatRoom,"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("1 deployed"); + }); + + it("fails when actor name not found in project", async () => { + await t.givenLoggedInWithProject(fixture("with-actors")); + + const result = await t.run("actors", "deploy", "nonexistent"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("not found in project"); + }); + + it("reports error when API fails for an actor", async () => { + await t.givenLoggedInWithProject(fixture("with-actors")); + t.api.mockSingleActorDeployError({ + status: 400, + body: { error: "Invalid actor code" }, + }); + + const result = await t.run("actors", "deploy"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("error"); + t.expectResult(result).toContain("1 error"); + }); +}); diff --git a/packages/cli/tests/cli/deploy.spec.ts b/packages/cli/tests/cli/deploy.spec.ts index c32ed161f..1f782c451 100644 --- a/packages/cli/tests/cli/deploy.spec.ts +++ b/packages/cli/tests/cli/deploy.spec.ts @@ -96,6 +96,22 @@ describe("deploy command (unified)", () => { t.expectResult(result).toContain("App deployed successfully"); }); + it("deploys actors with unified deploy", async () => { + await t.givenLoggedInWithProject(fixture("with-actors")); + t.api.mockSingleActorDeploy({ status: "deployed" }); + t.api.mockAgentsPush({ created: [], updated: [], deleted: [] }); + t.api.mockConnectorsList({ integrations: [] }); + t.api.mockStripeStatus({ stripe_mode: null }); + + const result = await t.run("deploy", "-y"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("1 actor"); + t.expectResult(result).toContain("Deploying ChatRoom"); + t.expectResult(result).toContain("deployed"); + t.expectResult(result).toContain("App deployed successfully"); + }); + it("deploys entities successfully with --yes flag", async () => { await t.givenLoggedInWithProject(fixture("with-entities")); t.api.mockEntitiesPush({ diff --git a/packages/cli/tests/cli/testkit/TestAPIServer.ts b/packages/cli/tests/cli/testkit/TestAPIServer.ts index 17d7f6ac6..62eae15dd 100644 --- a/packages/cli/tests/cli/testkit/TestAPIServer.ts +++ b/packages/cli/tests/cli/testkit/TestAPIServer.ts @@ -115,6 +115,10 @@ interface SingleFunctionDeployResponse { status: "deployed" | "unchanged"; } +interface SingleActorDeployResponse { + status: "deployed" | "unchanged"; +} + interface AutomationBase { name: string; description?: string | null; @@ -527,6 +531,15 @@ export class TestAPIServer { ); } + /** Mock PUT /api/apps/{appId}/actors/{name} - Deploy single actor */ + mockSingleActorDeploy(response: SingleActorDeployResponse): this { + return this.addRoute( + "PUT", + `/api/apps/${this.appId}/actors/:name`, + response, + ); + } + mockSiteDeploy(response: SiteDeployResponse): this { return this.addRoute( "POST", @@ -923,6 +936,15 @@ export class TestAPIServer { ); } + /** Mock single actor deploy to return an error */ + mockSingleActorDeployError(error: ErrorResponse): this { + return this.addErrorRoute( + "PUT", + `/api/apps/${this.appId}/actors/:name`, + error, + ); + } + /** Mock single function delete to return an error */ mockSingleFunctionDeleteError(error: ErrorResponse): this { return this.addErrorRoute( diff --git a/packages/cli/tests/cli/types_generate.spec.ts b/packages/cli/tests/cli/types_generate.spec.ts index b6bd77f3f..7338d42a3 100644 --- a/packages/cli/tests/cli/types_generate.spec.ts +++ b/packages/cli/tests/cli/types_generate.spec.ts @@ -49,10 +49,6 @@ describe("types generate command", () => { // Contains the ActorNameRegistry with the actor name expect(typesContent).toContain("ActorNameRegistry"); expect(typesContent).toContain(`"ChatRoom": true`); - - // Contains the ActorRegistry with typed inbound/outbound (from schema.jsonc) - expect(typesContent).toContain("ActorRegistry"); - expect(typesContent).toContain(`"ChatRoom"`); }); it("updates tsconfig.json to include types path", async () => { diff --git a/packages/cli/tests/core/types-actor.spec.ts b/packages/cli/tests/core/types-actor.spec.ts deleted file mode 100644 index 6d3231f00..000000000 --- a/packages/cli/tests/core/types-actor.spec.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { mkdtemp, readFile, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { getActorRuntimeTypesPath, getTypesOutputPath } from "@/core/config.js"; -import type { Actor } from "@/core/resources/actor/schema.js"; -import { generateContent, generateTypesFile } from "@/core/types/generator.js"; - -const EMPTY = { - projectRoot: "/tmp/does-not-matter", // only read for package.json detect; falls back to @base44/sdk - entities: [], - functions: [], - agents: [], - connectors: [], -}; - -function actor(messageSchema: Actor["messageSchema"]): Actor { - return { - name: "GameRoom", - entry: "entry.ts", - entryPath: "base44/actors/GameRoom/entry.ts", - filePaths: ["base44/actors/GameRoom/entry.ts"], - source: { type: "project" }, - messageSchema, - }; -} - -describe("actor type generation", () => { - it("compiles a named-message catalog into a discriminated union with shared types", async () => { - const out = await generateContent({ - ...EMPTY, - actors: [ - actor({ - types: { - Pt: { - type: "object", - properties: { x: { type: "number" }, y: { type: "number" } }, - required: ["x", "y"], - additionalProperties: false, - }, - }, - toClient: { - init: { - properties: { - food: { type: "array", items: { $ref: "#/types/Pt" } }, - }, - required: ["food"], - }, - died: { - properties: { id: { type: "string" }, score: { type: "number" } }, - required: ["id", "score"], - }, - }, - toServer: { - dir: { - properties: { angle: { type: "number" } }, - required: ["angle"], - }, - }, - }), - ], - }); - - // `type` discriminant is injected from the message key (author omits it). - expect(out).toContain('type: "init"'); - expect(out).toContain('type: "died"'); - expect(out).toContain('type: "dir"'); - // Shared type is emitted once, prefixed with the handler name (collision-safe), - // and referenced by name — not re-inlined. - expect(out).toContain("export interface GameRoomPt"); - expect(out).toContain("food: GameRoomPt[]"); - // Message interfaces carry their direction (so the same name can appear in both - // directions); the registry composes the unions from them. - expect(out).toContain( - '"GameRoom": { toClient: GameRoomToClientInit | GameRoomToClientDied; toServer: GameRoomToServerDir }', - ); - // The base44:runtime/actors virtual module is emitted into a SEPARATE ambient - // file (see the next test), never into this module-scoped output — here it - // would be a failed augmentation and the import would not resolve. - expect(out).not.toContain("base44:runtime/actors"); - // Output is valid TS: no `export interface` spliced inside a type literal - // (the failure mode of the old regex-based extraction). - expect(out).not.toMatch(/\{[^}]*export interface/); - }); - - it("emits base44:runtime/actors as an ambient .d.ts (not the module-scoped types.d.ts)", async () => { - const root = await mkdtemp(join(tmpdir(), "b44-types-")); - try { - await generateTypesFile({ - ...EMPTY, - projectRoot: root, - actors: [actor(undefined)], - }); - const runtime = await readFile(getActorRuntimeTypesPath(root), "utf8"); - const types = await readFile(getTypesOutputPath(root), "utf8"); - - // The ambient module lives in its own script-context file... - expect(runtime).toContain("declare module 'base44:runtime/actors'"); - expect(runtime).toContain("export { Actor } from '@base44/sdk'"); - // ...with no top-level export, so it stays an ambient declaration. - expect(runtime).not.toMatch(/^export \{\};/m); - // ...and it must NOT appear in the module-scoped types.d.ts. - expect(types).not.toContain("base44:runtime/actors"); - } finally { - await rm(root, { recursive: true, force: true }); - } - }); - - it("throws on a name collision instead of silently clobbering", async () => { - await expect( - generateContent({ - ...EMPTY, - actors: [ - actor({ - // Both keys PascalCase to the same GameRoomToClientUserJoined. - toClient: { - "user-joined": { properties: { a: { type: "string" } } }, - userJoined: { properties: { b: { type: "string" } } }, - }, - toServer: {}, - }), - ], - }), - ).rejects.toThrow(/Duplicate generated type "GameRoomToClientUserJoined"/); - }); -}); diff --git a/packages/cli/tests/fixtures/with-actors/base44/.app.jsonc b/packages/cli/tests/fixtures/with-actors/base44/.app.jsonc new file mode 100644 index 000000000..e1fbc58f2 --- /dev/null +++ b/packages/cli/tests/fixtures/with-actors/base44/.app.jsonc @@ -0,0 +1,4 @@ +// Base44 App Configuration +{ + "id": "test-app-id" +} diff --git a/packages/cli/tests/fixtures/with-actors/base44/actors/ChatRoom/entry.ts b/packages/cli/tests/fixtures/with-actors/base44/actors/ChatRoom/entry.ts new file mode 100644 index 000000000..d71c001ae --- /dev/null +++ b/packages/cli/tests/fixtures/with-actors/base44/actors/ChatRoom/entry.ts @@ -0,0 +1,11 @@ +import { Actor, type Conn } from "@base44/sdk"; +import { formatMessage } from "./helper.js"; + +export default class ChatRoom extends Actor { + handleConnect(_conn: Conn) {} + handleMessage(conn: Conn, msg: unknown) { + conn.send(formatMessage(msg)); + } + handleTick() {} + handleClose(_conn: Conn) {} +} diff --git a/packages/cli/tests/fixtures/with-actors/base44/actors/ChatRoom/helper.ts b/packages/cli/tests/fixtures/with-actors/base44/actors/ChatRoom/helper.ts new file mode 100644 index 000000000..aa63324fe --- /dev/null +++ b/packages/cli/tests/fixtures/with-actors/base44/actors/ChatRoom/helper.ts @@ -0,0 +1,3 @@ +export function formatMessage(msg: unknown): string { + return JSON.stringify(msg); +} diff --git a/packages/cli/tests/fixtures/with-actors/base44/config.jsonc b/packages/cli/tests/fixtures/with-actors/base44/config.jsonc new file mode 100644 index 000000000..701ea3eef --- /dev/null +++ b/packages/cli/tests/fixtures/with-actors/base44/config.jsonc @@ -0,0 +1,3 @@ +{ + "name": "Actors Test Project" +} diff --git a/packages/cli/tests/fixtures/with-types-resources/base44/actors/ChatRoom/schema.jsonc b/packages/cli/tests/fixtures/with-types-resources/base44/actors/ChatRoom/schema.jsonc deleted file mode 100644 index 4696dd169..000000000 --- a/packages/cli/tests/fixtures/with-types-resources/base44/actors/ChatRoom/schema.jsonc +++ /dev/null @@ -1,28 +0,0 @@ -{ - // Message catalog: each entry is a full JSON Schema (like an entity), keyed by - // message name. The generator injects `type: ""` as the discriminant. - "toClient": { - "joined": { - "type": "object", - "properties": { "userId": { "type": "string" } }, - "required": ["userId"] - }, - "left": { - "type": "object", - "properties": { "userId": { "type": "string" } }, - "required": ["userId"] - }, - "message": { - "type": "object", - "properties": { "from": { "type": "string" }, "text": { "type": "string" } }, - "required": ["from", "text"] - } - }, - "toServer": { - "message": { - "type": "object", - "properties": { "text": { "type": "string" } }, - "required": ["text"] - } - } -} From 04d7f70c8d8e8bf7e97dc9dee7ce334f5c815484 Mon Sep 17 00:00:00 2001 From: talge-a11y Date: Mon, 17 Aug 2026 14:19:16 +0300 Subject: [PATCH 22/22] minor adjustments --- CHANGELOG.md | 2 +- docs/error-handling.md | 2 + docs/resources.md | 27 ++++-- docs/testing.md | 8 ++ packages/cli/README.md | 1 + .../cli/src/cli/commands/actors/delete.ts | 61 ++++++++++++ .../cli/src/cli/commands/actors/deploy.ts | 8 +- packages/cli/src/cli/commands/actors/index.ts | 4 +- .../cli/src/cli/commands/functions/deploy.ts | 4 +- .../cli/src/cli/commands/project/deploy.ts | 2 +- .../src/cli/utils/command/Base44Command.ts | 22 ++++- packages/cli/src/core/errors.ts | 35 +++++++ packages/cli/src/core/project/config.ts | 32 +++++++ packages/cli/src/core/project/deploy.ts | 8 +- packages/cli/src/core/resources/actor/api.ts | 15 +++ .../cli/src/core/resources/actor/config.ts | 84 +++++++++++++++-- .../cli/src/core/resources/actor/deploy.ts | 1 + .../cli/src/core/resources/actor/resource.ts | 8 +- .../cli/src/core/resources/function/deploy.ts | 1 + .../src/core/resources/function/resource.ts | 8 +- packages/cli/src/core/resources/types.ts | 19 ++++ packages/cli/tests/cli/actors_delete.spec.ts | 81 ++++++++++++++++ packages/cli/tests/cli/actors_deploy.spec.ts | 41 +++++++- packages/cli/tests/cli/deploy.spec.ts | 36 +++++++ .../cli/tests/cli/functions_deploy.spec.ts | 26 ++++- .../cli/tests/cli/testkit/TestAPIServer.ts | 49 +++++++++- packages/cli/tests/core/actor-config.spec.ts | 94 +++++++++++++++++++ packages/cli/tests/core/errors.spec.ts | 17 ++++ packages/cli/tests/core/project.spec.ts | 10 ++ .../actor-discovery-entry-at-root/entry.js | 1 + .../actor-discovery/BoardRoom/entry.ts | 7 ++ .../actor-discovery/BoardRoom/lib/helper.ts | 3 + .../actor-discovery/Chat.Room/entry.ts | 3 + .../base44/.app.jsonc | 4 + .../base44/actors/ChatRoom/entry.ts | 1 + .../base44/config.jsonc | 3 + .../base44/functions/ChatRoom/entry.ts | 3 + .../actor-invalid-charset/chat-room/entry.js | 1 + .../BoardRoom/entry.ts | 1 + .../BoardRoom/lib/entry.ts | 5 + .../actor-invalid-nested/games/Arena/entry.js | 1 + .../actor-invalid-reserved/class/entry.js | 1 + .../base44/actors/Duplicate/entry.js | 1 + .../base44/actors/Duplicate/entry.ts | 1 + .../base44/actors/ChatRoom/entry.ts | 12 ++- .../base44/actors/ChatRoom/entry.ts | 10 +- 46 files changed, 719 insertions(+), 45 deletions(-) create mode 100644 packages/cli/src/cli/commands/actors/delete.ts create mode 100644 packages/cli/tests/cli/actors_delete.spec.ts create mode 100644 packages/cli/tests/core/actor-config.spec.ts create mode 100644 packages/cli/tests/fixtures/actor-discovery-entry-at-root/entry.js create mode 100644 packages/cli/tests/fixtures/actor-discovery/BoardRoom/entry.ts create mode 100644 packages/cli/tests/fixtures/actor-discovery/BoardRoom/lib/helper.ts create mode 100644 packages/cli/tests/fixtures/actor-discovery/Chat.Room/entry.ts create mode 100644 packages/cli/tests/fixtures/actor-function-name-collision/base44/.app.jsonc create mode 100644 packages/cli/tests/fixtures/actor-function-name-collision/base44/actors/ChatRoom/entry.ts create mode 100644 packages/cli/tests/fixtures/actor-function-name-collision/base44/config.jsonc create mode 100644 packages/cli/tests/fixtures/actor-function-name-collision/base44/functions/ChatRoom/entry.ts create mode 100644 packages/cli/tests/fixtures/actor-invalid-charset/chat-room/entry.js create mode 100644 packages/cli/tests/fixtures/actor-invalid-helper-entry/BoardRoom/entry.ts create mode 100644 packages/cli/tests/fixtures/actor-invalid-helper-entry/BoardRoom/lib/entry.ts create mode 100644 packages/cli/tests/fixtures/actor-invalid-nested/games/Arena/entry.js create mode 100644 packages/cli/tests/fixtures/actor-invalid-reserved/class/entry.js create mode 100644 packages/cli/tests/fixtures/duplicate-actor-names/base44/actors/Duplicate/entry.js create mode 100644 packages/cli/tests/fixtures/duplicate-actor-names/base44/actors/Duplicate/entry.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 98d78c695..9dcd97d61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- Actors (realtime handlers): deploy from `base44/actors/` via `base44 actors deploy`, included in unified `base44 deploy`; `base44 types generate` emits `ActorNameRegistry`. +- Actors (realtime handlers): deploy from `base44/actors/` via `base44 actors deploy`, remove with `base44 actors delete`, included in unified `base44 deploy`; `base44 types generate` emits `ActorNameRegistry`. Actor names are validated locally against the server's rule (a JavaScript identifier — no nesting, no dots), and a name shared with a backend function is rejected up front. - App visibility: `base44 visibility ` sets it on the server directly (accepts `--app-id` to target any app). Also configurable via `"visibility"` in `config.jsonc`, which `base44 deploy` applies. New projects scaffold `"visibility": "public"`. - `base44 build` runs the site's `buildCommand` with `VITE_BASE44_APP_ID` injected, so built bundles always carry the linked app's id. - `base44 deploy` (and `base44 site deploy`) can now build first: interactive runs ask, and `--build` / `--no-build` pre-answer the prompt. diff --git a/docs/error-handling.md b/docs/error-handling.md index 6792a6c5f..beff7a8a3 100644 --- a/docs/error-handling.md +++ b/docs/error-handling.md @@ -19,6 +19,7 @@ CLIError (abstract base class) │ └── SystemError (something broke - needs investigation) ├── ApiError # HTTP/network failures + ├── ResourceDeployError # One or more sequential deploy items failed ├── FileNotFoundError # File doesn't exist ├── FileReadError # Can't read file └── InternalError # Unexpected errors @@ -110,6 +111,7 @@ See [api-patterns.md](api-patterns.md) for the full `ApiError.fromHttpError()` p | `SCHEMA_INVALID` | `SchemaValidationError` | Zod validation failed | | `INVALID_INPUT` | `InvalidInputError` | User provided invalid input | | `API_ERROR` | `ApiError` | API request failed | +| `RESOURCE_DEPLOY_FAILED` | `ResourceDeployError` | One or more functions or actors failed to deploy | | `FILE_NOT_FOUND` | `FileNotFoundError` | File doesn't exist | | `FILE_READ_ERROR` | `FileReadError` | Can't read/write file | | `INTERNAL_ERROR` | `InternalError` | Unexpected error | diff --git a/docs/resources.md b/docs/resources.md index 201f4504c..adf51dc01 100644 --- a/docs/resources.md +++ b/docs/resources.md @@ -87,15 +87,25 @@ Entry files may also import `secrets` and `waitUntil` from `base44:runtime`. Loc ## Actors (project layout) -Actors are stateful realtime handlers, read from the project's actors directory (`base44/actors/`, or `actorsDir` in `config.jsonc`). Discovery is zero-config only: a folder containing `entry.ts` (or `entry.js`) is an actor, and its name is the path from the actors root (e.g. `actors/ChatRoom/entry.ts` → name `ChatRoom`; nesting is allowed). All `**/*.{js,ts,json,jsonc}` files under that folder are included in the deploy payload, sent via `PUT /api/apps/{app_id}/actors/{name}`. The entry file must default-export the actor class — the deploy bundler imports the default export. +Actors are stateful realtime handlers, read from the project's actors directory (`base44/actors/`, or `actorsDir` in `config.jsonc`). Discovery is zero-config only: a folder containing `entry.ts` (or `entry.js`) is an actor, and the folder name is the actor name (`actors/ChatRoom/entry.ts` → `ChatRoom`). All `**/*.{js,ts,json,jsonc}` files under that folder are included in the deploy payload, sent via `PUT /api/apps/{app_id}/actors/{name}`. The entry file must default-export the actor class — the deploy bundler imports the default export. -Deliberate gaps (vs functions): no `base44/shared/` inclusion, no `--force` prune, no plugin actors, and no local `base44 dev` runtime. Authoring guidance (scaffolding, message typing, editor setup for the `base44:runtime/actors` virtual module) lives in the realtime skill, not the CLI. Type generation only emits `ActorNameRegistry` (actor names) into `types.d.ts`. +**Naming is enforced locally, mirroring the server.** An actor name becomes a Durable Object class and the WebSocket connect handler, so it must be a plain ASCII JavaScript identifier: `[A-Za-z_][A-Za-z0-9_]*`, max 128 characters, not a JS reserved word, PascalCase by convention. Two consequences differ from functions: + +- **Actors cannot be nested.** `actors/games/Arena/entry.ts` is an error, not an actor named `games/Arena`. Since every `entry.{js,ts}` under the actors root is an entry file at any depth, this is also what a helper accidentally named `entry.ts` reports — the error hints at both causes. +- **Folders with a dot in the name are skipped**, using the same `ENTRY_IGNORE_DOT_PATHS` exclusion as functions. A dotted name can never be valid, so `actors/ChatRoom.bak/` is treated as scratch rather than a deploy that would 422. + +Both checks run in `readAllActors` (before any upload), and `readProjectConfig` additionally rejects a name shared with a backend function — actors deploy onto the same server-side namespace. + +Deliberate gaps (vs functions): no `base44/shared/` inclusion, no `--force` prune, no `list`/`pull`, no plugin actors, and no local `base44 dev` runtime. Authoring guidance (scaffolding, message typing, editor setup for the `base44:runtime/actors` virtual module) lives in the realtime skill, not the CLI. Type generation only emits `ActorNameRegistry` (actor names) into `types.d.ts`. ```bash -base44 actors deploy # Deploy all actors -base44 actors deploy ChatRoom # Deploy specific actors by name +base44 actors deploy # Deploy all actors +base44 actors deploy ChatRoom # Deploy specific actors by name +base44 actors delete ChatRoom # Tear down a deployed actor ``` +`actors delete` calls `DELETE /api/apps/{app_id}/actors/{name}`, which destroys the published script — it is not a local operation and does not need the actor to still exist on disk. A 404 is reported as "not found" rather than an error, so re-running it is safe. + ## Agent skills Agent skills are app-scoped instruction snippets shared across the app's agents. Unlike other resources they are stored as one markdown file per skill under the agent-skills directory (`base44/agent-skills/`, or `agentSkillsDir` in `config.jsonc`): the filename (without `.md`) is the skill name, the frontmatter `description` is the summary, and the body is the instruction text. Agents reference skills by name via `selected_skill_names`; `selected_workspace_skill_ids` (org-shared workspace skills) is not managed here and is passed through pull/push/deploy untouched. @@ -146,12 +156,15 @@ const { appUrl } = await deployAll(projectData); What it deploys (in order): 1. Entities (via `entityResource.push()`) -2. Functions (via `functionResource.push()`) +2. Functions (via `deployFunctionsSequentially()`) 3. Actors (via `deployActorsSequentially()`) 4. Agent skills (via `agentSkillResource.push()`) 5. Agents (via `agentResource.push()`) -6. Connectors (via `pushConnectors()`) -- may return OAuth redirect URLs -7. Site (if `site.outputDirectory` is configured) — the legacy tar.gz upload. The env-gated deployments-API lane is reachable only from `base44 site deploy`, not from here (see [deployments.md](deployments.md)). +6. Auth config (via `authConfigResource.push()`) +7. Connectors (via `pushConnectors()`) -- may return OAuth redirect URLs +8. Site (if `site.outputDirectory` is configured) — the legacy tar.gz upload. The env-gated deployments-API lane is reachable only from `base44 site deploy`, not from here (see [deployments.md](deployments.md)). + +Functions and actors deploy sequentially within their resource batch. If any item fails, the command reports every failed item as a structured `ResourceDeployError`, exits non-zero, and does not continue to later resource types. ```bash base44 deploy # With confirmation prompt diff --git a/docs/testing.md b/docs/testing.md index 77b4e449a..e183ddd3f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -215,6 +215,14 @@ t.api.mockFunctionsPushError({ status: 400, body: { error: "Invalid" } }); ```typescript t.api.mockSingleActorDeploy({ status: "deployed" }); t.api.mockSingleActorDeployError({ status: 400, body: { error: "Invalid" } }); +t.api.mockSingleActorDelete(); +t.api.mockSingleActorDeleteError({ status: 404, body: { error: "Not found" } }); + +// Successful requests are captured for payload assertions. +expect(t.api.actorDeployRequests[0]).toMatchObject({ + name: "ChatRoom", + entry: "entry.ts", +}); ``` ### Agent Mocks diff --git a/packages/cli/README.md b/packages/cli/README.md index 3f959f564..80ff35cb7 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -52,6 +52,7 @@ The CLI will guide you through project setup. For step-by-step tutorials, see th | [`logout`](https://docs.base44.com/developers/references/cli/commands/logout) | Sign out and clear stored credentials | | [`whoami`](https://docs.base44.com/developers/references/cli/commands/whoami) | Display the current authenticated user | | `actors deploy` | Deploy local actors to Base44 | +| `actors delete` | Delete deployed actors from Base44 | | [`agents pull`](https://docs.base44.com/developers/references/cli/commands/agents-pull) | Pull agents from Base44 to local files | | [`agents push`](https://docs.base44.com/developers/references/cli/commands/agents-push) | Push local agents to Base44 | | [`connectors initiate`](https://docs.base44.com/developers/references/cli/commands/connectors-initiate) | Initialize a connector on an app and start its OAuth flow | diff --git a/packages/cli/src/cli/commands/actors/delete.ts b/packages/cli/src/cli/commands/actors/delete.ts new file mode 100644 index 000000000..c1154211a --- /dev/null +++ b/packages/cli/src/cli/commands/actors/delete.ts @@ -0,0 +1,61 @@ +import type { Command } from "commander"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { Base44Command, parseNames } from "@/cli/utils/index.js"; +import { ApiError } from "@/core/errors.js"; +import { deleteSingleActor } from "@/core/resources/actor/api.js"; + +async function deleteActorsAction( + { runTask }: CLIContext, + names: string[], +): Promise { + let deleted = 0; + let notFound = 0; + let errors = 0; + + for (const name of names) { + try { + await runTask(`Deleting ${name}...`, () => deleteSingleActor(name), { + successMessage: `${name} deleted`, + errorMessage: `Failed to delete ${name}`, + }); + deleted++; + } catch (error) { + if (error instanceof ApiError && error.statusCode === 404) { + notFound++; + } else { + errors++; + } + } + } + + if (names.length === 1) { + if (deleted) return { outroMessage: `Actor "${names[0]}" deleted` }; + if (notFound) return { outroMessage: `Actor "${names[0]}" not found` }; + return { outroMessage: `Failed to delete "${names[0]}"` }; + } + + const total = names.length; + const parts: string[] = []; + if (deleted > 0) parts.push(`${deleted}/${total} deleted`); + if (notFound > 0) parts.push(`${notFound} not found`); + if (errors > 0) parts.push(`${errors} error${errors !== 1 ? "s" : ""}`); + return { outroMessage: parts.join(", ") }; +} + +function validateNames(command: Command): void { + const names = parseNames(command.args); + if (names.length === 0) { + command.error("At least one actor name is required"); + } +} + +export function getDeleteCommand(): Command { + return new Base44Command("delete") + .description("Delete deployed actors") + .argument("", "Actor names to delete") + .hook("preAction", validateNames) + .action(async (ctx: CLIContext, rawNames: string[]) => { + const names = parseNames(rawNames); + return deleteActorsAction(ctx, names); + }); +} diff --git a/packages/cli/src/cli/commands/actors/deploy.ts b/packages/cli/src/cli/commands/actors/deploy.ts index 6a11980af..e31d22b6b 100644 --- a/packages/cli/src/cli/commands/actors/deploy.ts +++ b/packages/cli/src/cli/commands/actors/deploy.ts @@ -1,5 +1,4 @@ import type { Command } from "commander"; -import { CLIExitError } from "@/cli/errors.js"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { Base44Command, @@ -12,6 +11,7 @@ import { InvalidInputError } from "@/core/errors.js"; import { readProjectConfig } from "@/core/index.js"; import { deployActorsSequentially } from "@/core/resources/actor/deploy.js"; import type { Actor } from "@/core/resources/actor/schema.js"; +import { throwIfDeployFailed } from "@/core/resources/types.js"; function resolveActorsToDeploy(names: string[], allActors: Actor[]): Actor[] { if (names.length === 0) return allActors; @@ -29,12 +29,12 @@ async function deployActorsAction( { log }: CLIContext, names: string[], ): Promise { - const { actors } = await readProjectConfig(); + const { actors, project } = await readProjectConfig(); const toDeploy = resolveActorsToDeploy(names, actors); if (toDeploy.length === 0) { return { - outroMessage: "No actors found. Create actors in the 'actors' directory.", + outroMessage: `No actors found. Create actors in the '${project.actorsDir}' directory.`, }; } @@ -62,7 +62,7 @@ async function deployActorsAction( const hasFailures = results.some((r) => r.status === "error"); if (hasFailures) { log.message(buildDeploySummary(results, "actors")); - throw new CLIExitError(1); + throwIfDeployFailed(results, "actor"); } return { outroMessage: buildDeploySummary(results, "actors") }; diff --git a/packages/cli/src/cli/commands/actors/index.ts b/packages/cli/src/cli/commands/actors/index.ts index 7b256abfd..bc6f5965f 100644 --- a/packages/cli/src/cli/commands/actors/index.ts +++ b/packages/cli/src/cli/commands/actors/index.ts @@ -1,8 +1,10 @@ import { Command } from "commander"; +import { getDeleteCommand } from "./delete.js"; import { getDeployCommand } from "./deploy.js"; export function getActorsCommand(): Command { return new Command("actors") .description("Manage actors") - .addCommand(getDeployCommand()); + .addCommand(getDeployCommand()) + .addCommand(getDeleteCommand()); } diff --git a/packages/cli/src/cli/commands/functions/deploy.ts b/packages/cli/src/cli/commands/functions/deploy.ts index d2645954d..98c94e754 100644 --- a/packages/cli/src/cli/commands/functions/deploy.ts +++ b/packages/cli/src/cli/commands/functions/deploy.ts @@ -1,6 +1,5 @@ import type { Logger } from "@base44-cli/logger"; import type { Command } from "commander"; -import { CLIExitError } from "@/cli/errors.js"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { Base44Command, @@ -17,6 +16,7 @@ import { pruneRemovedFunctions, } from "@/core/resources/function/deploy.js"; import type { BackendFunction } from "@/core/resources/function/schema.js"; +import { throwIfDeployFailed } from "@/core/resources/types.js"; function resolveFunctionsToDeploy( names: string[], @@ -95,7 +95,7 @@ async function deployFunctionsAction( const hasFailures = results.some((r) => r.status === "error"); if (hasFailures) { log.message(buildDeploySummary(results, "functions")); - throw new CLIExitError(1); + throwIfDeployFailed(results, "function"); } if (options.force) { diff --git a/packages/cli/src/cli/commands/project/deploy.ts b/packages/cli/src/cli/commands/project/deploy.ts index 9333763d6..1260a491a 100644 --- a/packages/cli/src/cli/commands/project/deploy.ts +++ b/packages/cli/src/cli/commands/project/deploy.ts @@ -173,7 +173,7 @@ export async function deployAction( export function getDeployCommand(): Command { return new Base44Command("deploy") .description( - "Deploy all project resources (entities, functions, agents, connectors, and site)", + "Deploy entities, functions, actors, agent skills, agents, auth, connectors, and site", ) .option("-y, --yes", "Skip confirmation prompt") .option("--build", "Build the site before deploying (skips the prompt)") diff --git a/packages/cli/src/cli/utils/command/Base44Command.ts b/packages/cli/src/cli/utils/command/Base44Command.ts index 903dc2b37..301baffa4 100644 --- a/packages/cli/src/cli/utils/command/Base44Command.ts +++ b/packages/cli/src/cli/utils/command/Base44Command.ts @@ -15,7 +15,7 @@ import { formatPlainUpgradeMessage, startUpgradeCheck, } from "@/cli/utils/upgradeNotification.js"; -import { ApiError, isCLIError } from "@/core/errors.js"; +import { ApiError, isCLIError, ResourceDeployError } from "@/core/errors.js"; /** * Write a command result to stdout as a single JSON document (the `--json` @@ -63,6 +63,26 @@ function writeJsonError(error: unknown): void { envelope.requestId = error.requestId; } } + if (error instanceof ResourceDeployError) { + envelope.failures = error.failures.map((failure) => { + const item: Record = { + name: failure.name, + error: failure.message, + }; + if (isCLIError(failure.cause)) { + item.code = failure.cause.code; + } + if (failure.cause instanceof ApiError) { + if (failure.cause.statusCode !== undefined) { + item.statusCode = failure.cause.statusCode; + } + if (failure.cause.requestId !== undefined) { + item.requestId = failure.cause.requestId; + } + } + return item; + }); + } process.stdout.write(`${JSON.stringify(envelope)}\n`); } diff --git a/packages/cli/src/core/errors.ts b/packages/cli/src/core/errors.ts index b5bf8049a..62f2e878a 100644 --- a/packages/cli/src/core/errors.ts +++ b/packages/cli/src/core/errors.ts @@ -484,6 +484,41 @@ export class ApiError extends SystemError { } } +interface ResourceDeployFailure { + name: string; + message: string; + cause?: Error; +} + +/** + * Thrown when one or more items in a sequential resource deploy fail. + */ +export class ResourceDeployError extends SystemError { + readonly code = "RESOURCE_DEPLOY_FAILED"; + readonly failures: ResourceDeployFailure[]; + + constructor(resource: string, failures: ResourceDeployFailure[]) { + const label = failures.length === 1 ? resource : `${resource}s`; + const hints = failures.flatMap((failure) => + failure.cause instanceof CLIError ? failure.cause.hints : [], + ); + + super(`Failed to deploy ${failures.length} ${label}`, { + details: failures.map((failure) => `${failure.name}: ${failure.message}`), + hints: hints.filter( + (hint, index, all) => + all.findIndex( + (candidate) => + candidate.message === hint.message && + candidate.command === hint.command, + ) === index, + ), + cause: failures.find((failure) => failure.cause)?.cause, + }); + this.failures = failures; + } +} + /** * Thrown when a file is not found. */ diff --git a/packages/cli/src/core/project/config.ts b/packages/cli/src/core/project/config.ts index c27ec9f02..d9a2f96d6 100644 --- a/packages/cli/src/core/project/config.ts +++ b/packages/cli/src/core/project/config.ts @@ -22,6 +22,7 @@ import type { ProjectRoot, ProjectWithPaths, } from "@/core/project/types.js"; +import type { Actor } from "@/core/resources/actor/index.js"; import { actorResource } from "@/core/resources/actor/index.js"; import { agentResource } from "@/core/resources/agent/index.js"; import { agentSkillResource } from "@/core/resources/agent-skill/index.js"; @@ -68,6 +69,7 @@ class ProjectConfigReader { ...pluginResources.functions, ]; this.validateFunctionNames(functions, configPath); + this.validateActorNames(localResources.actors, functions, configPath); return { project, @@ -307,6 +309,36 @@ class ProjectConfigReader { functionsByName.set(fn.name, fn); } } + + /** + * An actor deploys onto the same backend-function namespace server-side, so a + * name shared with a function is rejected there. Catch it at read time — the + * alternative is a mid-deploy failure after earlier resources already landed. + */ + private validateActorNames( + actors: Actor[], + functions: BackendFunction[], + configPath: string, + ): void { + const functionNames = new Set(functions.map((fn) => fn.name)); + + for (const actor of actors) { + if (functionNames.has(actor.name)) { + throw new ConfigInvalidError( + `"${actor.name}" exists as both a backend function and an actor.`, + configPath, + { + hints: [ + { + message: + "Actors and functions share one deploy namespace — rename one of them.", + }, + ], + }, + ); + } + } + } } /** diff --git a/packages/cli/src/core/project/deploy.ts b/packages/cli/src/core/project/deploy.ts index b474dda42..01973be2d 100644 --- a/packages/cli/src/core/project/deploy.ts +++ b/packages/cli/src/core/project/deploy.ts @@ -19,6 +19,7 @@ import { deployFunctionsSequentially, type SingleFunctionDeployResult, } from "@/core/resources/function/deploy.js"; +import { throwIfDeployFailed } from "@/core/resources/types.js"; import { deploySite } from "@/core/site/index.js"; /** @@ -110,14 +111,17 @@ export async function deployAll( options?.onVisibilitySet?.(project.visibility); } await entityResource.push(entities); - await deployFunctionsSequentially(functions, { + const functionResults = await deployFunctionsSequentially(functions, { onStart: options?.onFunctionStart, onResult: options?.onFunctionResult, }); - await deployActorsSequentially(actors, { + throwIfDeployFailed(functionResults, "function"); + + const actorResults = await deployActorsSequentially(actors, { onStart: options?.onActorStart, onResult: options?.onActorResult, }); + throwIfDeployFailed(actorResults, "actor"); await agentSkillResource.push(agentSkills); await agentResource.push(agents); await authConfigResource.push(authConfig); diff --git a/packages/cli/src/core/resources/actor/api.ts b/packages/cli/src/core/resources/actor/api.ts index 83e3d760b..577455ae3 100644 --- a/packages/cli/src/core/resources/actor/api.ts +++ b/packages/cli/src/core/resources/actor/api.ts @@ -30,3 +30,18 @@ export async function deploySingleActor( } return result.data; } + +/** + * Tear down a deployed actor. The server destroys the published script, so this + * is not reversible by a redeploy of the same instance state. + */ +export async function deleteSingleActor(name: string): Promise { + const appClient = getAppClient(); + try { + await appClient.delete(`actors/${encodeURIComponent(name)}`, { + timeout: 60_000, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, `deleting actor "${name}"`); + } +} diff --git a/packages/cli/src/core/resources/actor/config.ts b/packages/cli/src/core/resources/actor/config.ts index c35271cc9..514d1a4f4 100644 --- a/packages/cli/src/core/resources/actor/config.ts +++ b/packages/cli/src/core/resources/actor/config.ts @@ -9,17 +9,80 @@ import { ConfigInvalidError, InvalidInputError } from "@/core/errors.js"; import type { Actor } from "@/core/resources/actor/schema.js"; import { pathExists } from "@/core/utils/fs.js"; +/** + * An actor's name becomes a Durable Object class and the WebSocket connect + * handler on the server, so it has to be a plain ASCII JavaScript identifier. + * Mirrors the server's own rule — `PUT /api/apps/{app_id}/actors/{name}` 422s + * anything else — so a bad folder name fails locally instead of mid-deploy. + */ +const VALID_ACTOR_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/; + +/** + * ES reserved words + strict-mode reserved words + `eval`/`arguments`. The + * server interpolates the name into `import from …; export { }`, + * so a reserved word would compile to invalid JavaScript. + */ +const JS_RESERVED_ACTOR_NAMES = new Set( + ( + "await break case catch class const continue debugger default delete do else " + + "enum export extends false finally for function if import in instanceof let " + + "new null return static super switch this throw true try typeof var void " + + "while with yield implements interface package private protected public " + + "eval arguments" + ).split(" "), +); + +function assertValidActorName(name: string): void { + if (name.includes("/")) { + throw new ConfigInvalidError( + `Invalid actor name "${name}" — actors cannot be nested in subfolders`, + null, + { + hints: [ + { + message: `Use a single folder level (e.g. actors/${name.split("/").pop()}/entry.ts)`, + }, + { + message: + "A nested name can also mean a helper file was named entry.ts — every entry file under the actors directory is treated as an actor, so rename the helper", + }, + ], + }, + ); + } + + if (!VALID_ACTOR_NAME.test(name)) { + throw new ConfigInvalidError( + `Invalid actor name "${name}" — actor names become a JavaScript class binding, so they must match [A-Za-z_][A-Za-z0-9_]* (max 128 characters, no "-", "." or ":")`, + null, + { + hints: [ + { message: "Rename the folder in PascalCase (e.g. actors/ChatRoom)" }, + ], + }, + ); + } + + if (JS_RESERVED_ACTOR_NAMES.has(name)) { + throw new ConfigInvalidError( + `Invalid actor name "${name}" — it is a reserved word in JavaScript, and actor names become a class binding`, + null, + { + hints: [ + { message: "Rename the folder in PascalCase (e.g. actors/ChatRoom)" }, + ], + }, + ); + } +} + async function readActor(entryFile: string, actorsDir: string): Promise { const actorDir = dirname(entryFile); - const filePaths = await globby(BACKEND_FILE_GLOB, { - cwd: actorDir, - absolute: true, - }); - const name = relative(actorsDir, actorDir).split(/[/\\]/).join("/"); if (!name) { + const entryName = basename(entryFile); throw new InvalidInputError( - "entry.ts found directly in the actors directory — it must be inside a named subfolder", + `${entryName} found directly in the actors directory — it must be inside a named subfolder`, { hints: [ { @@ -29,6 +92,12 @@ async function readActor(entryFile: string, actorsDir: string): Promise { }, ); } + assertValidActorName(name); + + const filePaths = await globby(BACKEND_FILE_GLOB, { + cwd: actorDir, + absolute: true, + }); return { name, @@ -44,6 +113,9 @@ export async function readAllActors(actorsDir: string): Promise { return []; } + // Same dot-path exclusion as functions: a folder with a dot in its name can + // never be a valid actor name, so treat it as scratch (`ChatRoom.bak/`) + // rather than a deploy that would 422. const entryFiles = await globby(ENTRY_FILE_GLOB, { cwd: actorsDir, absolute: true, diff --git a/packages/cli/src/core/resources/actor/deploy.ts b/packages/cli/src/core/resources/actor/deploy.ts index 9ddf6ecf4..4751412b8 100644 --- a/packages/cli/src/core/resources/actor/deploy.ts +++ b/packages/cli/src/core/resources/actor/deploy.ts @@ -39,6 +39,7 @@ async function deployOne(actor: Actor): Promise { name: actor.name, status: "error", error: error instanceof Error ? error.message : String(error), + cause: error instanceof Error ? error : undefined, }; } } diff --git a/packages/cli/src/core/resources/actor/resource.ts b/packages/cli/src/core/resources/actor/resource.ts index 60d5a88d0..c3e0893ed 100644 --- a/packages/cli/src/core/resources/actor/resource.ts +++ b/packages/cli/src/core/resources/actor/resource.ts @@ -1,9 +1,13 @@ import { readAllActors } from "@/core/resources/actor/config.js"; import { deployActorsSequentially } from "@/core/resources/actor/deploy.js"; import type { Actor } from "@/core/resources/actor/schema.js"; -import type { Resource } from "@/core/resources/types.js"; +import { type Resource, throwIfDeployFailed } from "@/core/resources/types.js"; export const actorResource: Resource = { readAll: readAllActors, - push: (actors) => deployActorsSequentially(actors), + push: async (actors) => { + const results = await deployActorsSequentially(actors); + throwIfDeployFailed(results, "actor"); + return results; + }, }; diff --git a/packages/cli/src/core/resources/function/deploy.ts b/packages/cli/src/core/resources/function/deploy.ts index fb0434f95..6b9744396 100644 --- a/packages/cli/src/core/resources/function/deploy.ts +++ b/packages/cli/src/core/resources/function/deploy.ts @@ -49,6 +49,7 @@ async function deployOne( name: fn.name, status: "error", error: error instanceof Error ? error.message : String(error), + cause: error instanceof Error ? error : undefined, }; } } diff --git a/packages/cli/src/core/resources/function/resource.ts b/packages/cli/src/core/resources/function/resource.ts index 8cf9815df..196202884 100644 --- a/packages/cli/src/core/resources/function/resource.ts +++ b/packages/cli/src/core/resources/function/resource.ts @@ -1,9 +1,13 @@ import { readAllFunctions } from "@/core/resources/function/config.js"; import { deployFunctionsSequentially } from "@/core/resources/function/deploy.js"; import type { BackendFunction } from "@/core/resources/function/schema.js"; -import type { Resource } from "@/core/resources/types.js"; +import { type Resource, throwIfDeployFailed } from "@/core/resources/types.js"; export const functionResource: Resource = { readAll: readAllFunctions, - push: (functions) => deployFunctionsSequentially(functions), + push: async (functions) => { + const results = await deployFunctionsSequentially(functions); + throwIfDeployFailed(results, "function"); + return results; + }, }; diff --git a/packages/cli/src/core/resources/types.ts b/packages/cli/src/core/resources/types.ts index a8ad75da7..aa3a342f6 100644 --- a/packages/cli/src/core/resources/types.ts +++ b/packages/cli/src/core/resources/types.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { ResourceDeployError } from "@/core/errors.js"; export const ResourceSourceSchema = z.discriminatedUnion("type", [ z.object({ @@ -17,9 +18,27 @@ export interface SingleDeployResult { name: string; status: "deployed" | "unchanged" | "error"; error?: string | null; + cause?: Error; durationMs?: number; } +export function throwIfDeployFailed( + results: SingleDeployResult[], + resource: "actor" | "function", +): void { + const failures = results + .filter((result) => result.status === "error") + .map((result) => ({ + name: result.name, + message: result.error ?? "Unknown deployment error", + cause: result.cause, + })); + + if (failures.length > 0) { + throw new ResourceDeployError(resource, failures); + } +} + /** * Base interface for all project resources (entities, functions, etc.). * Resources are project-specific collections that can be loaded from the filesystem diff --git a/packages/cli/tests/cli/actors_delete.spec.ts b/packages/cli/tests/cli/actors_delete.spec.ts new file mode 100644 index 000000000..5dd7e7db2 --- /dev/null +++ b/packages/cli/tests/cli/actors_delete.spec.ts @@ -0,0 +1,81 @@ +import { describe, it } from "vitest"; +import { fixture, setupCLITests } from "./testkit/index.js"; + +describe("actors delete command", () => { + const t = setupCLITests(); + + it("deletes a single actor successfully", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockSingleActorDelete(); + + const result = await t.run("actors", "delete", "ChatRoom"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("deleted"); + }); + + it("deletes multiple actors with summary", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockSingleActorDelete(); + + const result = await t.run("actors", "delete", "ChatRoom", "BoardRoom"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("ChatRoom deleted"); + t.expectResult(result).toContain("BoardRoom deleted"); + t.expectResult(result).toContain("2/2 deleted"); + }); + + it("accepts comma-separated actor names", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockSingleActorDelete(); + + const result = await t.run("actors", "delete", "ChatRoom,BoardRoom"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("2/2 deleted"); + }); + + it("reports not found for non-existent actor", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockSingleActorDeleteError({ + status: 404, + body: { error: "Not found" }, + }); + + const result = await t.run("actors", "delete", "nonexistent"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("not found"); + }); + + it("reports API errors gracefully", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockSingleActorDeleteError({ + status: 500, + body: { error: "Server error" }, + }); + + const result = await t.run("actors", "delete", "ChatRoom"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Failed to delete"); + }); + + it("requires at least one actor name", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + + const result = await t.run("actors", "delete", ","); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("At least one actor name is required"); + }); + + it("fails when not in a project directory", async () => { + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + + const result = await t.run("actors", "delete", "ChatRoom"); + + t.expectResult(result).toFail(); + }); +}); diff --git a/packages/cli/tests/cli/actors_deploy.spec.ts b/packages/cli/tests/cli/actors_deploy.spec.ts index de2f35047..251166e17 100644 --- a/packages/cli/tests/cli/actors_deploy.spec.ts +++ b/packages/cli/tests/cli/actors_deploy.spec.ts @@ -1,4 +1,4 @@ -import { describe, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { fixture, setupCLITests } from "./testkit/index.js"; describe("actors deploy command", () => { @@ -31,6 +31,21 @@ describe("actors deploy command", () => { t.expectResult(result).toSucceed(); t.expectResult(result).toContain("Deploying ChatRoom"); t.expectResult(result).toContain("1 deployed"); + + expect(t.api.actorDeployRequests).toHaveLength(1); + const request = t.api.actorDeployRequests[0]!; + expect(request.name).toBe("ChatRoom"); + expect(request.entry).toBe("entry.ts"); + expect(request.files.map((file) => file.path).sort()).toEqual([ + "entry.ts", + "helper.ts", + ]); + expect( + request.files.find((file) => file.path === "entry.ts")?.content, + ).toContain('from "base44:runtime/actors"'); + expect( + request.files.find((file) => file.path === "helper.ts")?.content, + ).toContain("formatMessage"); }); it("reports unchanged actor", async () => { @@ -87,4 +102,28 @@ describe("actors deploy command", () => { t.expectResult(result).toContain("error"); t.expectResult(result).toContain("1 error"); }); + + it("returns structured actor failures in JSON mode", async () => { + await t.givenLoggedInWithProject(fixture("with-actors")); + t.api.mockSingleActorDeployError({ + status: 422, + body: { message: "Invalid actor code" }, + }); + + const result = await t.run("actors", "deploy", "--json"); + + t.expectResult(result).toFail(); + expect(JSON.parse(result.stdout)).toMatchObject({ + error: "Failed to deploy 1 actor", + code: "RESOURCE_DEPLOY_FAILED", + failures: [ + { + name: "ChatRoom", + code: "API_ERROR", + statusCode: 422, + }, + ], + }); + expect(result.stdout).toContain("Invalid actor code"); + }); }); diff --git a/packages/cli/tests/cli/deploy.spec.ts b/packages/cli/tests/cli/deploy.spec.ts index 1f782c451..d94ca5a92 100644 --- a/packages/cli/tests/cli/deploy.spec.ts +++ b/packages/cli/tests/cli/deploy.spec.ts @@ -4,6 +4,13 @@ import { fixture, setupCLITests } from "./testkit/index.js"; describe("deploy command (unified)", () => { const t = setupCLITests(); + it("lists actors in deploy help", async () => { + const result = await t.run("deploy", "--help"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("actors"); + }); + it("applies app visibility from config during deploy", async () => { await t.givenLoggedInWithProject(fixture("with-visibility")); @@ -112,6 +119,20 @@ describe("deploy command (unified)", () => { t.expectResult(result).toContain("App deployed successfully"); }); + it("fails unified deploy when an actor fails", async () => { + await t.givenLoggedInWithProject(fixture("with-actors")); + t.api.mockSingleActorDeployError({ + status: 400, + body: { error: "Invalid actor code" }, + }); + + const result = await t.run("deploy", "-y"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("Invalid actor code"); + t.expectResult(result).toNotContain("App deployed successfully"); + }); + it("deploys entities successfully with --yes flag", async () => { await t.givenLoggedInWithProject(fixture("with-entities")); t.api.mockEntitiesPush({ @@ -143,6 +164,21 @@ describe("deploy command (unified)", () => { t.expectResult(result).toContain("App deployed successfully"); }); + it("fails unified deploy when a function fails", async () => { + await t.givenLoggedInWithProject(fixture("with-functions-and-entities")); + t.api.mockEntitiesPush({ created: ["Order"], updated: [], deleted: [] }); + t.api.mockSingleFunctionDeployError({ + status: 400, + body: { error: "Invalid function code" }, + }); + + const result = await t.run("deploy", "-y"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("Invalid function code"); + t.expectResult(result).toNotContain("App deployed successfully"); + }); + it("deploys zero-config functions (path-based names) with unified deploy", async () => { await t.givenLoggedInWithProject(fixture("with-zero-config-functions")); t.api.mockEntitiesPush({ created: [], updated: [], deleted: [] }); diff --git a/packages/cli/tests/cli/functions_deploy.spec.ts b/packages/cli/tests/cli/functions_deploy.spec.ts index 573119202..4f1c46d66 100644 --- a/packages/cli/tests/cli/functions_deploy.spec.ts +++ b/packages/cli/tests/cli/functions_deploy.spec.ts @@ -1,4 +1,4 @@ -import { describe, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { fixture, setupCLITests } from "./testkit/index.js"; describe("functions deploy command", () => { @@ -115,6 +115,30 @@ describe("functions deploy command", () => { t.expectResult(result).toContain("1 error"); }); + it("returns structured function failures in JSON mode", async () => { + await t.givenLoggedInWithProject(fixture("with-functions-and-entities")); + t.api.mockSingleFunctionDeployError({ + status: 422, + body: { message: "Invalid function code" }, + }); + + const result = await t.run("functions", "deploy", "--json"); + + t.expectResult(result).toFail(); + expect(JSON.parse(result.stdout)).toMatchObject({ + error: "Failed to deploy 1 function", + code: "RESOURCE_DEPLOY_FAILED", + failures: [ + { + name: "process-order", + code: "API_ERROR", + statusCode: 422, + }, + ], + }); + expect(result.stdout).toContain("Invalid function code"); + }); + it("reports validation error from 422 response", async () => { await t.givenLoggedInWithProject(fixture("with-functions-and-entities")); t.api.mockSingleFunctionDeployError({ diff --git a/packages/cli/tests/cli/testkit/TestAPIServer.ts b/packages/cli/tests/cli/testkit/TestAPIServer.ts index 62eae15dd..d24d619f4 100644 --- a/packages/cli/tests/cli/testkit/TestAPIServer.ts +++ b/packages/cli/tests/cli/testkit/TestAPIServer.ts @@ -119,6 +119,12 @@ interface SingleActorDeployResponse { status: "deployed" | "unchanged"; } +interface ActorDeployRequest { + name: string; + entry: string; + files: Array<{ path: string; content: string }>; +} + interface AutomationBase { name: string; description?: string | null; @@ -385,6 +391,9 @@ export class TestAPIServer { private server: Server | null = null; private _port = 0; + /** Captured actor deploy request bodies and decoded actor names. */ + readonly actorDeployRequests: ActorDeployRequest[] = []; + constructor(readonly appId: string) { this.app = express(); this.app.use(express.json()); @@ -533,11 +542,32 @@ export class TestAPIServer { /** Mock PUT /api/apps/{appId}/actors/{name} - Deploy single actor */ mockSingleActorDeploy(response: SingleActorDeployResponse): this { - return this.addRoute( - "PUT", - `/api/apps/${this.appId}/actors/:name`, - response, - ); + this.pendingRoutes.push({ + method: "PUT", + path: `/api/apps/${this.appId}/actors/:name`, + handler: (req, res) => { + const body = req.body as Omit; + this.actorDeployRequests.push({ + name: String(req.params.name), + entry: body.entry, + files: body.files, + }); + res.status(200).json(response); + }, + }); + return this; + } + + /** Mock DELETE /api/apps/{appId}/actors/{name} - Delete single actor */ + mockSingleActorDelete(): this { + this.pendingRoutes.push({ + method: "DELETE", + path: `/api/apps/${this.appId}/actors/:name`, + handler: (_req, res) => { + res.status(200).json({ status: "deleted" }); + }, + }); + return this; } mockSiteDeploy(response: SiteDeployResponse): this { @@ -954,6 +984,15 @@ export class TestAPIServer { ); } + /** Mock single actor delete to return an error */ + mockSingleActorDeleteError(error: ErrorResponse): this { + return this.addErrorRoute( + "DELETE", + `/api/apps/${this.appId}/actors/:name`, + error, + ); + } + mockSiteDeployError(error: ErrorResponse): this { return this.addErrorRoute( "POST", diff --git a/packages/cli/tests/core/actor-config.spec.ts b/packages/cli/tests/core/actor-config.spec.ts new file mode 100644 index 000000000..9322a8b1b --- /dev/null +++ b/packages/cli/tests/core/actor-config.spec.ts @@ -0,0 +1,94 @@ +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { readAllActors } from "@/core/resources/actor/config.js"; + +const FIXTURES_DIR = resolve(__dirname, "../fixtures"); +const fwd = (path: string) => path.replace(/\\/g, "/"); + +describe("readAllActors", () => { + it("returns an empty array when the actors directory does not exist", async () => { + const actors = await readAllActors( + resolve(FIXTURES_DIR, "nonexistent-actors"), + ); + + expect(actors).toEqual([]); + }); + + it("discovers actors and skips folders whose name contains a dot", async () => { + const actors = await readAllActors( + resolve(FIXTURES_DIR, "actor-discovery"), + ); + + // Chat.Room/ is present in the fixture but can never be a valid actor name, + // so it is excluded rather than deployed into a server-side rejection. + expect(actors.map((actor) => actor.name)).toEqual(["BoardRoom"]); + }); + + it("collects actor files recursively", async () => { + const actors = await readAllActors( + resolve(FIXTURES_DIR, "actor-discovery"), + ); + const boardRoom = actors.find((actor) => actor.name === "BoardRoom"); + + expect(boardRoom).toBeDefined(); + expect(boardRoom!.entry).toBe("entry.ts"); + expect(boardRoom!.filePaths.map(fwd).sort()).toEqual( + expect.arrayContaining([ + expect.stringMatching(/BoardRoom\/entry\.ts$/), + expect.stringMatching(/BoardRoom\/lib\/helper\.ts$/), + ]), + ); + }); + + it("rejects an entry file directly under the actors root", async () => { + const actorsDir = resolve(FIXTURES_DIR, "actor-discovery-entry-at-root"); + + await expect(readAllActors(actorsDir)).rejects.toThrow( + /entry\.js found directly in the actors directory/, + ); + }); + + it("rejects a nested actor folder", async () => { + const actorsDir = resolve(FIXTURES_DIR, "actor-invalid-nested"); + + await expect(readAllActors(actorsDir)).rejects.toThrow( + /Invalid actor name "games\/Arena" — actors cannot be nested/, + ); + }); + + it("rejects a helper file named entry.ts inside an actor folder", async () => { + const actorsDir = resolve(FIXTURES_DIR, "actor-invalid-helper-entry"); + + // The failure mode the nesting error's second hint covers. + await expect(readAllActors(actorsDir)).rejects.toThrow( + /Invalid actor name "BoardRoom\/lib" — actors cannot be nested/, + ); + }); + + it("rejects an actor name that is not a JavaScript identifier", async () => { + const actorsDir = resolve(FIXTURES_DIR, "actor-invalid-charset"); + + await expect(readAllActors(actorsDir)).rejects.toThrow( + /Invalid actor name "chat-room" — actor names become a JavaScript class binding/, + ); + }); + + it("rejects an actor name that is a JavaScript reserved word", async () => { + const actorsDir = resolve(FIXTURES_DIR, "actor-invalid-reserved"); + + await expect(readAllActors(actorsDir)).rejects.toThrow( + /Invalid actor name "class" — it is a reserved word in JavaScript/, + ); + }); + + it("rejects folders containing both entry.js and entry.ts", async () => { + const actorsDir = resolve( + FIXTURES_DIR, + "duplicate-actor-names/base44/actors", + ); + + await expect(readAllActors(actorsDir)).rejects.toThrow( + /Duplicate actor name "Duplicate"/, + ); + }); +}); diff --git a/packages/cli/tests/core/errors.spec.ts b/packages/cli/tests/core/errors.spec.ts index d14924f4d..9d6c2a7e2 100644 --- a/packages/cli/tests/core/errors.spec.ts +++ b/packages/cli/tests/core/errors.spec.ts @@ -14,6 +14,7 @@ import { isCLIError, isSystemError, isUserError, + ResourceDeployError, SchemaValidationError, } from "../../src/core/errors.js"; @@ -132,6 +133,22 @@ describe("UserError subclasses", () => { }); describe("SystemError subclasses", () => { + it("ResourceDeployError preserves per-resource failures", () => { + const cause = new ApiError("Invalid actor code", { statusCode: 422 }); + const error = new ResourceDeployError("actor", [ + { name: "ChatRoom", message: cause.message, cause }, + ]); + + expect(error.code).toBe("RESOURCE_DEPLOY_FAILED"); + expect(error.message).toBe("Failed to deploy 1 actor"); + expect(error.details).toEqual(["ChatRoom: Invalid actor code"]); + expect(error.failures).toEqual([ + { name: "ChatRoom", message: cause.message, cause }, + ]); + expect(error.cause).toBe(cause); + expect(isSystemError(error)).toBe(true); + }); + it("ApiError provides default hints based on status code", () => { const error401 = new ApiError("Unauthorized", { statusCode: 401 }); expect(error401.hints.some((h) => h.command === "base44 login")).toBe(true); diff --git a/packages/cli/tests/core/project.spec.ts b/packages/cli/tests/core/project.spec.ts index 9e9e0defc..501ec990b 100644 --- a/packages/cli/tests/core/project.spec.ts +++ b/packages/cli/tests/core/project.spec.ts @@ -48,6 +48,16 @@ describe("readProjectConfig", () => { expect(result.agents).toEqual([]); }); + it("rejects a name used by both a function and an actor", async () => { + // Actors deploy onto the backend-function namespace, so the server rejects + // the collision — fail at read time instead of mid-deploy. + await expect( + readProjectConfig(resolve(FIXTURES_DIR, "actor-function-name-collision")), + ).rejects.toThrow( + /"ChatRoom" exists as both a backend function and an actor/, + ); + }); + it("reads project plugins with automatic entity merging and namespaced functions", async () => { const result = await readProjectConfig( resolve(FIXTURES_DIR, "with-config-plugins"), diff --git a/packages/cli/tests/fixtures/actor-discovery-entry-at-root/entry.js b/packages/cli/tests/fixtures/actor-discovery-entry-at-root/entry.js new file mode 100644 index 000000000..a07086906 --- /dev/null +++ b/packages/cli/tests/fixtures/actor-discovery-entry-at-root/entry.js @@ -0,0 +1 @@ +export default class RootActor {} diff --git a/packages/cli/tests/fixtures/actor-discovery/BoardRoom/entry.ts b/packages/cli/tests/fixtures/actor-discovery/BoardRoom/entry.ts new file mode 100644 index 000000000..595e583b0 --- /dev/null +++ b/packages/cli/tests/fixtures/actor-discovery/BoardRoom/entry.ts @@ -0,0 +1,7 @@ +import { formatMessage } from "./lib/helper.js"; + +export default class BoardRoom { + handleMessage(message: unknown): string { + return formatMessage(message); + } +} diff --git a/packages/cli/tests/fixtures/actor-discovery/BoardRoom/lib/helper.ts b/packages/cli/tests/fixtures/actor-discovery/BoardRoom/lib/helper.ts new file mode 100644 index 000000000..76283bf5b --- /dev/null +++ b/packages/cli/tests/fixtures/actor-discovery/BoardRoom/lib/helper.ts @@ -0,0 +1,3 @@ +export function formatMessage(message: unknown): string { + return JSON.stringify(message); +} diff --git a/packages/cli/tests/fixtures/actor-discovery/Chat.Room/entry.ts b/packages/cli/tests/fixtures/actor-discovery/Chat.Room/entry.ts new file mode 100644 index 000000000..9d395876f --- /dev/null +++ b/packages/cli/tests/fixtures/actor-discovery/Chat.Room/entry.ts @@ -0,0 +1,3 @@ +// A dotted folder name can never be a valid actor name, so discovery skips it +// (same dot-path exclusion functions use) instead of failing the deploy. +export default class ScratchCopy {} diff --git a/packages/cli/tests/fixtures/actor-function-name-collision/base44/.app.jsonc b/packages/cli/tests/fixtures/actor-function-name-collision/base44/.app.jsonc new file mode 100644 index 000000000..e1fbc58f2 --- /dev/null +++ b/packages/cli/tests/fixtures/actor-function-name-collision/base44/.app.jsonc @@ -0,0 +1,4 @@ +// Base44 App Configuration +{ + "id": "test-app-id" +} diff --git a/packages/cli/tests/fixtures/actor-function-name-collision/base44/actors/ChatRoom/entry.ts b/packages/cli/tests/fixtures/actor-function-name-collision/base44/actors/ChatRoom/entry.ts new file mode 100644 index 000000000..c09f37e14 --- /dev/null +++ b/packages/cli/tests/fixtures/actor-function-name-collision/base44/actors/ChatRoom/entry.ts @@ -0,0 +1 @@ +export default class ChatRoom {} diff --git a/packages/cli/tests/fixtures/actor-function-name-collision/base44/config.jsonc b/packages/cli/tests/fixtures/actor-function-name-collision/base44/config.jsonc new file mode 100644 index 000000000..29f45c91c --- /dev/null +++ b/packages/cli/tests/fixtures/actor-function-name-collision/base44/config.jsonc @@ -0,0 +1,3 @@ +{ + "name": "Actor/Function Collision Project" +} diff --git a/packages/cli/tests/fixtures/actor-function-name-collision/base44/functions/ChatRoom/entry.ts b/packages/cli/tests/fixtures/actor-function-name-collision/base44/functions/ChatRoom/entry.ts new file mode 100644 index 000000000..8ce2e6645 --- /dev/null +++ b/packages/cli/tests/fixtures/actor-function-name-collision/base44/functions/ChatRoom/entry.ts @@ -0,0 +1,3 @@ +export default async function handler() { + return new Response("ok"); +} diff --git a/packages/cli/tests/fixtures/actor-invalid-charset/chat-room/entry.js b/packages/cli/tests/fixtures/actor-invalid-charset/chat-room/entry.js new file mode 100644 index 000000000..c09f37e14 --- /dev/null +++ b/packages/cli/tests/fixtures/actor-invalid-charset/chat-room/entry.js @@ -0,0 +1 @@ +export default class ChatRoom {} diff --git a/packages/cli/tests/fixtures/actor-invalid-helper-entry/BoardRoom/entry.ts b/packages/cli/tests/fixtures/actor-invalid-helper-entry/BoardRoom/entry.ts new file mode 100644 index 000000000..2551fe7fb --- /dev/null +++ b/packages/cli/tests/fixtures/actor-invalid-helper-entry/BoardRoom/entry.ts @@ -0,0 +1 @@ +export default class BoardRoom {} diff --git a/packages/cli/tests/fixtures/actor-invalid-helper-entry/BoardRoom/lib/entry.ts b/packages/cli/tests/fixtures/actor-invalid-helper-entry/BoardRoom/lib/entry.ts new file mode 100644 index 000000000..a83cdf9b1 --- /dev/null +++ b/packages/cli/tests/fixtures/actor-invalid-helper-entry/BoardRoom/lib/entry.ts @@ -0,0 +1,5 @@ +// Named entry.ts by mistake: discovery treats every entry file under the actors +// directory as an actor, so this resolves to the nested name "BoardRoom/lib". +export function formatMessage(message: unknown): string { + return JSON.stringify(message); +} diff --git a/packages/cli/tests/fixtures/actor-invalid-nested/games/Arena/entry.js b/packages/cli/tests/fixtures/actor-invalid-nested/games/Arena/entry.js new file mode 100644 index 000000000..e87af5d5b --- /dev/null +++ b/packages/cli/tests/fixtures/actor-invalid-nested/games/Arena/entry.js @@ -0,0 +1 @@ +export default class Arena {} diff --git a/packages/cli/tests/fixtures/actor-invalid-reserved/class/entry.js b/packages/cli/tests/fixtures/actor-invalid-reserved/class/entry.js new file mode 100644 index 000000000..095725595 --- /dev/null +++ b/packages/cli/tests/fixtures/actor-invalid-reserved/class/entry.js @@ -0,0 +1 @@ +export default class ReservedName {} diff --git a/packages/cli/tests/fixtures/duplicate-actor-names/base44/actors/Duplicate/entry.js b/packages/cli/tests/fixtures/duplicate-actor-names/base44/actors/Duplicate/entry.js new file mode 100644 index 000000000..c74e2fdea --- /dev/null +++ b/packages/cli/tests/fixtures/duplicate-actor-names/base44/actors/Duplicate/entry.js @@ -0,0 +1 @@ +export default class DuplicateActor {} diff --git a/packages/cli/tests/fixtures/duplicate-actor-names/base44/actors/Duplicate/entry.ts b/packages/cli/tests/fixtures/duplicate-actor-names/base44/actors/Duplicate/entry.ts new file mode 100644 index 000000000..c74e2fdea --- /dev/null +++ b/packages/cli/tests/fixtures/duplicate-actor-names/base44/actors/Duplicate/entry.ts @@ -0,0 +1 @@ +export default class DuplicateActor {} diff --git a/packages/cli/tests/fixtures/with-actors/base44/actors/ChatRoom/entry.ts b/packages/cli/tests/fixtures/with-actors/base44/actors/ChatRoom/entry.ts index d71c001ae..7a9311934 100644 --- a/packages/cli/tests/fixtures/with-actors/base44/actors/ChatRoom/entry.ts +++ b/packages/cli/tests/fixtures/with-actors/base44/actors/ChatRoom/entry.ts @@ -1,11 +1,15 @@ -import { Actor, type Conn } from "@base44/sdk"; +import { Actor } from "base44:runtime/actors"; import { formatMessage } from "./helper.js"; +interface TestConnection { + send(message: string): void; +} + export default class ChatRoom extends Actor { - handleConnect(_conn: Conn) {} - handleMessage(conn: Conn, msg: unknown) { + handleConnect(_conn: TestConnection) {} + handleMessage(conn: TestConnection, msg: unknown) { conn.send(formatMessage(msg)); } handleTick() {} - handleClose(_conn: Conn) {} + handleClose(_conn: TestConnection) {} } diff --git a/packages/cli/tests/fixtures/with-types-resources/base44/actors/ChatRoom/entry.ts b/packages/cli/tests/fixtures/with-types-resources/base44/actors/ChatRoom/entry.ts index db5ad7165..e11dd6287 100644 --- a/packages/cli/tests/fixtures/with-types-resources/base44/actors/ChatRoom/entry.ts +++ b/packages/cli/tests/fixtures/with-types-resources/base44/actors/ChatRoom/entry.ts @@ -1,8 +1,8 @@ -import { Actor, type Conn } from "@base44/sdk"; +import { Actor } from "base44:runtime/actors"; -export class ChatRoom extends Actor { - handleConnect(_conn: Conn) {} - handleMessage(_conn: Conn, _msg: unknown) {} +export default class ChatRoom extends Actor { + handleConnect() {} + handleMessage() {} handleTick() {} - handleClose(_conn: Conn) {} + handleClose() {} }