From 4205f418a5879daa98665051ea17eefc390e1748 Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Thu, 15 Oct 2026 13:40:09 +0300 Subject: [PATCH] feat(version): compile and declare actors on every version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The versions lane now compiles each actor into a module of its own and declares `actors` on every version, `[]` for an app with none. An absent field is what a CLI from before actors sends, and the platform refuses it (409 actors_undeclared) for an app that has them. - Each actor compiles with the compiler's single-function bundle(), keyed base44/actors//entry.ts (an entry.js too), over the files its entry reaches among its own folder and base44/shared — as apper assembles one. Not cfwBundleInput: its flat main.ts loses the path that makes the entry an actor. - Declared as name, entry, source, module {size, digest} and static_egress (the compiler's STATIC_EGRESS_ARTIFACT_MARKER, installed by the actor prelude). Sorted by name. - Upload slots come after the bundles', each at actors/.mjs, which the client checks the server signed. - BuildTarget.actorsDir; at most 50 actors, refused before compiling. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/versions.md | 14 +- packages/cli/src/core/project/target.ts | 5 +- packages/cli/src/core/version/actors.ts | 175 ++++++++++++++++ packages/cli/src/core/version/api.ts | 49 ++++- packages/cli/src/core/version/artifacts.ts | 4 + packages/cli/src/core/version/functions.ts | 10 +- packages/cli/src/core/version/schema.ts | 20 ++ packages/cli/tests/cli/publish.spec.ts | 192 ++++++++++++++++++ .../cli/tests/cli/testkit/TestAPIServer.ts | 12 +- .../cli/tests/core/project-target.spec.ts | 1 + .../cli/tests/core/version-actors.spec.ts | 163 +++++++++++++++ .../cli/tests/core/version-artifacts.spec.ts | 1 + .../base44/actors/ChatRoom/entry.ts | 5 +- .../with-actors/base44/shared/limits.ts | 1 + 14 files changed, 638 insertions(+), 14 deletions(-) create mode 100644 packages/cli/src/core/version/actors.ts create mode 100644 packages/cli/tests/core/version-actors.spec.ts create mode 100644 packages/cli/tests/fixtures/with-actors/base44/shared/limits.ts diff --git a/docs/versions.md b/docs/versions.md index 4112e776b..1d8f75ec6 100644 --- a/docs/versions.md +++ b/docs/versions.md @@ -1,10 +1,10 @@ # Versions -**Keywords:** versions, publish, deployment, rollback, artifact set, sha256, digest, staged upload, presigned, x-amz-checksum-sha256, entities, agents, backend functions, backend_bundles, functions-compiler, shards, raw payloads, provenance, commit, idempotency key, step, BASE44_VERSIONS_API, env gate, build sandbox +**Keywords:** versions, publish, deployment, rollback, artifact set, sha256, digest, staged upload, presigned, x-amz-checksum-sha256, entities, agents, backend functions, backend_bundles, actors, Durable Objects, functions-compiler, shards, raw payloads, provenance, commit, idempotency key, step, BASE44_VERSIONS_API, env gate, build sandbox A **version** is one immutable thing a build produced — every frontend file, plus the entity and agent payloads the app declares. A **deployment** is a version made live at an environment. Recording one and serving one are separate acts, which is what makes a rollback a deploy of an older version rather than a second code path. -This lives in `src/core/version/`: `artifacts.ts` (the build-output walk and the raw resource reads), `functions.ts` (compiling the backend functions), `api.ts` (the three HTTP calls), `publish.ts` (orchestration, and the step names behind the shared tagger in `core/errors.ts`), `gate.ts` (the env gate), `schema.ts` (wire types). +This lives in `src/core/version/`: `artifacts.ts` (the build-output walk and the raw resource reads), `functions.ts` (compiling the backend functions), `actors.ts` (compiling the actors), `api.ts` (the three HTTP calls), `publish.ts` (orchestration, and the step names behind the shared tagger in `core/errors.ts`), `gate.ts` (the env gate), `schema.ts` (wire types). Where to build and what to collect is **not** here — it is `core/project/target.ts`, because nothing it resolves is about a version and `base44 build` needs the same answer without importing this lane. @@ -20,7 +20,7 @@ It is **not** `src/core/site/` — see [Deployments](deployments.md). That lane `createVersion(artifacts, options)` in `api.ts` — three calls, in this order and no other, so nothing is recorded until the bytes are in place: -1. **Declare.** `POST versions` with `assets` (path, size, digest per file), optionally a `site_worker` beside it, the compiled `backend_bundles`, plus the raw `entities` and `agents` payloads and `source_commit`. The response carries a `session_id` and one presigned PUT per declared file, in declared order: the assets, the Worker's modules, then one per bundle. +1. **Declare.** `POST versions` with `assets` (path, size, digest per file), optionally a `site_worker` beside it, the compiled `backend_bundles` and `actors`, plus the raw `entities` and `agents` payloads and `source_commit`. The response carries a `session_id` and one presigned PUT per declared file, in declared order: the assets, the Worker's modules, one per bundle, then one per actor. Each kind comes after the ones before it, so an app gaining one moves no existing slot. 2. **Upload.** `putPresigned` per file — the same PUT the static deployments lane uses, same ky retry policy. Each PUT sends the server's `Content-Type` **and** its `x-amz-checksum-sha256` verbatim; deriving either locally would 403 on any mapping difference. Uploads are paired with declared files **by position**, not by path: the frontend and the Worker's modules are separate namespaces, so the same name can appear in both and mean two different files. 3. **Finalize.** `POST versions/{session_id}/finalize`, no body. The set was fixed at declare, so there is nothing left for the caller to change. @@ -71,6 +71,14 @@ Compiled during collection, not by `base44 build`: `--no-build` skips only the s The compiler is bundled like the rest of the CLI, and imports `esbuild` and `@deno/loader` — the external runtime packages `base44 dev` uses too — only when it compiles. The standalone binary cannot carry those, so from there an app with functions fails with `DEPENDENCY_NOT_FOUND`; an app without functions publishes as before. +## Actors are compiled here too + +`compileActors` in `actors.ts` compiles each actor into a module of its own — an actor is a Durable Object Worker, never a shard — with the compiler's single-function `bundle()`. Each is declared with its `name` (the folder's, which is also the Durable Object class), its `entry`, the entry file's `source` as written, the module's `size` and `digest`, and the `static_egress` contract it was compiled for: the compiler's `STATIC_EGRESS_ARTIFACT_MARKER`, because an actor's prelude installs the same static-egress fetch every worker entry does. Actors are sorted by name, and each one's module uploads at `actors/.mjs`, a path the client checks the server signed. + +The entry is always `base44/actors//entry.ts`, an `entry.js` included, and that path is not cosmetic: it is how the compiler knows the entry is an actor. That is why this does not go through `cfwBundleInput`, which flattens an entry that reaches nothing to `main.ts`. The files are the ones the entry reaches among its own folder and the shared modules, keyed at `base44/actors//` and `base44/shared/` wherever the project keeps them — which is how apper assembles an actor, so `../../shared/x.ts` resolves and a reach into another actor or a function does not. + +`actors` is always sent, `[]` for an app with none, for the reason `backend_bundles` is: an absent field is what a CLI from before actors sends, and the platform refuses it (409 `actors_undeclared`) for an app that has them. One actor that fails to compile fails the publish at step `build`. The platform caps a version at 50 actors and the CLI refuses more before compiling any; the standalone binary refuses an app with actors as it does one with functions. + ## Why the digest is signed into the URL The platform pins content type, content length **and** sha256 into each presigned PUT, so S3 itself rejects a body that does not hash to the declared digest. The URL is permission to write exactly one payload, once — which is what lets the server commit those bytes with a server-side copy instead of reading them back to re-hash. A frontend of any size is recorded without its bytes passing through a worker. diff --git a/packages/cli/src/core/project/target.ts b/packages/cli/src/core/project/target.ts index 4ef1bb875..5857f24c4 100644 --- a/packages/cli/src/core/project/target.ts +++ b/packages/cli/src/core/project/target.ts @@ -8,13 +8,15 @@ import type { ProjectWithPaths } from "@/core/project/types.js"; /** A project's resolved layout: where its build runs, and what it leaves behind. */ export interface BuildTarget { root: string; - /** Where `entitiesDir`, `agentsDir` and `functionsDir` are resolved from. */ + /** Where `entitiesDir`, `agentsDir`, `functionsDir` and `actorsDir` are + * resolved from. */ configDir: string; buildCommand: string; outputDir: string; entitiesDir: string; agentsDir: string; functionsDir: string; + actorsDir: string; } /** @@ -52,6 +54,7 @@ export async function resolveBuildTarget( entitiesDir: project?.entitiesDir ?? "entities", agentsDir: project?.agentsDir ?? "agents", functionsDir: project?.functionsDir ?? "functions", + actorsDir: project?.actorsDir ?? "actors", }; } diff --git a/packages/cli/src/core/version/actors.ts b/packages/cli/src/core/version/actors.ts new file mode 100644 index 000000000..c301e8847 --- /dev/null +++ b/packages/cli/src/core/version/actors.ts @@ -0,0 +1,175 @@ +import { dirname, relative, resolve } from "node:path"; +import { + type BundleResponse, + bundle, + collectReachableFiles, + STATIC_EGRESS_ARTIFACT_MARKER, +} from "@base44/functions-compiler"; +import { globby } from "globby"; +import pMap from "p-map"; +import { BACKEND_FILE_GLOB } from "@/core/consts.js"; +import { InternalError, InvalidInputError } from "@/core/errors.js"; +import { readAllActors } from "@/core/resources/actor/config.js"; +import type { ActorDefinition } from "@/core/resources/actor/schema.js"; +import { pathExists, readTextFile } from "@/core/utils/fs.js"; +import { + describeFailure, + requireCompilerRuntime, + sha256, +} from "@/core/version/functions.js"; +import type { ActorArtifact } from "@/core/version/schema.js"; + +/** Must match the server's ceiling: declaring more only earns a late rejection. */ +const MAX_ACTORS = 50; + +/** Each actor is an esbuild run of its own; the compiler bounds its own + * per-function fan-out at the same 4. */ +const COMPILE_CONCURRENCY = 4; + +/** + * Where the compiler expects an actor and the modules it shares, wherever the + * project keeps them: an entry at `base44/actors//entry.ts` is what makes + * it an actor named ``. `../../shared/x.ts` from the entry still lands on + * `base44/shared/x.ts`, because the shared directory sits beside the actors + * directory just as it does beside the functions one. + */ +const ACTORS_ROOT = "base44/actors"; +const SHARED_ROOT = "base44/shared"; + +function forwardSlashed(from: string, to: string): string { + return relative(from, to).split(/[/\\]/).join("/"); +} + +/** Every file under the shared directory, keyed where the compiler looks. */ +async function readSharedFiles( + actorsDir: string, +): Promise> { + const sharedDir = resolve(actorsDir, "..", "shared"); + if (!(await pathExists(sharedDir))) { + return {}; + } + const paths = await globby(BACKEND_FILE_GLOB, { + cwd: sharedDir, + absolute: true, + }); + const files: Record = {}; + for (const path of paths.sort()) { + files[`${SHARED_ROOT}/${forwardSlashed(sharedDir, path)}`] = + await readTextFile(path); + } + return files; +} + +/** + * What one actor compiles from, as apper submits it: the files its entry + * reaches among its own folder and the shared modules, and nothing else — a + * reach into another actor or a function stays unresolved, and fails the + * compile. An `entry.js` is keyed `entry.ts` too, as apper re-homes one. + * + * Not `cfwBundleInput`: it flattens an entry that reaches nothing to `main.ts`, + * which is the path that says "actor". + */ +async function compilerInput( + actor: ActorDefinition, + shared: Record, +): Promise<{ entry: string; files: Record; source: string }> { + const actorDir = dirname(actor.entryPath); + const entry = `${ACTORS_ROOT}/${actor.name}/entry.ts`; + const source = await readTextFile(actor.entryPath); + const candidates: Record = { ...shared }; + for (const filePath of actor.filePaths) { + if (filePath !== actor.entryPath) { + candidates[ + `${ACTORS_ROOT}/${actor.name}/${forwardSlashed(actorDir, filePath)}` + ] = await readTextFile(filePath); + } + } + candidates[entry] = source; + return { + entry, + files: await collectReachableFiles(entry, candidates), + source, + }; +} + +/** + * Every actor, each compiled into a module of its own. One that fails to compile + * fails the whole set: a version without it would describe the app without it. + */ +export async function compileActors( + actorsDir: string, +): Promise { + const actors = await readAllActors(actorsDir); + if (actors.length === 0) { + return []; + } + if (actors.length > MAX_ACTORS) { + throw new InvalidInputError( + `Too many actors: found ${actors.length}, the limit is ${MAX_ACTORS}.`, + ); + } + + await requireCompilerRuntime("Actors"); + const shared = await readSharedFiles(actorsDir); + // By name, so the declared order — and with it every upload slot — does not + // follow a filesystem walk. + const sorted = [...actors].sort((a, b) => (a.name < b.name ? -1 : 1)); + const compiled = await pMap( + sorted, + async (actor) => { + const input = await compilerInput(actor, shared); + const result = await bundle({ entry: input.entry, files: input.files }); + return { + name: actor.name, + entry: input.entry, + source: input.source, + result, + }; + }, + { concurrency: COMPILE_CONCURRENCY }, + ); + + const failures = compiled.flatMap(({ name, result }) => + result.ok + ? [] + : [ + { + function: name, + message: `Actor "${name}" failed to compile.`, + errors: result.errors, + }, + ], + ); + if (failures.length > 0) { + throw new InvalidInputError( + `Actors failed to compile (${failures.length} error${failures.length === 1 ? "" : "s"}).`, + { details: failures.map(describeFailure) }, + ); + } + + return compiled.map(({ name, entry, source, result }) => { + const module = new TextEncoder().encode(compiledModule(name, result)); + return { + name, + entry, + source, + module, + size: module.byteLength, + digest: sha256(module), + // The actor's prelude installs the same static-egress fetch every worker + // entry does, so its module is compiled for the same contract. + staticEgress: STATIC_EGRESS_ARTIFACT_MARKER, + }; + }); +} + +/** The module, once the compiler has confirmed it built an actor of that name — + * a plain Worker deployed under an actor's identity would serve no rooms. */ +function compiledModule(name: string, result: BundleResponse): string { + if (!result.ok || result.do_class_name !== name) { + throw new InternalError( + `Actor "${name}" did not compile as an actor of that name.`, + ); + } + return result.module; +} diff --git a/packages/cli/src/core/version/api.ts b/packages/cli/src/core/version/api.ts index 6b7cf5610..2976eeffa 100644 --- a/packages/cli/src/core/version/api.ts +++ b/packages/cli/src/core/version/api.ts @@ -10,6 +10,7 @@ import { import { putPresigned, putPresignedBody } from "@/core/site/upload.js"; import type { ResolvedAssetsConfig } from "@/core/site/wrangler-config.js"; import type { + ActorArtifact, ArtifactFile, ArtifactSet, BackendBundleArtifact, @@ -56,6 +57,31 @@ function declaredBundle({ }; } +/** The name is the folder's and also the Durable Object class, so no class + * name is declared. */ +function declaredActor({ + name, + entry, + source, + size, + digest, + staticEgress, +}: ActorArtifact) { + return { + name, + entry, + source, + module: { size, digest }, + static_egress: staticEgress, + }; +} + +/** The path the server stages an actor's module at, which the client knows in + * advance — unlike a bundle's. */ +function actorUploadPath(actor: ActorArtifact): string { + return `actors/${actor.name}.mjs`; +} + /** * Wrangler's own `assets` block, back in its own snake_case. The FIELDS are * theirs; what contains them is ours, named for what it decides. @@ -166,6 +192,7 @@ export async function createVersion( } : {}), backend_bundles: artifacts.backendBundles.map(declaredBundle), + actors: artifacts.actors.map(declaredActor), entities: artifacts.entities, agents: artifacts.agents, source_commit: options.sourceCommit, @@ -178,12 +205,14 @@ export async function createVersion( // Paired by POSITION, in the order the server signed them: assets and modules // are separate namespaces, so the two may share a path. Each bundle's module - // follows them, at a path the server names. + // follows them, at a path the server names, and then each actor's — last, so + // an app gaining actors moves no slot the others had. const declaredFiles = [ ...artifacts.assets, ...(artifacts.siteWorker?.modules ?? []), ]; - const slotCount = declaredFiles.length + artifacts.backendBundles.length; + const actorsAt = declaredFiles.length + artifacts.backendBundles.length; + const slotCount = actorsAt + artifacts.actors.length; options.progress?.onDeclared?.({ fileCount: slotCount }); @@ -202,6 +231,15 @@ export async function createVersion( `Upload ${drifted} is signed for ${declared.uploads[drifted].path}, but that slot declared ${declaredFiles[drifted].path}.`, ); } + const actorDrifted = artifacts.actors.findIndex( + (actor, index) => + declared.uploads[actorsAt + index].path !== actorUploadPath(actor), + ); + if (actorDrifted !== -1) { + throw new InternalError( + `Upload ${actorsAt + actorDrifted} is signed for ${declared.uploads[actorsAt + actorDrifted].path}, but that slot declared the actor ${artifacts.actors[actorDrifted].name}.`, + ); + } let uploadedFiles = 0; await pMap( @@ -209,11 +247,16 @@ export async function createVersion( async (upload, index) => { if (index < declaredFiles.length) { await putPresigned(upload, declaredFiles[index].absolutePath); - } else { + } else if (index < actorsAt) { await putPresignedBody( upload, artifacts.backendBundles[index - declaredFiles.length].module, ); + } else { + await putPresignedBody( + upload, + artifacts.actors[index - actorsAt].module, + ); } uploadedFiles++; options.progress?.onUpload?.({ diff --git a/packages/cli/src/core/version/artifacts.ts b/packages/cli/src/core/version/artifacts.ts index 42d601fa5..56104aeb4 100644 --- a/packages/cli/src/core/version/artifacts.ts +++ b/packages/cli/src/core/version/artifacts.ts @@ -9,6 +9,7 @@ import type { FullStackBuild } from "@/core/site/full-stack.js"; import { resolveFullStackBuild } from "@/core/site/full-stack.js"; import { describeBuildOutput, hashFileInto } from "@/core/site/manifest.js"; import { pathExists, readJsonFile } from "@/core/utils/fs.js"; +import { compileActors } from "@/core/version/actors.js"; import { compileBackendBundles } from "@/core/version/functions.js"; import { tagStep } from "@/core/version/publish.js"; import type { @@ -197,6 +198,9 @@ export async function collectArtifacts( join(target.configDir, target.functionsDir), ), ), + actors: await tagStep("build", () => + compileActors(join(target.configDir, target.actorsDir)), + ), ...(await collectResources(target.configDir, target)), }; } diff --git a/packages/cli/src/core/version/functions.ts b/packages/cli/src/core/version/functions.ts index 734792dc5..6f383796b 100644 --- a/packages/cli/src/core/version/functions.ts +++ b/packages/cli/src/core/version/functions.ts @@ -34,12 +34,14 @@ const COMPILE_MODES = { runtimeSecrets: false, postResponseTelemetry: false }; * runtime packages the standalone binary cannot carry, which the compiler * itself imports only when it compiles. */ -async function requireCompilerRuntime(): Promise { +export async function requireCompilerRuntime( + what = "Backend functions", +): Promise { try { await Promise.all([import("esbuild"), import("@deno/loader")]); } catch (error) { throw new DependencyNotFoundError( - "Backend functions cannot be compiled by this installation of the CLI.", + `${what} cannot be compiled by this installation of the CLI.`, { hints: [{ message: "Install the CLI from npm: npm install -g base44" }], cause: error instanceof Error ? error : undefined, @@ -73,7 +75,7 @@ async function compilerInput( return { input: { name: fn.name, entry, files }, source }; } -function describeFailure(failure: ShardBuildFailure): string { +export function describeFailure(failure: ShardBuildFailure): string { const where = failure.function ? `${failure.function}: ` : ""; const errors = (failure.errors ?? []).map((e) => e.file ? `${e.file}:${e.line ?? 0} ${e.message}` : e.message, @@ -81,7 +83,7 @@ function describeFailure(failure: ShardBuildFailure): string { return [`${where}${failure.message}`, ...errors].join("\n "); } -function sha256(bytes: Uint8Array): string { +export function sha256(bytes: Uint8Array): string { return `sha256:${createHash("sha256").update(bytes).digest("hex")}`; } diff --git a/packages/cli/src/core/version/schema.ts b/packages/cli/src/core/version/schema.ts index 192239b51..a19b93d3e 100644 --- a/packages/cli/src/core/version/schema.ts +++ b/packages/cli/src/core/version/schema.ts @@ -65,6 +65,24 @@ export interface BackendBundleArtifact { wrapper: ShardWrapper; } +/** + * One actor, compiled on its own: a Durable Object Worker of its own, never a + * shard. Its name is its folder's, and also the class the module exports. + */ +export interface ActorArtifact { + name: string; + /** Always `base44/actors//entry.ts`, an `entry.js` included: the path + * is how the compiler knows the entry is an actor. */ + entry: string; + /** The entry file as written, not as compiled. */ + source: string; + module: Uint8Array; + size: number; + digest: string; + /** The static-egress contract the module was compiled for. */ + staticEgress: string; +} + /** Everything one build produced, as the create-version call describes it. */ export interface ArtifactSet { /** The app's files. Who serves them is what `siteWorker` says. */ @@ -75,6 +93,8 @@ export interface ArtifactSet { * platform this producer cannot describe functions, and it refuses that for * an app that has them. */ backendBundles: BackendBundleArtifact[]; + /** Always sent, empty when the app has none, for the same reason. */ + actors: ActorArtifact[]; /** Raw payloads by name. The server normalizes and hashes them. */ entities: Record; agents: Record; diff --git a/packages/cli/tests/cli/publish.spec.ts b/packages/cli/tests/cli/publish.spec.ts index f5b06edcc..134fcb74d 100644 --- a/packages/cli/tests/cli/publish.spec.ts +++ b/packages/cli/tests/cli/publish.spec.ts @@ -276,11 +276,203 @@ describe.skipIf(process.env.CLI_TEST_RUNNER === "binary")( }, ); +describe.skipIf(process.env.CLI_TEST_RUNNER === "binary")( + "publish command, for an app with actors", + () => { + const t = setupCLITests(); + const ACTOR_SLOTS = ["/actors/ChatRoom.mjs", "/actors/Counter.mjs"]; + + /** with-actors builds no site of its own; publish needs one to enter. */ + async function givenActorApp(): Promise { + await t.givenLoggedInWithProject(fixture("with-actors")); + const dist = join(t.getTempDir(), "project", "dist"); + await mkdir(dist, { recursive: true }); + await writeFile(join(dist, "index.html"), INDEX); + } + + const mockActorsApi = (...extraSlots: string[]) => { + t.api.mockVersionDeclare(SESSION).mockPresignedUpload("/index.html"); + for (const slot of [...extraSlots, ...ACTOR_SLOTS]) { + t.api.mockPresignedUpload(slot); + } + t.api + .mockVersionFinalize({ + version_id: "ver-1", + manifest_hash: "sha256:abc", + }) + .mockEnvironmentSet({ + name: "production", + version_id: "ver-1", + manifest_hash: "sha256:abc", + deployment_id: "dep-1", + }); + }; + + const uploaded = (path: string) => + t.api.presignedUploadRequests.find((u) => u.path === path)?.data; + + it("declares each actor at its folder's entry, with the source as written", async () => { + // Counter is an entry.js, keyed entry.ts all the same: that path is how + // the compiler, and the platform, know an entry is an actor. ChatRoom + // imports base44/shared, so it compiles only if that module came along. + t.givenEnv({ BASE44_VERSIONS_API: "1" }); + await givenActorApp(); + mockActorsApi(); + + const result = await t.run("publish", "--no-build"); + + t.expectResult(result).toSucceed(); + const declared = t.api.versionDeclareRequests[0] as { + actors: { name: string; module: { size: number; digest: string } }[]; + }; + expect(declared.actors).toEqual([ + { + name: "ChatRoom", + entry: "base44/actors/ChatRoom/entry.ts", + source: await t.readProjectFile("base44/actors/ChatRoom/entry.ts"), + module: { size: expect.any(Number), digest: expect.any(String) }, + static_egress: "base44.static-egress.request-env.v2", + }, + { + name: "Counter", + entry: "base44/actors/Counter/entry.ts", + source: await t.readProjectFile("base44/actors/Counter/entry.js"), + module: { size: expect.any(Number), digest: expect.any(String) }, + static_egress: "base44.static-egress.request-env.v2", + }, + ]); + expect(declared).toMatchObject({ backend_bundles: [] }); + }); + + it("uploads each actor's module at its own slot, after every other file", async () => { + t.givenEnv({ BASE44_VERSIONS_API: "1" }); + await givenActorApp(); + mockActorsApi(); + + const result = await t.run("publish", "--no-build"); + + t.expectResult(result).toSucceed(); + const declared = t.api.versionDeclareRequests[0] as { + actors: { name: string; module: { size: number; digest: string } }[]; + }; + for (const actor of declared.actors) { + const module = uploaded(`/actors/${actor.name}.mjs`); + expect(actor.module).toEqual({ + size: module?.length, + digest: sha256(module ?? ""), + }); + } + }); + + it("stages actors after the bundles, so neither moves the other's slot", async () => { + t.givenEnv({ BASE44_VERSIONS_API: "1" }); + await givenActorApp(); + const greet = join( + t.getTempDir(), + "project", + "base44", + "functions", + "greet", + ); + await mkdir(greet, { recursive: true }); + await writeFile( + join(greet, "entry.ts"), + 'Deno.serve(() => new Response("hi"));\n', + ); + mockActorsApi("/backend-bundles/0.mjs"); + + const result = await t.run("publish", "--no-build"); + + t.expectResult(result).toSucceed(); + const declared = t.api.versionDeclareRequests[0] as { + backend_bundles: { module: { digest: string } }[]; + actors: { name: string; module: { digest: string } }[]; + }; + expect(sha256(uploaded("/backend-bundles/0.mjs") ?? "")).toBe( + declared.backend_bundles[0].module.digest, + ); + expect(sha256(uploaded("/actors/ChatRoom.mjs") ?? "")).toBe( + declared.actors[0].module.digest, + ); + }); + + it("declares an app with no actors as having none", async () => { + // Not an absent field: that is what an older CLI sends, and the platform + // refuses it for an app whose actors it would then drop. + t.givenEnv({ BASE44_VERSIONS_API: "1" }); + await t.givenLoggedInWithProject(fixture("publishable")); + t.api + .mockVersionDeclare(SESSION) + .mockPresignedUpload("/index.html") + .mockPresignedUpload("/assets/app.js") + .mockVersionFinalize({ + version_id: "ver-1", + manifest_hash: "sha256:abc", + }) + .mockEnvironmentSet({ + name: "production", + version_id: "ver-1", + manifest_hash: "sha256:abc", + deployment_id: "dep-1", + }); + + const result = await t.run("publish", "--no-build"); + + t.expectResult(result).toSucceed(); + expect(t.api.versionDeclareRequests[0]).toMatchObject({ actors: [] }); + }); + + it("names the build step when an actor does not compile, before declaring", async () => { + t.givenEnv({ BASE44_VERSIONS_API: "1" }); + await givenActorApp(); + const broken = join( + t.getTempDir(), + "project", + "base44", + "actors", + "Broken", + ); + await mkdir(broken, { recursive: true }); + await writeFile( + join(broken, "entry.ts"), + "export default class Broken {\n", + ); + mockActorsApi(); + + const result = await t.run("publish", "--no-build", "--json"); + + t.expectResult(result).toFail(); + const envelope = JSON.parse(result.stdout); + expect(envelope).toMatchObject({ step: "build", code: "INVALID_INPUT" }); + expect(envelope.details.join("\n")).toContain("Broken"); + expect(t.api.versionDeclareRequests).toEqual([]); + }); + }, +); + describe.runIf(process.env.CLI_TEST_RUNNER === "binary")( "publish command, from the standalone binary", () => { const t = setupCLITests(); + it("refuses an app with actors before declaring anything", async () => { + t.givenEnv({ BASE44_VERSIONS_API: "1" }); + await t.givenLoggedInWithProject(fixture("with-actors")); + const dist = join(t.getTempDir(), "project", "dist"); + await mkdir(dist, { recursive: true }); + await writeFile(join(dist, "index.html"), INDEX); + t.api.mockVersionDeclare(SESSION); + + const result = await t.run("publish", "--no-build", "--json"); + + t.expectResult(result).toFail(); + expect(JSON.parse(result.stdout)).toMatchObject({ + step: "build", + code: "DEPENDENCY_NOT_FOUND", + }); + expect(t.api.versionDeclareRequests).toEqual([]); + }); + it("refuses an app with backend functions before declaring anything", async () => { t.givenEnv({ BASE44_VERSIONS_API: "1" }); await t.givenLoggedInWithProject(fixture("publishable-with-functions")); diff --git a/packages/cli/tests/cli/testkit/TestAPIServer.ts b/packages/cli/tests/cli/testkit/TestAPIServer.ts index 946a6f585..ccc2b0941 100644 --- a/packages/cli/tests/cli/testkit/TestAPIServer.ts +++ b/packages/cli/tests/cli/testkit/TestAPIServer.ts @@ -878,11 +878,15 @@ export class TestAPIServer { assets: Array<{ path: string; size: number; digest: string }>; }; backend_bundles?: Array<{ module: { size: number; digest: string } }>; + actors?: Array<{ + name: string; + module: { size: number; digest: string }; + }>; }; this.versionDeclareRequests.push(body); // The app's assets, then the Worker's modules, then each bundle's module - // at a path the server names — the slot order the server signs them in, - // which is what the client pairs uploads against. + // at a path the server names, then each actor's — the slot order the + // server signs them in, which is what the client pairs uploads against. const declared = [ ...body.assets, ...(body.site_worker?.modules ?? []), @@ -890,6 +894,10 @@ export class TestAPIServer { path: `backend-bundles/${index}.mjs`, ...bundle.module, })), + ...(body.actors ?? []).map((actor) => ({ + path: `actors/${actor.name}.mjs`, + ...actor.module, + })), ]; res.status(200).json({ session_id: sessionId, diff --git a/packages/cli/tests/core/project-target.spec.ts b/packages/cli/tests/core/project-target.spec.ts index 4a41cd9a4..1931e1fb9 100644 --- a/packages/cli/tests/core/project-target.spec.ts +++ b/packages/cli/tests/core/project-target.spec.ts @@ -44,6 +44,7 @@ describe("resolveBuildTarget", () => { entitiesDir: "entities", agentsDir: "agents", functionsDir: "functions", + actorsDir: "actors", }); }); diff --git a/packages/cli/tests/core/version-actors.spec.ts b/packages/cli/tests/core/version-actors.spec.ts new file mode 100644 index 000000000..4c6bd3065 --- /dev/null +++ b/packages/cli/tests/core/version-actors.spec.ts @@ -0,0 +1,163 @@ +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { STATIC_EGRESS_ARTIFACT_MARKER } from "@base44/functions-compiler"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { InvalidInputError } from "@/core/errors.js"; +import { compileActors } from "@/core/version/actors.js"; + +const ROOM = `import { Actor } from "base44:runtime/actors"; +export default class Room extends Actor { + handleMessage(conn, message) { this.broadcast(message); } +} +`; + +describe("compileActors", () => { + let root: string; + const actorsDir = () => join(root, "base44", "actors"); + + async function givenFile(path: string, content: string): Promise { + await mkdir(join(root, path, ".."), { recursive: true }); + await writeFile(join(root, path), content); + } + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "b44-actors-")); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it("is an empty set for an app with no actors, without loading the compiler", async () => { + // The standalone binary cannot load the compiler, and an app with no + // actors must still publish from it. + expect(await compileActors(actorsDir())).toEqual([]); + }); + + it("compiles each actor into a module of its own, keyed at its folder", async () => { + await givenFile("base44/actors/Room/entry.ts", ROOM); + await givenFile("base44/actors/Lobby/entry.ts", ROOM); + + const actors = await compileActors(actorsDir()); + + expect(actors.map(({ name, entry }) => ({ name, entry }))).toEqual([ + { name: "Lobby", entry: "base44/actors/Lobby/entry.ts" }, + { name: "Room", entry: "base44/actors/Room/entry.ts" }, + ]); + const [lobby, room] = actors; + expect(room).toMatchObject({ + source: ROOM, + size: room.module.byteLength, + digest: `sha256:${createHash("sha256").update(room.module).digest("hex")}`, + staticEgress: STATIC_EGRESS_ARTIFACT_MARKER, + }); + // Each its own Worker, exporting the class its folder names. + expect(lobby.digest).not.toBe(room.digest); + expect(new TextDecoder().decode(room.module)).toContain( + STATIC_EGRESS_ARTIFACT_MARKER, + ); + }); + + it("keys an entry.js actor as entry.ts, and declares its source as written", async () => { + const counter = ROOM.replace("Room", "Counter"); + await givenFile("base44/actors/Counter/entry.js", counter); + + const [actor] = await compileActors(actorsDir()); + + expect(actor).toMatchObject({ + name: "Counter", + entry: "base44/actors/Counter/entry.ts", + source: counter, + }); + }); + + it("compiles the helpers and shared modules the entry reaches", async () => { + await givenFile( + "base44/actors/Room/entry.ts", + `import { Actor } from "base44:runtime/actors"; +import { limit } from "../../shared/limits.ts"; +import { greet } from "./lib/greet.ts"; +export default class Room extends Actor { + handleConnect(conn) { conn.send(greet(limit)); } +} +`, + ); + await givenFile( + "base44/actors/Room/lib/greet.ts", + "export const greet = (s: string) => 'room-helper-' + s;\n", + ); + await givenFile( + "base44/shared/limits.ts", + `import { base } from "./base.ts";\nexport const limit = base + "-limit";\n`, + ); + await givenFile( + "base44/shared/base.ts", + 'export const base = "shared-base-module";\n', + ); + + const [actor] = await compileActors(actorsDir()); + + const module = new TextDecoder().decode(actor.module); + expect(module).toContain("room-helper-"); + expect(module).toContain("shared-base-module"); + }); + + it("does not reach outside the actor's folder and the shared modules", async () => { + // As apper scopes an actor: another actor's or a function's files are not + // its to bundle, so the import has no target and the compile fails. + await givenFile( + "base44/actors/Room/entry.ts", + `import { Actor } from "base44:runtime/actors"; +import { helper } from "../../functions/tool/helper.ts"; +export default class Room extends Actor { + handleConnect(conn) { conn.send(helper); } +} +`, + ); + await givenFile( + "base44/functions/tool/helper.ts", + "export const helper = 1;\n", + ); + + const error = await compileActors(actorsDir()).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(InvalidInputError); + expect((error as InvalidInputError).details.join("\n")).toContain("Room"); + }); + + it("compiles the same actors to the same bytes", async () => { + // A version is named by the digest, so a rebuild of unchanged code must + // not mint a new one. + await givenFile("base44/actors/Room/entry.ts", ROOM); + + const first = await compileActors(actorsDir()); + const second = await compileActors(actorsDir()); + + expect(second.map((a) => a.digest)).toEqual(first.map((a) => a.digest)); + }); + + it("fails the whole set when one actor does not compile", async () => { + await givenFile("base44/actors/Room/entry.ts", ROOM); + await givenFile( + "base44/actors/Broken/entry.ts", + "export default class Broken {\n", + ); + + const error = await compileActors(actorsDir()).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(InvalidInputError); + expect((error as InvalidInputError).details.join("\n")).toContain("Broken"); + }); + + it("refuses more actors than a version may declare, before compiling any", async () => { + for (let i = 0; i < 51; i++) { + await givenFile(`base44/actors/Room${i}/entry.ts`, "not compiled\n"); + } + + await expect(compileActors(actorsDir())).rejects.toThrow( + "Too many actors: found 51, the limit is 50.", + ); + }); +}); diff --git a/packages/cli/tests/core/version-artifacts.spec.ts b/packages/cli/tests/core/version-artifacts.spec.ts index f67f18c14..c5c203002 100644 --- a/packages/cli/tests/core/version-artifacts.spec.ts +++ b/packages/cli/tests/core/version-artifacts.spec.ts @@ -379,6 +379,7 @@ describe("collectArtifacts", () => { entitiesDir: "entities", agentsDir: "agents", functionsDir: "functions", + actorsDir: "actors", }; } diff --git a/packages/cli/tests/fixtures/with-actors/base44/actors/ChatRoom/entry.ts b/packages/cli/tests/fixtures/with-actors/base44/actors/ChatRoom/entry.ts index 2d6b8b06a..14a9bd998 100644 --- a/packages/cli/tests/fixtures/with-actors/base44/actors/ChatRoom/entry.ts +++ b/packages/cli/tests/fixtures/with-actors/base44/actors/ChatRoom/entry.ts @@ -1,9 +1,12 @@ import { Actor } from "base44:runtime/actors"; +import { maxMessageLength } from "../../shared/limits.ts"; import { greeting } from "./lib/message.ts"; export default class ChatRoom extends Actor { handleConnect(conn) { conn.send(greeting); } - handleMessage(conn, message) { this.broadcast(message); } + handleMessage(conn, message) { + if (String(message).length <= maxMessageLength) this.broadcast(message); + } handleClose() {} handleTick() {} } diff --git a/packages/cli/tests/fixtures/with-actors/base44/shared/limits.ts b/packages/cli/tests/fixtures/with-actors/base44/shared/limits.ts new file mode 100644 index 000000000..a39ce1c89 --- /dev/null +++ b/packages/cli/tests/fixtures/with-actors/base44/shared/limits.ts @@ -0,0 +1 @@ +export const maxMessageLength = 1000;