Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 11 additions & 3 deletions docs/versions.md
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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.

Expand Down Expand Up @@ -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/<name>.mjs`, a path the client checks the server signed.

The entry is always `base44/actors/<name>/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/<name>/` 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.
Expand Down
5 changes: 4 additions & 1 deletion packages/cli/src/core/project/target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -52,6 +54,7 @@ export async function resolveBuildTarget(
entitiesDir: project?.entitiesDir ?? "entities",
agentsDir: project?.agentsDir ?? "agents",
functionsDir: project?.functionsDir ?? "functions",
actorsDir: project?.actorsDir ?? "actors",
};
}

Expand Down
175 changes: 175 additions & 0 deletions packages/cli/src/core/version/actors.ts
Original file line number Diff line number Diff line change
@@ -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/<Name>/entry.ts` is what makes
* it an actor named `<Name>`. `../../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<Record<string, string>> {
const sharedDir = resolve(actorsDir, "..", "shared");
if (!(await pathExists(sharedDir))) {
return {};
}
const paths = await globby(BACKEND_FILE_GLOB, {
cwd: sharedDir,
absolute: true,
});
const files: Record<string, string> = {};
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<string, string>,
): Promise<{ entry: string; files: Record<string, string>; source: string }> {
const actorDir = dirname(actor.entryPath);
const entry = `${ACTORS_ROOT}/${actor.name}/entry.ts`;
const source = await readTextFile(actor.entryPath);
const candidates: Record<string, string> = { ...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<ActorArtifact[]> {
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;
}
49 changes: 46 additions & 3 deletions packages/cli/src/core/version/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -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 });

Expand All @@ -202,18 +231,32 @@ 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(
declared.uploads,
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?.({
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/core/version/artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)),
};
}
10 changes: 6 additions & 4 deletions packages/cli/src/core/version/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
export async function requireCompilerRuntime(
what = "Backend functions",
): Promise<void> {
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,
Expand Down Expand Up @@ -73,15 +75,15 @@ 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,
);
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")}`;
}

Expand Down
Loading
Loading