Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
ba7c277
feat(realtime): add realtime-handler resource and CLI commands
ImriKochWix Jun 30, 2026
25640a2
fix(lint): apply biome formatting and unused import fixes
ImriKochWix Jun 30, 2026
c26a3b3
fix(realtime): create handler inside base44/ dir, not project root
ImriKochWix Jun 30, 2026
0f4376a
fix(realtime): scaffold imports RealtimeHandler from @base44/sdk
ImriKochWix Jun 30, 2026
7e255e9
fix(realtime): scaffold includes State/Message generic type parameters
ImriKochWix Jun 30, 2026
d50d315
feat(types): auto-generate RealtimeHandlerRegistry from schema.jsonc
ImriKochWix Jun 30, 2026
351dcf1
fix(types): detect SDK package name and use module context in types.d.ts
ImriKochWix Jun 30, 2026
4625332
fix(lint): resolve Biome errors in realtime handler types
ImriKochWix Jun 30, 2026
b5cd59f
fix(realtime): use /realtime-handlers endpoint for handler deploy
ImriKochWix Jun 30, 2026
7681728
fix(types): compile realtime messages as a named catalog, drop the regex
ImriKochWix Jul 5, 2026
f9f2ad8
feat(types)!: rename realtime schema sections inbound/outbound -> toC…
ImriKochWix Jul 5, 2026
958f77a
refactor(cli): rename realtime -> actor (RealtimeHandler -> Actor)
ImriKochWix Jul 9, 2026
7a34025
fix(cli-ci): organize imports (biome) + pin npm@11 for publish
ImriKochWix Jul 9, 2026
408e870
feat(types): emit declare module for base44:runtime/actors
ImriKochWix Jul 26, 2026
b5de63d
refactor(types): base44:runtime/actors re-exports only Actor
ImriKochWix Jul 27, 2026
cd28d86
feat(actor): scaffold imports Actor from base44:runtime/actors
ImriKochWix Jul 28, 2026
0a1ca6a
feat(actor): regenerate types after `actor new` so the scaffolded bas…
ImriKochWix Jul 28, 2026
a07a713
fix(actor): scaffold matches the SDK Actor API
ImriKochWix Jul 30, 2026
52928da
fix(actor): make base44:runtime/actors actually resolve in the editor
ImriKochWix Jul 30, 2026
53cf828
feat(actor): scaffold schema.jsonc and type the actor from ActorRegistry
ImriKochWix Jul 30, 2026
e47095c
fix(actor): scaffold a default export — the deploy bundler needs it
ImriKochWix Jul 30, 2026
e5348b7
ci: revert npm@11 pin in publish workflows (not needed)
ImriKochWix Jul 30, 2026
1e237d3
Merge remote-tracking branch 'origin/main' into feat/realtime-handler
ImriKochWix Jul 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions packages/cli/src/cli/commands/actor/deploy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
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 {
deployActorsSequentially,
type SingleActorDeployResult,
} from "@/core/resources/actor/deploy.js";
import type { Actor } from "@/core/resources/actor/schema.js";

function parseNames(args: string[]): string[] {
return args
.flatMap((arg) => arg.split(","))
.map((n) => n.trim())
.filter(Boolean);
}

function resolveActorsToDeploy(names: string[], allActors: Actor[]): Actor[] {
if (names.length === 0) return allActors;

const notFound = names.filter((n) => !allActors.some((a) => a.name === n));
if (notFound.length > 0) {
throw new InvalidInputError(
`Actor${notFound.length > 1 ? "s" : ""} not found in project: ${notFound.join(", ")}`,
);
}
return allActors.filter((a) => names.includes(a.name));
}

function formatDeployResult(
result: SingleActorDeployResult,
log: Logger,
): void {
const label = result.name.padEnd(25);
if (result.status === "deployed") {
const timing = result.durationMs
? theme.styles.dim(` (${(result.durationMs / 1000).toFixed(1)}s)`)
: "";
log.success(`${label} deployed${timing}`);
} else if (result.status === "unchanged") {
log.success(`${label} unchanged`);
} else {
log.error(`${label} error: ${result.error}`);
}
}

function buildDeploySummary(results: SingleActorDeployResult[]): string {
const deployed = results.filter((r) => r.status === "deployed").length;
const unchanged = results.filter((r) => r.status === "unchanged").length;
const failed = results.filter((r) => r.status === "error").length;

const parts: string[] = [];
if (deployed > 0) parts.push(`${deployed} deployed`);
if (unchanged > 0) parts.push(`${unchanged} unchanged`);
if (failed > 0) parts.push(`${failed} error${failed !== 1 ? "s" : ""}`);
return parts.join(", ") || "No actors deployed";
}

