From d1a405573afdb473dfaa8571290f73d28be3d887 Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 30 Jun 2026 11:21:56 +0300 Subject: [PATCH 01/23] 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 ba5bf73a98a8d9f3d5d8b520b9cbcda70469681c Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 30 Jun 2026 13:54:46 +0300 Subject: [PATCH 02/23] 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 c076caa4b05e84f88bdc516be374407818f79fab Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 30 Jun 2026 14:38:28 +0300 Subject: [PATCH 03/23] 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 97c3c852feb891b64a6ce36e0fcf7a0e1517ab55 Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 30 Jun 2026 14:39:29 +0300 Subject: [PATCH 04/23] 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 2db5dc492b2f31b09082594e13cdf6f2d5c3f8b9 Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 30 Jun 2026 14:40:44 +0300 Subject: [PATCH 05/23] 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 fe996cd73014c97026a84424312174778a2750a1 Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 30 Jun 2026 15:10:06 +0300 Subject: [PATCH 06/23] 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 da5ff953f7d34c152dc89608e8b270f7590929b0 Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 30 Jun 2026 15:16:39 +0300 Subject: [PATCH 07/23] 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 56df28776d43b967c0c80cbee4430afff495215b Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 30 Jun 2026 15:19:24 +0300 Subject: [PATCH 08/23] 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 0bd42fcf8bf8771b5d410f7c884d6ff897428c34 Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 30 Jun 2026 16:14:38 +0300 Subject: [PATCH 09/23] 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 c99a4c7706d5a86c46777f386229ae9329b0b1d2 Mon Sep 17 00:00:00 2001 From: imrik Date: Sun, 5 Jul 2026 13:54:15 +0300 Subject: [PATCH 10/23] 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 c70772315662d6de2deccefbd7054920055b2a83 Mon Sep 17 00:00:00 2001 From: imrik Date: Mon, 6 Jul 2026 00:30:03 +0300 Subject: [PATCH 11/23] 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 0032dc382d418179467a207b1643c9fce0c1242e Mon Sep 17 00:00:00 2001 From: imrik Date: Thu, 9 Jul 2026 16:27:39 +0300 Subject: [PATCH 12/23] 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 aab0a0e84c93a3ae241c5464084544dfdfedfd6a Mon Sep 17 00:00:00 2001 From: imrik Date: Thu, 9 Jul 2026 16:35:13 +0300 Subject: [PATCH 13/23] fix(cli-ci): organize imports (biome) + pin npm@11 for publish - Biome organizeImports across the files touched by the actor rename (import order shifted when realtime-handler paths became actor paths). - preview-publish + manual-publish: pin npm@11; npm@latest is now 12.x which requires node >=22 and fails EBADENGINE on the node-20 runner (.node-version). Co-Authored-By: Claude Opus 4.8 --- .github/workflows/manual-publish.yml | 4 +++- .github/workflows/preview-publish.yml | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/manual-publish.yml b/.github/workflows/manual-publish.yml index 89234c240..b5dbd4357 100644 --- a/.github/workflows/manual-publish.yml +++ b/.github/workflows/manual-publish.yml @@ -61,7 +61,9 @@ jobs: registry-url: "https://registry.npmjs.org" - name: Update npm - run: npm install -g npm@latest + # Pin npm@11: npm@latest is now 12.x (needs node >=22) and fails + # EBADENGINE on the node-20 runner (.node-version). npm 11 supports node ^20.17. + run: npm install -g npm@11 - name: Setup Bun id: setup-bun diff --git a/.github/workflows/preview-publish.yml b/.github/workflows/preview-publish.yml index 225235f9f..959cd9d50 100644 --- a/.github/workflows/preview-publish.yml +++ b/.github/workflows/preview-publish.yml @@ -25,7 +25,9 @@ jobs: registry-url: "https://registry.npmjs.org" - name: Update npm - run: npm install -g npm@latest + # Pin npm@11: npm@latest is now 12.x (needs node >=22) and fails + # EBADENGINE on the node-20 runner (.node-version). npm 11 supports node ^20.17. + run: npm install -g npm@11 working-directory: . - name: Setup Bun From 5507903479898c7492493baff1750b3a6984a2d0 Mon Sep 17 00:00:00 2001 From: imrik Date: Sun, 26 Jul 2026 14:58:29 +0300 Subject: [PATCH 14/23] 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 afa3eae9c60a70789305492bed92e352652795f7 Mon Sep 17 00:00:00 2001 From: imrik Date: Mon, 27 Jul 2026 11:36:14 +0300 Subject: [PATCH 15/23] 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 1b09f7ef37df4fa6ada04faa2b370721623ce5ce Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 28 Jul 2026 13:31:21 +0300 Subject: [PATCH 16/23] 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 246a698fe151efb4e3a18cb169fd9c717b547fe9 Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 28 Jul 2026 14:08:56 +0300 Subject: [PATCH 17/23] 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 bda3f216299a01c1fde7ca3b57cc380670d42749 Mon Sep 17 00:00:00 2001 From: imrik Date: Thu, 30 Jul 2026 09:54:48 +0300 Subject: [PATCH 18/23] 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 e11b43cc0373d7b5a8056826ddf7c3bae3662713 Mon Sep 17 00:00:00 2001 From: imrik Date: Thu, 30 Jul 2026 10:25:59 +0300 Subject: [PATCH 19/23] 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 0c408c0690017cd0202d43a2c682cbcabba04177 Mon Sep 17 00:00:00 2001 From: imrik Date: Thu, 30 Jul 2026 10:39:02 +0300 Subject: [PATCH 20/23] 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 d40c1108c17ddc1a78ae1b5e24d5ffefcf5a6084 Mon Sep 17 00:00:00 2001 From: imrik Date: Thu, 30 Jul 2026 12:26:56 +0300 Subject: [PATCH 21/23] =?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 01c2a0503afc1e54324e2e397b0309f3a3327594 Mon Sep 17 00:00:00 2001 From: imrik Date: Thu, 30 Jul 2026 13:54:55 +0300 Subject: [PATCH 22/23] ci: revert npm@11 pin in publish workflows (not needed) Co-Authored-By: Claude Opus 4.8 --- .github/workflows/manual-publish.yml | 4 +--- .github/workflows/preview-publish.yml | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/workflows/manual-publish.yml b/.github/workflows/manual-publish.yml index b5dbd4357..89234c240 100644 --- a/.github/workflows/manual-publish.yml +++ b/.github/workflows/manual-publish.yml @@ -61,9 +61,7 @@ jobs: registry-url: "https://registry.npmjs.org" - name: Update npm - # Pin npm@11: npm@latest is now 12.x (needs node >=22) and fails - # EBADENGINE on the node-20 runner (.node-version). npm 11 supports node ^20.17. - run: npm install -g npm@11 + run: npm install -g npm@latest - name: Setup Bun id: setup-bun diff --git a/.github/workflows/preview-publish.yml b/.github/workflows/preview-publish.yml index 959cd9d50..225235f9f 100644 --- a/.github/workflows/preview-publish.yml +++ b/.github/workflows/preview-publish.yml @@ -25,9 +25,7 @@ jobs: registry-url: "https://registry.npmjs.org" - name: Update npm - # Pin npm@11: npm@latest is now 12.x (needs node >=22) and fails - # EBADENGINE on the node-20 runner (.node-version). npm 11 supports node ^20.17. - run: npm install -g npm@11 + run: npm install -g npm@latest working-directory: . - name: Setup Bun From b0d7b88223700fa55f899e00d8d064ca812d64bc Mon Sep 17 00:00:00 2001 From: talge-a11y Date: Mon, 10 Aug 2026 15:20:35 +0300 Subject: [PATCH 23/23] 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"] - } - } -}