From ba7c277c86370ce83026839b4d28d420219fbbfb Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 30 Jun 2026 11:21:56 +0300 Subject: [PATCH 01/13] 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 | 12 +- packages/cli/src/core/project/deploy.ts | 8 +- 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, 428 insertions(+), 5 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 ad07fa79..1758c6a0 100644 --- a/packages/cli/src/cli/commands/project/deploy.ts +++ b/packages/cli/src/cli/commands/project/deploy.ts @@ -45,7 +45,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 @@ -60,6 +60,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 00000000..cbc57a33 --- /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 00000000..171356a5 --- /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 00000000..ad94f483 --- /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 cfa1a45b..5f29d79e 100644 --- a/packages/cli/src/cli/program.ts +++ b/packages/cli/src/cli/program.ts @@ -8,6 +8,7 @@ import { getConnectorsCommand } from "@/cli/commands/connectors/index.js"; import { getDashboardCommand } from "@/cli/commands/dashboard/index.js"; import { getEntitiesPushCommand } from "@/cli/commands/entities/push.js"; import { getFunctionsCommand } from "@/cli/commands/functions/index.js"; +import { getRealtimeCommand } from "@/cli/commands/realtime/index.js"; import { getCreateCommand } from "@/cli/commands/project/create.js"; import { getDeployCommand } from "@/cli/commands/project/deploy.js"; import { getLinkCommand } from "@/cli/commands/project/link.js"; @@ -82,6 +83,9 @@ export function createProgram(context: CLIContext): Command { // Register functions commands program.addCommand(getFunctionsCommand()); + // Register realtime commands + program.addCommand(getRealtimeCommand()); + // Register secrets commands program.addCommand(getSecretsCommand()); diff --git a/packages/cli/src/core/project/config.ts b/packages/cli/src/core/project/config.ts index bb3ff5c2..9ada8bda 100644 --- a/packages/cli/src/core/project/config.ts +++ b/packages/cli/src/core/project/config.ts @@ -28,6 +28,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; @@ -60,6 +64,7 @@ class ProjectConfigReader { project: { ...project, root, configPath }, entities, functions, + realtimeHandlers: localResources.realtimeHandlers, agents: localResources.agents, connectors: localResources.connectors, authConfig: localResources.authConfig, @@ -105,16 +110,17 @@ class ProjectConfigReader { project: ProjectConfig, ): Promise { const configDir = dirname(configPath); - const [entities, functions, agents, connectors, authConfig] = + const [entities, functions, realtimeHandlers, agents, 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)), connectorResource.readAll(join(configDir, project.connectorsDir)), authConfigResource.readAll(join(configDir, project.authDir)), ]); - return { entities, functions, agents, connectors, authConfig }; + return { entities, functions, realtimeHandlers, agents, connectors, authConfig }; } private assertPluginProjectDoesNotLoadPlugins( @@ -184,6 +190,7 @@ class ProjectConfigReader { return { entities: markPluginEntities(resources.entities, namespace), functions: namespacePluginFunctions(resources.functions, namespace), + realtimeHandlers: [], agents: [], connectors: [], authConfig: [], @@ -240,6 +247,7 @@ class ProjectConfigReader { return { entities, functions, + realtimeHandlers: [], agents: [], connectors: [], authConfig: [], diff --git a/packages/cli/src/core/project/deploy.ts b/packages/cli/src/core/project/deploy.ts index 68089173..50e46017 100644 --- a/packages/cli/src/core/project/deploy.ts +++ b/packages/cli/src/core/project/deploy.ts @@ -11,6 +11,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"; /** @@ -20,11 +21,12 @@ import { deploySite } from "@/core/site/index.js"; * @returns true if there are entities, functions, agents, connectors, or a configured site to deploy */ export function hasResourcesToDeploy(projectData: ProjectData): boolean { - const { project, entities, functions, agents, connectors, authConfig } = + const { project, entities, functions, realtimeHandlers, agents, connectors, authConfig } = projectData; 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 hasConnectors = connectors.length > 0; const hasAuthConfig = authConfig.length > 0; @@ -32,6 +34,7 @@ export function hasResourcesToDeploy(projectData: ProjectData): boolean { return ( hasEntities || hasFunctions || + hasRealtimeHandlers || hasAgents || hasConnectors || hasAuthConfig || @@ -69,7 +72,7 @@ export async function deployAll( projectData: ProjectData, options?: DeployAllOptions, ): Promise { - const { project, entities, functions, agents, connectors, authConfig } = + const { project, entities, functions, realtimeHandlers, agents, connectors, authConfig } = projectData; await entityResource.push(entities); @@ -77,6 +80,7 @@ export async function deployAll( onStart: options?.onFunctionStart, onResult: options?.onFunctionResult, }); + await deployRealtimeHandlersSequentially(realtimeHandlers); await agentResource.push(agents); await authConfigResource.push(authConfig); const { results: connectorResults } = await pushConnectors(connectors); diff --git a/packages/cli/src/core/project/schema.ts b/packages/cli/src/core/project/schema.ts index 22938a99..c7cc7548 100644 --- a/packages/cli/src/core/project/schema.ts +++ b/packages/cli/src/core/project/schema.ts @@ -45,6 +45,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"), connectorsDir: z.string().optional().default("connectors"), authDir: z.string().optional().default("auth"), diff --git a/packages/cli/src/core/project/types.ts b/packages/cli/src/core/project/types.ts index 7b581b3c..921704e3 100644 --- a/packages/cli/src/core/project/types.ts +++ b/packages/cli/src/core/project/types.ts @@ -4,6 +4,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"; interface ProjectWithPaths extends ProjectConfig { root: string; @@ -19,6 +20,7 @@ export interface ProjectData { project: ProjectWithPaths; entities: Entity[]; functions: BackendFunction[]; + realtimeHandlers: RealtimeHandler[]; agents: AgentConfig[]; connectors: ConnectorResource[]; authConfig: AuthConfig[]; 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 00000000..c13abda2 --- /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 00000000..7d42b7e2 --- /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 00000000..4afd2c75 --- /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 00000000..90b197a7 --- /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 00000000..9a61f37c --- /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 00000000..df002b4c --- /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 25640a2c50b321904e2ca6df858fe16d4f4edca8 Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 30 Jun 2026 13:54:46 +0300 Subject: [PATCH 02/13] fix(lint): apply biome formatting and unused import fixes Co-Authored-By: Claude Sonnet 4.6 --- .../cli/src/cli/commands/project/deploy.ts | 11 ++++- .../cli/src/cli/commands/realtime/deploy.ts | 4 +- packages/cli/src/cli/program.ts | 2 +- packages/cli/src/core/project/config.ts | 40 ++++++++++++------- packages/cli/src/core/project/deploy.ts | 22 ++++++++-- .../core/resources/realtime-handler/api.ts | 8 +--- .../core/resources/realtime-handler/config.ts | 7 +++- .../core/resources/realtime-handler/deploy.ts | 2 +- .../core/resources/realtime-handler/schema.ts | 4 +- 9 files changed, 66 insertions(+), 34 deletions(-) diff --git a/packages/cli/src/cli/commands/project/deploy.ts b/packages/cli/src/cli/commands/project/deploy.ts index 1758c6a0..0d2f5848 100644 --- a/packages/cli/src/cli/commands/project/deploy.ts +++ b/packages/cli/src/cli/commands/project/deploy.ts @@ -45,8 +45,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 cbc57a33..7a434e51 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/cli/program.ts b/packages/cli/src/cli/program.ts index 5f29d79e..4e7b8361 100644 --- a/packages/cli/src/cli/program.ts +++ b/packages/cli/src/cli/program.ts @@ -8,12 +8,12 @@ import { getConnectorsCommand } from "@/cli/commands/connectors/index.js"; import { getDashboardCommand } from "@/cli/commands/dashboard/index.js"; import { getEntitiesPushCommand } from "@/cli/commands/entities/push.js"; import { getFunctionsCommand } from "@/cli/commands/functions/index.js"; -import { getRealtimeCommand } from "@/cli/commands/realtime/index.js"; import { getCreateCommand } from "@/cli/commands/project/create.js"; import { getDeployCommand } from "@/cli/commands/project/deploy.js"; 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 { 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"; diff --git a/packages/cli/src/core/project/config.ts b/packages/cli/src/core/project/config.ts index 9ada8bda..35c4a10e 100644 --- a/packages/cli/src/core/project/config.ts +++ b/packages/cli/src/core/project/config.ts @@ -28,10 +28,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; @@ -110,17 +107,30 @@ class ProjectConfigReader { project: ProjectConfig, ): Promise { const configDir = dirname(configPath); - const [entities, functions, realtimeHandlers, agents, 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)), - connectorResource.readAll(join(configDir, project.connectorsDir)), - authConfigResource.readAll(join(configDir, project.authDir)), - ]); - - return { entities, functions, realtimeHandlers, agents, connectors, authConfig }; + const [ + entities, + functions, + realtimeHandlers, + agents, + 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)), + connectorResource.readAll(join(configDir, project.connectorsDir)), + authConfigResource.readAll(join(configDir, project.authDir)), + ]); + + return { + entities, + functions, + realtimeHandlers, + agents, + connectors, + authConfig, + }; } private assertPluginProjectDoesNotLoadPlugins( diff --git a/packages/cli/src/core/project/deploy.ts b/packages/cli/src/core/project/deploy.ts index 50e46017..e905aedb 100644 --- a/packages/cli/src/core/project/deploy.ts +++ b/packages/cli/src/core/project/deploy.ts @@ -21,8 +21,15 @@ import { deploySite } from "@/core/site/index.js"; * @returns true if there are entities, functions, agents, connectors, or a configured site to deploy */ export function hasResourcesToDeploy(projectData: ProjectData): boolean { - const { project, entities, functions, realtimeHandlers, agents, connectors, authConfig } = - projectData; + const { + project, + entities, + functions, + realtimeHandlers, + agents, + connectors, + authConfig, + } = projectData; const hasSite = Boolean(project.site?.outputDirectory); const hasEntities = entities.length > 0; const hasFunctions = functions.length > 0; @@ -72,8 +79,15 @@ export async function deployAll( projectData: ProjectData, options?: DeployAllOptions, ): Promise { - const { project, entities, functions, realtimeHandlers, agents, connectors, authConfig } = - projectData; + const { + project, + entities, + functions, + realtimeHandlers, + agents, + connectors, + authConfig, + } = projectData; await entityResource.push(entities); await deployFunctionsSequentially(functions, { diff --git a/packages/cli/src/core/resources/realtime-handler/api.ts b/packages/cli/src/core/resources/realtime-handler/api.ts index c13abda2..71c7df40 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 7d42b7e2..42adc18e 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 4afd2c75..64e78650 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 df002b4c..78dee24b 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 c26a3b3a7c14d548cca751322bc14056af2358b9 Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 30 Jun 2026 14:38:28 +0300 Subject: [PATCH 03/13] 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 ad94f483..816b0e2f 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 0f4376a8bffd9fdef4f006531f35eb2d1357f1d7 Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 30 Jun 2026 14:39:29 +0300 Subject: [PATCH 04/13] 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 816b0e2f..455a716b 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 7e255e9cbbb6bfdc67dabde31aade0d8adbece50 Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 30 Jun 2026 14:40:44 +0300 Subject: [PATCH 05/13] 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 455a716b..52d23c41 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 d50d3153dcedfcd6fe8aae1417f6acd8dee818cf Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 30 Jun 2026 15:10:06 +0300 Subject: [PATCH 06/13] 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 f74545af..973fd318 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 42adc18e..3466c6da 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 78dee24b..b41ec8cd 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 6297f9c1..2248f067 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 9232d9e1..98acbc3e 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 00000000..91a9c29a --- /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 00000000..760269e5 --- /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 351dcf1dc5f01bd4ed43bc8caaa4dd0e721ea9ba Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 30 Jun 2026 15:16:39 +0300 Subject: [PATCH 07/13] 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 2248f067..6ecdfe00 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 462533278816499ed5e412e0b7d97039447580b3 Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 30 Jun 2026 15:19:24 +0300 Subject: [PATCH 08/13] 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 3466c6da..bf4002eb 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 b41ec8cd..fe0027cb 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 6ecdfe00..47f86ac9 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 b5cd59ff9de2dd322b82f8f148b4d0778e995390 Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 30 Jun 2026 16:14:38 +0300 Subject: [PATCH 09/13] 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 71c7df40..b185539c 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 768172821401732d4e6feb542ccf104eb7f0c6e2 Mon Sep 17 00:00:00 2001 From: imrik Date: Sun, 5 Jul 2026 13:54:15 +0300 Subject: [PATCH 10/13] 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 bf4002eb..bb5df4e8 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 fe0027cb..b8e95374 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 47f86ac9..d701abdd 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 00000000..08b9bb66 --- /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 760269e5..2a077651 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 f9f2ad8a27ee9367f75451f0d2a7bcc7dcde8a81 Mon Sep 17 00:00:00 2001 From: imrik Date: Mon, 6 Jul 2026 00:30:03 +0300 Subject: [PATCH 11/13] 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 bb5df4e8..3b924440 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 b8e95374..9fb6c129 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 d701abdd..3451c7ea 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 08b9bb66..fcb97ae6 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 2a077651..4696dd16 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 958f77a0f40dad04ecda9805574b8f0e960d9cfc Mon Sep 17 00:00:00 2001 From: imrik Date: Thu, 9 Jul 2026 16:27:39 +0300 Subject: [PATCH 12/13] 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 | 51 +++++++------- .../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 | 34 ++++----- 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 | 63 ++++++++--------- packages/cli/tests/cli/types_generate.spec.ts | 10 +-- ...s-realtime.spec.ts => types-actor.spec.ts} | 18 ++--- .../{realtime => actors}/ChatRoom/entry.ts | 4 +- .../ChatRoom/schema.jsonc | 0 24 files changed, 257 insertions(+), 292 deletions(-) rename packages/cli/src/cli/commands/{realtime => actor}/deploy.ts (62%) 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} (86%) 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 62% rename from packages/cli/src/cli/commands/realtime/deploy.ts rename to packages/cli/src/cli/commands/actor/deploy.ts index 7a434e51..85ad5869 100644 --- a/packages/cli/src/cli/commands/realtime/deploy.ts +++ b/packages/cli/src/cli/commands/actor/deploy.ts @@ -3,13 +3,13 @@ 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 { + deployActorsSequentially, + type SingleActorDeployResult, +} from "@/core/resources/actor/deploy.js"; +import type { Actor } from "@/core/resources/actor/schema.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 @@ -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,36 @@ 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.", + "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} actors`; log.step( theme.styles.dim(`[${completed + 1}/${total}] Deploying ${label}...`), ); @@ -112,10 +107,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 171356a5..6be9a149 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 52d23c41..a5f4c659 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 0d2f5848..98f90281 100644 --- a/packages/cli/src/cli/commands/project/deploy.ts +++ b/packages/cli/src/cli/commands/project/deploy.ts @@ -49,7 +49,7 @@ export async function deployAction( project, entities, functions, - realtimeHandlers, + actors, agents, connectors, authConfig, @@ -67,9 +67,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 973fd318..e06de2b4 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 4e7b8361..68c2450b 100644 --- a/packages/cli/src/cli/program.ts +++ b/packages/cli/src/cli/program.ts @@ -13,7 +13,7 @@ import { getDeployCommand } from "@/cli/commands/project/deploy.js"; 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 { getRealtimeCommand } from "@/cli/commands/realtime/index.js"; +import { getActorCommand } from "@/cli/commands/actor/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"; @@ -83,8 +83,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 secrets commands program.addCommand(getSecretsCommand()); diff --git a/packages/cli/src/core/project/config.ts b/packages/cli/src/core/project/config.ts index 35c4a10e..de69a622 100644 --- a/packages/cli/src/core/project/config.ts +++ b/packages/cli/src/core/project/config.ts @@ -28,7 +28,7 @@ import { type BackendFunction, functionResource, } from "@/core/resources/function/index.js"; -import { realtimeHandlerResource } from "@/core/resources/realtime-handler/index.js"; +import { actorResource } from "@/core/resources/actor/index.js"; import { readJsonFile } from "@/core/utils/fs.js"; type ProjectResources = Omit; @@ -61,7 +61,7 @@ class ProjectConfigReader { project: { ...project, root, configPath }, entities, functions, - realtimeHandlers: localResources.realtimeHandlers, + actors: localResources.actors, agents: localResources.agents, connectors: localResources.connectors, authConfig: localResources.authConfig, @@ -107,26 +107,20 @@ class ProjectConfigReader { project: ProjectConfig, ): Promise { const configDir = dirname(configPath); - const [ - entities, - functions, - realtimeHandlers, - agents, - 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)), - connectorResource.readAll(join(configDir, project.connectorsDir)), - authConfigResource.readAll(join(configDir, project.authDir)), - ]); + const [entities, functions, actors, agents, connectors, authConfig] = + await Promise.all([ + entityResource.readAll(join(configDir, project.entitiesDir)), + functionResource.readAll(join(configDir, project.functionsDir)), + actorResource.readAll(join(configDir, project.actorsDir)), + agentResource.readAll(join(configDir, project.agentsDir)), + connectorResource.readAll(join(configDir, project.connectorsDir)), + authConfigResource.readAll(join(configDir, project.authDir)), + ]); return { entities, functions, - realtimeHandlers, + actors, agents, connectors, authConfig, @@ -200,7 +194,7 @@ class ProjectConfigReader { return { entities: markPluginEntities(resources.entities, namespace), functions: namespacePluginFunctions(resources.functions, namespace), - realtimeHandlers: [], + actors: [], agents: [], connectors: [], authConfig: [], @@ -257,7 +251,7 @@ class ProjectConfigReader { return { entities, functions, - realtimeHandlers: [], + actors: [], agents: [], connectors: [], authConfig: [], diff --git a/packages/cli/src/core/project/deploy.ts b/packages/cli/src/core/project/deploy.ts index e905aedb..1ee3a3e4 100644 --- a/packages/cli/src/core/project/deploy.ts +++ b/packages/cli/src/core/project/deploy.ts @@ -11,7 +11,7 @@ import { deployFunctionsSequentially, type SingleFunctionDeployResult, } from "@/core/resources/function/deploy.js"; -import { deployRealtimeHandlersSequentially } from "@/core/resources/realtime-handler/deploy.js"; +import { deployActorsSequentially } from "@/core/resources/actor/deploy.js"; import { deploySite } from "@/core/site/index.js"; /** @@ -25,7 +25,7 @@ export function hasResourcesToDeploy(projectData: ProjectData): boolean { project, entities, functions, - realtimeHandlers, + actors, agents, connectors, authConfig, @@ -33,7 +33,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 hasConnectors = connectors.length > 0; const hasAuthConfig = authConfig.length > 0; @@ -41,7 +41,7 @@ export function hasResourcesToDeploy(projectData: ProjectData): boolean { return ( hasEntities || hasFunctions || - hasRealtimeHandlers || + hasActors || hasAgents || hasConnectors || hasAuthConfig || @@ -83,7 +83,7 @@ export async function deployAll( project, entities, functions, - realtimeHandlers, + actors, agents, connectors, authConfig, @@ -94,7 +94,7 @@ export async function deployAll( onStart: options?.onFunctionStart, onResult: options?.onFunctionResult, }); - await deployRealtimeHandlersSequentially(realtimeHandlers); + await deployActorsSequentially(actors); await agentResource.push(agents); await authConfigResource.push(authConfig); const { results: connectorResults } = await pushConnectors(connectors); diff --git a/packages/cli/src/core/project/schema.ts b/packages/cli/src/core/project/schema.ts index c7cc7548..24cc5364 100644 --- a/packages/cli/src/core/project/schema.ts +++ b/packages/cli/src/core/project/schema.ts @@ -45,7 +45,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"), connectorsDir: z.string().optional().default("connectors"), authDir: z.string().optional().default("auth"), diff --git a/packages/cli/src/core/project/types.ts b/packages/cli/src/core/project/types.ts index 921704e3..d8da66a8 100644 --- a/packages/cli/src/core/project/types.ts +++ b/packages/cli/src/core/project/types.ts @@ -4,7 +4,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"; +import type { Actor } from "@/core/resources/actor/index.js"; interface ProjectWithPaths extends ProjectConfig { root: string; @@ -20,7 +20,7 @@ export interface ProjectData { project: ProjectWithPaths; entities: Entity[]; functions: BackendFunction[]; - realtimeHandlers: RealtimeHandler[]; + actors: Actor[]; agents: AgentConfig[]; connectors: ConnectorResource[]; authConfig: AuthConfig[]; 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 00000000..83e3d760 --- /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 3b924440..74628368 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 00000000..0e02365c --- /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 00000000..60d5a88d --- /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 9fb6c129..9dd490e6 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 b185539c..00000000 --- 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 64e78650..00000000 --- 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 9a61f37c..00000000 --- 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 3451c7ea..ad4b8562 100644 --- a/packages/cli/src/core/types/generator.ts +++ b/packages/cli/src/core/types/generator.ts @@ -8,7 +8,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 type { Actor } from "@/core/resources/actor/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,19 @@ 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; - // 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 +201,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 }; @@ -264,8 +257,8 @@ 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, ); } diff --git a/packages/cli/tests/cli/types_generate.spec.ts b/packages/cli/tests/cli/types_generate.spec.ts index 98acbc3e..b6bd77f3 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 86% rename from packages/cli/tests/core/types-realtime.spec.ts rename to packages/cli/tests/core/types-actor.spec.ts index fcb97ae6..7bca0892 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", @@ -74,8 +74,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 91a9c29a..db5ad716 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 7a34025b98ef19262feb6ca45bb60d95c72444f5 Mon Sep 17 00:00:00 2001 From: imrik Date: Thu, 9 Jul 2026 16:35:13 +0300 Subject: [PATCH 13/13] 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 +++- packages/cli/src/cli/commands/actor/deploy.ts | 11 ++++------ packages/cli/src/cli/program.ts | 2 +- packages/cli/src/core/project/config.ts | 2 +- packages/cli/src/core/project/deploy.ts | 2 +- packages/cli/src/core/project/types.ts | 2 +- packages/cli/src/core/types/generator.ts | 20 ++++++++++++++----- packages/cli/tests/core/types-actor.spec.ts | 9 +++++++-- 9 files changed, 36 insertions(+), 20 deletions(-) diff --git a/.github/workflows/manual-publish.yml b/.github/workflows/manual-publish.yml index 31ad0870..4c855bfe 100644 --- a/.github/workflows/manual-publish.yml +++ b/.github/workflows/manual-publish.yml @@ -53,7 +53,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 276012f1..c5e0cc2b 100644 --- a/.github/workflows/preview-publish.yml +++ b/.github/workflows/preview-publish.yml @@ -22,7 +22,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 diff --git a/packages/cli/src/cli/commands/actor/deploy.ts b/packages/cli/src/cli/commands/actor/deploy.ts index 85ad5869..5232f5cc 100644 --- a/packages/cli/src/cli/commands/actor/deploy.ts +++ b/packages/cli/src/cli/commands/actor/deploy.ts @@ -3,13 +3,13 @@ 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 { deployActorsSequentially, type SingleActorDeployResult, } from "@/core/resources/actor/deploy.js"; import type { Actor } from "@/core/resources/actor/schema.js"; -import { InvalidInputError } from "@/core/errors.js"; -import { readProjectConfig } from "@/core/index.js"; function parseNames(args: string[]): string[] { return args @@ -68,8 +68,7 @@ async function deployActorAction( if (toDeploy.length === 0) { return { - outroMessage: - "No actors found. Create actors in the 'actors' directory.", + outroMessage: "No actors found. Create actors in the 'actors' directory.", }; } @@ -83,9 +82,7 @@ async function deployActorAction( const results = await deployActorsSequentially(toDeploy, { onStart: (startNames) => { const label = - startNames.length === 1 - ? startNames[0] - : `${startNames.length} actors`; + startNames.length === 1 ? startNames[0] : `${startNames.length} actors`; log.step( theme.styles.dim(`[${completed + 1}/${total}] Deploying ${label}...`), ); diff --git a/packages/cli/src/cli/program.ts b/packages/cli/src/cli/program.ts index 68c2450b..f2d5bad5 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 { getAgentsCommand } from "@/cli/commands/agents/index.js"; import { getAuthCommand } from "@/cli/commands/auth/index.js"; import { getLoginCommand } from "@/cli/commands/auth/login.js"; @@ -13,7 +14,6 @@ import { getDeployCommand } from "@/cli/commands/project/deploy.js"; 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 { getActorCommand } from "@/cli/commands/actor/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"; diff --git a/packages/cli/src/core/project/config.ts b/packages/cli/src/core/project/config.ts index de69a622..f4ef9f40 100644 --- a/packages/cli/src/core/project/config.ts +++ b/packages/cli/src/core/project/config.ts @@ -18,6 +18,7 @@ import { ProjectConfigSchema, } from "@/core/project/schema.js"; import type { ProjectData, ProjectRoot } from "@/core/project/types.js"; +import { actorResource } from "@/core/resources/actor/index.js"; import { agentResource } from "@/core/resources/agent/index.js"; import { authConfigResource } from "@/core/resources/auth-config/index.js"; import { connectorResource } from "@/core/resources/connector/index.js"; @@ -28,7 +29,6 @@ import { type BackendFunction, functionResource, } from "@/core/resources/function/index.js"; -import { actorResource } from "@/core/resources/actor/index.js"; import { readJsonFile } from "@/core/utils/fs.js"; type ProjectResources = Omit; diff --git a/packages/cli/src/core/project/deploy.ts b/packages/cli/src/core/project/deploy.ts index 1ee3a3e4..3ba875eb 100644 --- a/packages/cli/src/core/project/deploy.ts +++ b/packages/cli/src/core/project/deploy.ts @@ -1,5 +1,6 @@ import { resolve } from "node:path"; 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 { authConfigResource } from "@/core/resources/auth-config/index.js"; import { @@ -11,7 +12,6 @@ import { deployFunctionsSequentially, type SingleFunctionDeployResult, } from "@/core/resources/function/deploy.js"; -import { deployActorsSequentially } from "@/core/resources/actor/deploy.js"; import { deploySite } from "@/core/site/index.js"; /** diff --git a/packages/cli/src/core/project/types.ts b/packages/cli/src/core/project/types.ts index d8da66a8..9ad7a2e2 100644 --- a/packages/cli/src/core/project/types.ts +++ b/packages/cli/src/core/project/types.ts @@ -1,10 +1,10 @@ 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 { 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 { Actor } from "@/core/resources/actor/index.js"; interface ProjectWithPaths extends ProjectConfig { root: string; diff --git a/packages/cli/src/core/types/generator.ts b/packages/cli/src/core/types/generator.ts index ad4b8562..880cf4fc 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 { Actor } from "@/core/resources/actor/schema.js"; import { readJsonFile, writeFile } from "@/core/utils/fs.js"; interface GenerateTypesInput { @@ -186,8 +186,14 @@ async function compileActor(actor: Actor): Promise { 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 actor name so names (Pt, Snake, …) can't // collide across actors or with entity interfaces. Messages additionally carry @@ -227,7 +233,10 @@ async function compileActor(actor: Actor): Promise { 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; @@ -263,7 +272,8 @@ async function compileActor(actor: Actor): Promise { ); } - 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/core/types-actor.spec.ts b/packages/cli/tests/core/types-actor.spec.ts index 7bca0892..1edc0d8f 100644 --- a/packages/cli/tests/core/types-actor.spec.ts +++ b/packages/cli/tests/core/types-actor.spec.ts @@ -37,7 +37,9 @@ describe("actor 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("actor type generation", () => { }, }, toServer: { - dir: { properties: { angle: { type: "number" } }, required: ["angle"] }, + dir: { + properties: { angle: { type: "number" } }, + required: ["angle"], + }, }, }), ],