async function deployActorAction(
{ log }: CLIContext,
names: string[],
): Promise<RunCommandResult> {
const { actors } = await readProjectConfig();
const toDeploy = resolveActorsToDeploy(names, actors);

if (toDeploy.length === 0) {
return {
outroMessage: "No actors found. Create actors in the 'actors' directory.",
};
}

log.info(
`Found ${toDeploy.length} ${toDeploy.length === 1 ? "actor" : "actors"} to deploy`,
);

let completed = 0;
const total = toDeploy.length;

const results = await deployActorsSequentially(toDeploy, {
onStart: (startNames) => {
const label =
startNames.length === 1 ? startNames[0] : `${startNames.length} actors`;
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 actors to Base44")
.argument("[names...]", "Actor names to deploy (deploys all if omitted)")
.action(async (ctx: CLIContext, rawNames: string[]) => {
const names = parseNames(rawNames);
return deployActorAction(ctx, names);
});
}
10 changes: 10 additions & 0 deletions packages/cli/src/cli/commands/actor/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Command } from "commander";
import { getDeployCommand } from "./deploy.js";
import { getNewCommand } from "./new.js";

export function getActorCommand(): Command {
return new Command("actor")
.description("Manage actors")
.addCommand(getNewCommand())
.addCommand(getDeployCommand());
}
103 changes: 103 additions & 0 deletions packages/cli/src/cli/commands/actor/new.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { dirname, join } from "node:path";
import type { Command } from "commander";
import type { CLIContext, RunCommandResult } from "@/cli/types.js";
import { Base44Command } from "@/cli/utils/index.js";
import { InvalidInputError } from "@/core/errors.js";
import { readProjectConfig } from "@/core/index.js";
import { generateTypesFile, updateProjectConfig } from "@/core/types/index.js";
import { pathExists, writeFile } from "@/core/utils/fs.js";

function buildActorScaffold(actorName: string): string {
return `import { Actor } from "base44:runtime/actors";
import type { ActorRegistry, Conn } from "@base44/sdk";

// Message types are generated from ./schema.jsonc by \`base44 types generate\` —
// the same source the client is typed from, so the two can't drift.
type Messages = ActorRegistry["${actorName}"];
type Incoming = Messages["toServer"];
type Outgoing = Messages["toClient"];

// The deploy bundler imports the actor as the entry's default export.
export default class ${actorName} extends Actor<Incoming, Outgoing> {
handleConnect(conn: Conn<Outgoing>) {
console.log("Connected:", conn.id);
}
handleMessage(conn: Conn<Outgoing>, msg: Incoming) {
console.log("Message:", msg);
}
handleTick() {}
handleClose(conn: Conn<Outgoing>) {}
}
`;
}

// Starter message catalog. Each message is a type-less object schema (the
// generator injects the \`type\` discriminant); shared shapes go under \`types\`
// and are referenced via #/types/<Name>.
function buildActorSchema(): string {
return `{
"types": {},
// Messages this actor sends to clients (server → client).
"toClient": {
"welcome": {
"properties": { "message": { "type": "string" } },
"required": ["message"]
}
},
// Messages clients send to this actor (client → server).
"toServer": {
"hello": {
"properties": { "name": { "type": "string" } },
"required": ["name"]
}
}
}
`;
}

async function newActorAction(
_ctx: CLIContext,
actorName: string,
): Promise<RunCommandResult> {
const { project } = await readProjectConfig();
const actorsDir = join(dirname(project.configPath), project.actorsDir);
const actorDir = join(actorsDir, actorName);

if (await pathExists(actorDir)) {
throw new InvalidInputError(
`Actor "${actorName}" already exists at ${actorDir}`,
);
}

const entryPath = join(actorDir, "entry.ts");
await writeFile(entryPath, buildActorScaffold(actorName));
await writeFile(join(actorDir, "schema.jsonc"), buildActorSchema());

// Regenerate types so the scaffolded `base44:runtime/actors` import + the
// schema-derived ActorRegistry types resolve immediately (re-read to pick up
// the actor and its schema just written).
const { entities, functions, agents, connectors, actors } =
await readProjectConfig();
await generateTypesFile({
projectRoot: project.root,
entities,
functions,
agents,
connectors,
actors,
});
await updateProjectConfig(project.root);

return {
outroMessage: `Created actor "${actorName}" at ${entryPath} — define its messages in schema.jsonc`,
};
}

export function getNewCommand(): Command {
return new Base44Command("new")
.description("Create a new actor scaffold")
.argument("<ActorName>", "Name of the actor class")
.action(async (ctx: CLIContext, actorName: string) => {
return newActorAction(ctx, actorName);
});
}
16 changes: 14 additions & 2 deletions packages/cli/src/cli/commands/project/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,15 @@ export async function deployAction(
};
}

const { project, entities, functions, agents, connectors, authConfig } =
projectData;
const {
project,
entities,
functions,
actors,
agents,
connectors,
authConfig,
} = projectData;

// Build summary of what will be deployed
const summaryLines: string[] = [];
Expand All @@ -60,6 +67,11 @@ export async function deployAction(
` - ${functions.length} ${functions.length === 1 ? "function" : "functions"}`,
);
}
if (actors.length > 0) {
summaryLines.push(
` - ${actors.length} ${actors.length === 1 ? "actor" : "actors"}`,
);
}
if (agents.length > 0) {
summaryLines.push(
` - ${agents.length} ${agents.length === 1 ? "agent" : "agents"}`,
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/cli/commands/types/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const TYPES_FILE_PATH = "base44/.types/types.d.ts";
async function generateTypesAction({
runTask,
}: CLIContext): Promise<RunCommandResult> {
const { entities, functions, agents, connectors, project } =
const { entities, functions, agents, connectors, actors, project } =
await readProjectConfig();

await runTask("Generating types", async () => {
Expand All @@ -19,6 +19,7 @@ async function generateTypesAction({
functions,
agents,
connectors,
actors,
});
});

Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/cli/program.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Command, Option } from "commander";
import { getActorCommand } from "@/cli/commands/actor/index.js";
import { getAgentSkillsCommand } from "@/cli/commands/agent-skills/index.js";
import { getAgentsCommand } from "@/cli/commands/agents/index.js";
import { getAuthCommand } from "@/cli/commands/auth/index.js";
Expand Down Expand Up @@ -92,6 +93,9 @@ export function createProgram(context: CLIContext): Command {
// Register functions commands
program.addCommand(getFunctionsCommand());

// Register actor commands
program.addCommand(getActorCommand());

// Register secrets commands
program.addCommand(getSecretsCommand());

Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ export function getTypesOutputPath(projectRoot: string): string {
return join(projectRoot, PROJECT_SUBDIR, TYPES_OUTPUT_SUBDIR, TYPES_FILENAME);
}

/**
* Ambient declaration for the `base44:runtime/actors` virtual module. Kept in
* its own script-context file (no exports) so the `declare module` is an ambient
* declaration — in the module-scoped types.d.ts it would be a failed augmentation
* of a non-existent module and never resolve.
*/
export function getActorRuntimeTypesPath(projectRoot: string): string {
return join(projectRoot, PROJECT_SUBDIR, TYPES_OUTPUT_SUBDIR, "runtime.d.ts");
}

export function getBase44ApiUrl(): string {
return process.env.BASE44_API_URL || "https://app.base44.com";
}
Expand Down
25 changes: 23 additions & 2 deletions packages/cli/src/core/project/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { agentSkillResource } from "@/core/resources/agent-skill/index.js";
import { authConfigResource } from "@/core/resources/auth-config/index.js";
Expand Down Expand Up @@ -61,6 +62,7 @@ class ProjectConfigReader {
project: { ...project, root, configPath },
entities,
functions,
actors: localResources.actors,
agents: localResources.agents,
agentSkills: localResources.agentSkills,
connectors: localResources.connectors,
Expand Down Expand Up @@ -107,17 +109,34 @@ class ProjectConfigReader {
project: ProjectConfig,
): Promise<ProjectResources> {
const configDir = dirname(configPath);
const [entities, functions, agents, agentSkills, connectors, authConfig] =
const [
entities,
functions,
actors,
agents,
agentSkills,
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)),
agentSkillResource.readAll(join(configDir, project.agentSkillsDir)),
connectorResource.readAll(join(configDir, project.connectorsDir)),
authConfigResource.readAll(join(configDir, project.authDir)),
]);

return { entities, functions, agents, agentSkills, connectors, authConfig };
return {
entities,
functions,
actors,
agents,
agentSkills,
connectors,
authConfig,
};
}

private assertPluginProjectDoesNotLoadPlugins(
Expand Down Expand Up @@ -187,6 +206,7 @@ class ProjectConfigReader {
return {
entities: markPluginEntities(resources.entities, namespace),
functions: namespacePluginFunctions(resources.functions, namespace),
actors: [],
agents: [],
agentSkills: [],
connectors: [],
Expand Down Expand Up @@ -244,6 +264,7 @@ class ProjectConfigReader {
return {
entities,
functions,
actors: [],
agents: [],
agentSkills: [],
connectors: [],
Expand Down
Loading
Loading