From 3461f007d3155fc46f93720d7a1622743085ebb7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 17:16:20 +0000 Subject: [PATCH 1/7] feat: static-site deploys through the deployments API (s3 arm, env-gated) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the deployments core (commit-addressed create/finalize, asset manifest hashing, presigned uploads) and routes site.outputDirectory through it when BASE44_STATIC_DEPLOYMENTS is set: POST deployments with {git_hash, asset_manifest} and no worker config, PUT each requested file directly to its presigned URL echoing the signed content_type (the URL also signs content_length), finalize with the index.html bytes as the completion sentinel. asset_uploads: null means nothing is owed — re-deploying a commit is idempotent. The create response is a type-discriminated ADT so the worker (cf) arm can slot in next to s3 without protocol changes. Gate off keeps the legacy tar.gz upload byte-identical. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DvhQfqxACcq25XAQRpoSh9 --- .gitattributes | 3 + docs/AGENTS.md | 1 + docs/deployments.md | 45 +++++ docs/resources.md | 19 +- docs/testing.md | 14 ++ .../cli/src/cli/commands/project/deploy.ts | 62 +++++- packages/cli/src/cli/commands/site/deploy.ts | 55 ++++-- .../src/cli/commands/site/run-app-deploy.ts | 69 +++++++ packages/cli/src/core/deployments/api.ts | 85 ++++++++ packages/cli/src/core/deployments/git-hash.ts | 42 ++++ packages/cli/src/core/deployments/index.ts | 6 + packages/cli/src/core/deployments/manifest.ts | 183 +++++++++++++++++ packages/cli/src/core/deployments/schema.ts | 141 +++++++++++++ .../cli/src/core/deployments/static-site.ts | 79 ++++++++ packages/cli/src/core/deployments/upload.ts | 83 ++++++++ packages/cli/src/core/index.ts | 1 + packages/cli/src/core/project/deploy.ts | 15 +- packages/cli/src/core/site/deploy-app.ts | 83 ++++++++ packages/cli/src/core/site/index.ts | 1 + .../tests/cli/static_site_deployments.spec.ts | 185 ++++++++++++++++++ .../cli/tests/cli/testkit/TestAPIServer.ts | 148 ++++++++++++++ .../tests/core/deployments-manifest.spec.ts | 121 ++++++++++++ 22 files changed, 1405 insertions(+), 36 deletions(-) create mode 100644 .gitattributes create mode 100644 docs/deployments.md create mode 100644 packages/cli/src/cli/commands/site/run-app-deploy.ts create mode 100644 packages/cli/src/core/deployments/api.ts create mode 100644 packages/cli/src/core/deployments/git-hash.ts create mode 100644 packages/cli/src/core/deployments/index.ts create mode 100644 packages/cli/src/core/deployments/manifest.ts create mode 100644 packages/cli/src/core/deployments/schema.ts create mode 100644 packages/cli/src/core/deployments/static-site.ts create mode 100644 packages/cli/src/core/deployments/upload.ts create mode 100644 packages/cli/src/core/site/deploy-app.ts create mode 100644 packages/cli/tests/cli/static_site_deployments.spec.ts create mode 100644 packages/cli/tests/core/deployments-manifest.spec.ts diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..05384c2db --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Test fixtures are hashed byte-for-byte by the deploy tests; CRLF checkout +# on Windows would change the bytes and break the content-addressed hashes. +packages/cli/tests/fixtures/** text=auto eol=lf diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 4f04d7f49..453cff780 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -79,6 +79,7 @@ Read these when working on the relevant area: - **[Adding or modifying CLI commands](commands.md)** - Factory pattern, `runCommand()`, `runTask()`, `CLIContext`, theming, `chalk` ban - **[Making API calls](api-patterns.md)** - HTTP clients, Zod snake_case-to-camelCase transforms, `ApiError.fromHttpError()` - **[Working with resources](resources.md)** - `Resource` interface, adding new resources, site module, unified deploy +- **[Deployments API](deployments.md)** - Static-site deploys addressed by commit, asset manifest hashing, presigned uploads, index.html finalize sentinel - **[Plugins](plugins.md)** - Plugin config, namespaces, entity extension rules, function namespacing, pull/deploy behavior - **[Error handling](error-handling.md)** - Error hierarchy, throwing patterns, error codes, `CLIExitError`, `process.exit` ban - **[Writing tests](testing.md)** - Testkit, Given/When/Then pattern, API mocks, fixtures, test overrides diff --git a/docs/deployments.md b/docs/deployments.md new file mode 100644 index 000000000..9e95e2058 --- /dev/null +++ b/docs/deployments.md @@ -0,0 +1,45 @@ +# Deployments API (Static Sites) + +**Keywords:** deployments, static site, asset manifest, hash, git hash, commit, presigned, S3, finalize, index.html sentinel, BASE44_STATIC_DEPLOYMENTS, upload + +Deployments ship an app's built output addressed by the commit that produced it. The core module is `src/core/deployments/` (`git-hash.ts`, `manifest.ts`, `static-site.ts`, `upload.ts`, `api.ts`, `schema.ts`). Today it carries the env-gated static-site lane; the create response is an ADT designed so a worker (`cf`) arm can slot in next to the static (`s3`) arm without protocol changes — that is the progressive-upgrade path for full-stack apps. + +**Deploying builds — it never publishes.** A deployment is addressed by the commit that produced the build: the server derives the deployment id from `git_hash`, so one commit means one deployment and re-deploying a commit is idempotent. What production serves is decided by the platform publish flow, not by this CLI — there is no `--prod`, no promote/rollback, and no deployment list/logs surface. + +## Git Hash Resolution + +`resolveGitHash(projectRoot, explicit?)` — an explicit `--git-hash` wins; otherwise `git rev-parse HEAD` in the project root. No hash (not a git checkout, no flag) or a non-hex value fails fast with guidance. Pattern: `^[a-fA-F0-9]{7,64}$` (same validation as the server). + +## API Contract (app-scoped, via `getAppClient()`) + +1. `POST deployments` — JSON body: `git_hash` (required) and `asset_manifest` (`{"/path": {hash, size}}`). The response is `{deployment_id, asset_uploads}` where `deployment_id` is a handle for the rest of the flow and `asset_uploads` says where the assets still owed should go, discriminated on `type`: + - `{type: "s3", uploads: [{path, content_type, content_length, url}]}` — one presigned S3 PUT per asset still to upload, **always excluding `/index.html`** (finalize carries it). + - `null` — nothing owed: no assets, or the build already exists (re-deploying a commit is idempotent). +2. **Asset upload — bytes never pass through the backend.** Each upload's raw file bytes are `PUT` directly to its presigned `url` with the signed `content_type` sent verbatim (the URL also signs `content_length`, so the body must be exactly the declared bytes). The URL itself is the credential, so no auth headers and never the app client. Per file: 3 attempts with exponential backoff, concurrency 3. +3. `POST deployments/{id}/finalize` — multipart with exactly one file field named `index.html` carrying the index.html bytes (contentType `text/html`) — no other fields. index.html is the sentinel that completes the deployment, which is also why it never appears in the uploads. Returns `{deployment_id}`. + +## Asset Manifest & Hashing + +`hash = first 32 hex chars of sha256(utf8(app_id) || raw file bytes)` — see `hashAsset()` in `src/core/deployments/manifest.ts`. The app-id salt is a cache-poisoning defense: a tenant can only produce hash collisions with its own files. + +The output directory is walked recursively. `.assetsignore` at the root is honored (minimal gitignore-style matching: exact names, `*`/`**` globs, directory patterns; no negation). `.assetsignore` itself, `wrangler.json`, and `.dev.vars` are always skipped. Files over 25 MiB fail with a per-file error; total file count is capped at 100,000. Manifest keys are `/`-prefixed forward-slash paths. + +Content types are deliberately **not** derived client-side: the server decides each asset's Content-Type, signs it into the presigned URL, and the CLI echoes it verbatim — deriving our own value would 403 on any mapping difference. + +## The Static Lane (experimental, env-gated) + +With `BASE44_STATIC_DEPLOYMENTS=1` (or `true`; internal gate, not user-facing yet), a project with `site.outputDirectory` deploys through the deployments API instead of the legacy tar.gz upload: the output directory becomes the asset manifest (index.html included — it is only ever excluded from uploads), the CLI PUTs each requested file directly to its presigned URL and finalizes with the index.html bytes; today's serving keeps working because the server stores the result the way the legacy site upload does. Same command, same `--git-hash` addressing, same `--json` output (`{deploymentId, gitHash}`). + +Both `base44 deploy` and `base44 site deploy` route through `deployAppSite()` in `core/site/` (see [resources.md](resources.md#site-module-not-a-resource)), which picks the transport (`static-deployment` vs legacy `static`). The primary automated consumer is the platform's build/deploy sandbox, which runs `base44 deploy -y --json --git-hash ` with a scoped `apps:deploy` workspace key — so the sandbox and a human at a terminal go through the exact same door. + +## Testing + +`TestAPIServer` mocks: `mockDeploymentCreate` (captures the JSON body in `deploymentCreateRequests`; echoes whatever response shape you pass — `asset_uploads` is `{type: "s3", ...}` or `null`), `mockPresignedUpload(path)` (serves a presigned-style `PUT /presigned{path}` target, captures body/Content-Type/Authorization in `presignedUploadRequests`), `mockDeploymentFinalize` (captures multipart fields in `finalizeRequests`). Fixture: `tests/fixtures/with-site/` (static output dir) — not a git repo, so specs pass `--git-hash`. Unit tests live in `tests/core/deployments-*.spec.ts`. + +## Rules (Deployments-Specific) + +- **Never re-derive the asset hash** — always go through `hashAsset()` so the app-id salt stays consistent +- **Never derive an upload's Content-Type client-side** — the server signs it into the presigned URL; echo the signed value verbatim +- **Presigned PUTs carry no auth headers and never use the app client** — the URL itself is the scoped credential +- **`git_hash` is required** — a build with no commit behind it has no address and could never be published +- **Legacy behavior stays identical** when the gate is off — the tar.gz site path must not change diff --git a/docs/resources.md b/docs/resources.md index 95f9fc400..4c7b01718 100644 --- a/docs/resources.md +++ b/docs/resources.md @@ -76,18 +76,23 @@ Agent skills are app-scoped instruction snippets shared across the app's agents. ## Site Module (Not a Resource) -The site module at `packages/cli/src/core/site/` handles deploying built frontend files. It follows a different pattern than resources: +The site module at `packages/cli/src/core/site/` handles deploying an app's built output. It follows a different pattern than resources — there is no item list, so no `readAll`/`push`. -- Reads built artifacts (JS, CSS, HTML) from the output directory -- Gets configuration from `site.outputDirectory` in project config -- Creates a tar.gz archive and uploads it via `POST /api/apps/{app_id}/deploy-dist` +It owns **which transport ships the build**. `deployAppSite()` in `deploy-app.ts` is the single entry point both `base44 deploy` and `base44 site deploy` call: + +- `site.outputDirectory` ships as a static site: through the deployments API when the env-gated lane is enabled (see [deployments.md](deployments.md)), else the legacy path — tar.gz the built files and upload via `POST /api/apps/{app_id}/deploy-dist`. +- No `site.outputDirectory` → `{ kind: "none" }`. ```typescript -import { deploySite } from "@/core/site/index.js"; +import { deployAppSite } from "@/core/site/index.js"; -const { appUrl } = await deploySite("./dist"); +const result = await deployAppSite(project, { gitHash }); +// { kind: "static-deployment", deploymentId, gitHash } +// | { kind: "static", appUrl } | { kind: "none" } ``` +`detectAppDeployKind()` answers what would ship right now — used for the deploy summary and spinner labels. It answers for the current state of the tree, so a build step invalidates it. + ### Deploy Flow 1. Validate output directory exists and has files @@ -116,7 +121,7 @@ What it deploys (in order): 3. Agent skills (via `agentSkillResource.push()`) 4. Agents (via `agentResource.push()`) 5. Connectors (via `pushConnectors()`) -- may return OAuth redirect URLs -6. Site (if `site.outputDirectory` is configured) +6. Site — via `deployAppSite()`, which picks the transport (see [Site Module](#site-module-not-a-resource)). The deploy command passes `site: false` to `deployAll()` and handles this step itself, after the optional build step has produced whatever the site ships. ```bash base44 deploy # With confirmation prompt diff --git a/docs/testing.md b/docs/testing.md index 3a81485a1..f34052dc6 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -298,6 +298,20 @@ t.api.mockFunctionLogs("my-function", [ t.api.mockFunctionLogsError("my-function", { status: 500, body: { error: "Server error" } }); ``` +### Deployment Mocks + +See [deployments.md](deployments.md) for the API contract. Requests are captured for assertions: `t.api.deploymentCreateRequests` (JSON bodies), `t.api.presignedUploadRequests` (raw body, Content-Type, Authorization), and `t.api.finalizeRequests` (parsed multipart fields). + +```typescript +t.api.mockDeploymentCreate({ + deployment_id: "app-1-git-a1b2c3d4e5f6", + // {type: "s3", uploads: [...]} or null (nothing owed) + asset_uploads: { type: "s3", uploads: [{ path, content_type, content_length, url }] }, +}); +t.api.mockPresignedUpload("/main.js"); // serves a presigned-style PUT target +t.api.mockDeploymentFinalize({ deployment_id: "app-1-git-a1b2c3d4e5f6" }); +``` + ### Custom Route Mock For advanced scenarios (e.g. stateful responses across retries): diff --git a/packages/cli/src/cli/commands/project/deploy.ts b/packages/cli/src/cli/commands/project/deploy.ts index 986c01ffc..5c3aabc35 100644 --- a/packages/cli/src/cli/commands/project/deploy.ts +++ b/packages/cli/src/cli/commands/project/deploy.ts @@ -7,6 +7,7 @@ import { } from "@/cli/commands/connectors/oauth-prompt.js"; import { formatDeployResult } from "@/cli/commands/functions/formatDeployResult.js"; import { maybeBuildBeforeDeploy } from "@/cli/commands/project/site-build.js"; +import { runAppSiteDeploy } from "@/cli/commands/site/run-app-deploy.js"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { Base44Command, @@ -24,11 +25,13 @@ import type { ConnectorSyncResult, StripeSyncResult, } from "@/core/resources/connector/index.js"; +import { detectAppDeployKind } from "@/core/site/index.js"; interface DeployOptions { yes?: boolean; build?: boolean; projectRoot?: string; + gitHash?: string; } export async function deployAction( @@ -41,16 +44,20 @@ export async function deployAction( } const projectData = await readProjectConfig(options.projectRoot); + const { project, entities, functions, agents, connectors, authConfig } = + projectData; - if (!hasResourcesToDeploy(projectData)) { + // Best-effort pre-build look at what the site step would ship, for the + // summary and the no-resources check. The build below can change the + // answer, so the deploy itself decides again. + const plannedSite = await detectAppDeployKind(project); + + if (!hasResourcesToDeploy(projectData) && plannedSite === "none") { return { outroMessage: "No resources found to deploy", }; } - const { project, entities, functions, agents, connectors, authConfig } = - projectData; - // Build summary of what will be deployed const summaryLines: string[] = []; if (entities.length > 0) { @@ -102,11 +109,13 @@ export async function deployAction( await maybeBuildBeforeDeploy(ctx, project, options.build); - // Deploy resources with per-function progress + // Deploy resources with per-function progress. The site ships below, + // from whatever the build produced. let functionCompleted = 0; const functionTotal = functions.length; const result = await deployAll(projectData, { + site: false, onVisibilitySet: (level) => { log.success(`App visibility set to ${level}`); }, @@ -124,6 +133,10 @@ export async function deployAction( }, }); + const siteResult = await runAppSiteDeploy(ctx, project, { + gitHash: options.gitHash, + }); + // Handle connector-specific post-deploy flows const connectorResults = result.connectorResults ?? []; await handleOAuthConnectors(connectorResults, isNonInteractive, options, log); @@ -135,13 +148,42 @@ export async function deployAction( log.message( `${theme.styles.header("Dashboard")}: ${theme.colors.links(getDashboardUrl())}`, ); - if (result.appUrl) { + if (siteResult.kind === "static") { log.message( - `${theme.styles.header("App URL")}: ${theme.colors.links(result.appUrl)}`, + `${theme.styles.header("App URL")}: ${theme.colors.links(siteResult.appUrl)}`, ); } + const deployment = + siteResult.kind === "static-deployment" ? siteResult : undefined; + if (deployment) { + printDeploymentSummary(deployment, log); + } - return { outroMessage: "App deployed successfully" }; + return { + outroMessage: "App deployed successfully", + stdout: + ctx.jsonMode && deployment + ? `${JSON.stringify( + { + deploymentId: deployment.deploymentId, + gitHash: deployment.gitHash, + }, + null, + 2, + )}\n` + : undefined, + }; +} + +function printDeploymentSummary( + deployment: { deploymentId: string; gitHash: string }, + log: Logger, +): void { + // A build has no URL of its own: what production serves is decided when the + // app is published from the builder, not by this deploy. + log.message( + `${theme.styles.header("Deployment")}: ${deployment.deploymentId} ${theme.styles.dim(`(commit ${deployment.gitHash.slice(0, 12)})`)}`, + ); } export function getDeployCommand(): Command { @@ -150,6 +192,10 @@ export function getDeployCommand(): Command { "Deploy all project resources (entities, functions, agents, connectors, and site)", ) .option("-y, --yes", "Skip confirmation prompt") + .option( + "--git-hash ", + "Commit the build came from (defaults to the checkout's HEAD)", + ) .option("--build", "Build the site before deploying (skips the prompt)") .option("--no-build", "Deploy without building (skips the prompt)") .action(deployAction); diff --git a/packages/cli/src/cli/commands/site/deploy.ts b/packages/cli/src/cli/commands/site/deploy.ts index 03ecc2519..38e1e300b 100644 --- a/packages/cli/src/cli/commands/site/deploy.ts +++ b/packages/cli/src/cli/commands/site/deploy.ts @@ -1,4 +1,3 @@ -import { resolve } from "node:path"; import { confirm, isCancel } from "@clack/prompts"; import type { Command } from "commander"; import { maybeBuildBeforeDeploy } from "@/cli/commands/project/site-build.js"; @@ -6,25 +5,29 @@ import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { Base44Command } from "@/cli/utils/index.js"; import { ConfigNotFoundError, InvalidInputError } from "@/core/errors.js"; import { readProjectConfig } from "@/core/project/index.js"; -import { deploySite } from "@/core/site/index.js"; +import { detectAppDeployKind } from "@/core/site/index.js"; +import { runAppSiteDeploy } from "./run-app-deploy.js"; interface DeployOptions { yes?: boolean; build?: boolean; + gitHash?: string; } async function deployAction( ctx: CLIContext, options: DeployOptions, ): Promise { - const { isNonInteractive, runTask } = ctx; + const { isNonInteractive } = ctx; if (isNonInteractive && !options.yes) { throw new InvalidInputError("--yes is required in non-interactive mode"); } const { project } = await readProjectConfig(); - if (!project.site?.outputDirectory) { + const kind = await detectAppDeployKind(project); + + if (kind === "none") { throw new ConfigNotFoundError("No site configuration found.", { hints: [ { @@ -35,11 +38,9 @@ async function deployAction( }); } - const outputDir = resolve(project.root, project.site.outputDirectory); - if (!options.yes) { const shouldDeploy = await confirm({ - message: `Deploy site from ${project.site.outputDirectory}?`, + message: `Deploy site from ${project.site?.outputDirectory}?`, }); if (isCancel(shouldDeploy) || !shouldDeploy) { @@ -49,24 +50,40 @@ async function deployAction( await maybeBuildBeforeDeploy(ctx, project, options.build); - const result = await runTask( - "Creating archive and deploying site...", - async () => { - return await deploySite(outputDir); - }, - { - successMessage: "Site deployed successfully", - errorMessage: "Deployment failed", - }, - ); + const result = await runAppSiteDeploy(ctx, project, { + gitHash: options.gitHash, + }); + + if (result.kind === "static-deployment") { + // A build has no URL of its own: what production serves is decided when + // the app is published from the builder, not by this deploy. + return { + outroMessage: `Deployment ${result.deploymentId} (commit ${result.gitHash.slice(0, 12)})`, + stdout: ctx.jsonMode + ? `${JSON.stringify( + { deploymentId: result.deploymentId, gitHash: result.gitHash }, + null, + 2, + )}\n` + : undefined, + }; + } + + if (result.kind === "static") { + return { outroMessage: `Visit your site at: ${result.appUrl}` }; + } - return { outroMessage: `Visit your site at: ${result.appUrl}` }; + return { outroMessage: "Nothing to deploy" }; } export function getSiteDeployCommand(): Command { return new Base44Command("deploy") - .description("Deploy built site files to Base44 hosting") + .description("Deploy the built site to Base44 hosting") .option("-y, --yes", "Skip confirmation prompt") + .option( + "--git-hash ", + "Commit the build came from (defaults to the checkout's HEAD)", + ) .option("--build", "Build the site before deploying (skips the prompt)") .option("--no-build", "Deploy without building (skips the prompt)") .action(deployAction); diff --git a/packages/cli/src/cli/commands/site/run-app-deploy.ts b/packages/cli/src/cli/commands/site/run-app-deploy.ts new file mode 100644 index 000000000..a402b9758 --- /dev/null +++ b/packages/cli/src/cli/commands/site/run-app-deploy.ts @@ -0,0 +1,69 @@ +import type { CLIContext } from "@/cli/types.js"; +import { theme } from "@/cli/utils/index.js"; +import type { AppDeployResult, AppSiteTarget } from "@/core/site/index.js"; +import { deployAppSite, detectAppDeployKind } from "@/core/site/index.js"; + +const TASK_LABELS = { + "static-deployment": { + start: "Deploying site...", + success: "Site deployed", + error: "Site deploy failed", + }, + static: { + start: "Creating archive and deploying site...", + success: "Site deployed successfully", + error: "Deployment failed", + }, +} as const; + +/** + * Run the project's site deploy behind a spinner, adapting the labels and the + * progress stream to whichever transport applies. The kind is detected here + * only to pick the messages; `deployAppSite` decides for itself what to ship. + */ +export async function runAppSiteDeploy( + { runTask, log }: CLIContext, + target: AppSiteTarget, + options: { gitHash?: string } = {}, +): Promise { + const kind = await detectAppDeployKind(target); + if (kind === "none") return { kind: "none" }; + + const labels = TASK_LABELS[kind]; + const progressLines: string[] = []; + const warnings: string[] = []; + + const result = await runTask( + labels.start, + async (updateMessage) => + await deployAppSite(target, { + gitHash: options.gitHash, + progress: { + onWarning: (message) => { + warnings.push(message); + }, + onAssets: ({ totalAssets, newAssets }) => { + const line = `Found ${totalAssets} static assets (${newAssets} new)`; + progressLines.push(line); + updateMessage(line); + }, + onAssetUpload: ({ uploadedFiles, totalFiles }) => { + updateMessage(`Uploaded ${uploadedFiles} of ${totalFiles} assets`); + }, + }, + }), + { + successMessage: labels.success, + errorMessage: labels.error, + }, + ); + + for (const line of progressLines) { + log.message(theme.styles.dim(line)); + } + for (const warning of warnings) { + log.warn(warning); + } + + return result; +} diff --git a/packages/cli/src/core/deployments/api.ts b/packages/cli/src/core/deployments/api.ts new file mode 100644 index 000000000..311a92092 --- /dev/null +++ b/packages/cli/src/core/deployments/api.ts @@ -0,0 +1,85 @@ +import type { KyResponse } from "ky"; +import { getAppClient } from "@/core/clients/index.js"; +import { ApiError, SchemaValidationError } from "@/core/errors.js"; +import type { + CreateDeploymentRequest, + CreateDeploymentResponse, + FinalizeDeploymentResponse, +} from "./schema.js"; +import { + CreateDeploymentResponseSchema, + FinalizeDeploymentResponseSchema, +} from "./schema.js"; + +export async function createDeployment( + request: CreateDeploymentRequest, +): Promise { + const appClient = getAppClient(); + + let response: KyResponse; + try { + response = await appClient.post("deployments", { + json: request, + timeout: 120_000, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, "creating deployment"); + } + + const result = CreateDeploymentResponseSchema.safeParse( + await response.json(), + ); + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error, + ); + } + return result.data; +} + +/** + * Finalize a static-site (s3-target) deployment. The form carries exactly one + * file part — `index.html` — and nothing else (no `payload`, no modules): + * index.html is always excluded from the presigned uploads and travels + * through finalize as the sentinel that completes the deployment. + */ +export async function finalizeStaticDeployment( + deploymentId: string, + indexHtml: Uint8Array, +): Promise { + const formData = new FormData(); + formData.append( + "index.html", + new File([indexHtml], "index.html", { type: "text/html" }), + ); + return await postFinalize(deploymentId, formData); +} + +async function postFinalize( + deploymentId: string, + formData: FormData, +): Promise { + const appClient = getAppClient(); + + let response: KyResponse; + try { + response = await appClient.post( + `deployments/${encodeURIComponent(deploymentId)}/finalize`, + { body: formData, timeout: 180_000 }, + ); + } catch (error) { + throw await ApiError.fromHttpError(error, "finalizing deployment"); + } + + const result = FinalizeDeploymentResponseSchema.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/deployments/git-hash.ts b/packages/cli/src/core/deployments/git-hash.ts new file mode 100644 index 000000000..3910429cd --- /dev/null +++ b/packages/cli/src/core/deployments/git-hash.ts @@ -0,0 +1,42 @@ +import { execa } from "execa"; +import { InvalidInputError } from "@/core/errors.js"; +import { GIT_HASH_PATTERN } from "./schema.js"; + +/** + * The commit this build came from — a deployment is addressed by it, so the + * hash is required. An explicit hash (flag/automation) wins; otherwise it + * comes from the git checkout at the project root. + */ +export async function resolveGitHash( + projectRoot: string, + explicit?: string, +): Promise { + const hash = explicit ?? (await gitHead(projectRoot)); + if (!hash || !GIT_HASH_PATTERN.test(hash)) { + throw new InvalidInputError( + explicit + ? `'${explicit}' is not a git commit hash.` + : "Deployments are addressed by the commit that produced the build, and no git commit was found.", + { + hints: [ + { + message: + "Run the deploy from a git checkout, or pass the commit explicitly with --git-hash.", + }, + ], + }, + ); + } + return hash; +} + +async function gitHead(projectRoot: string): Promise { + try { + const { stdout } = await execa("git", ["rev-parse", "HEAD"], { + cwd: projectRoot, + }); + return stdout.trim(); + } catch { + return null; + } +} diff --git a/packages/cli/src/core/deployments/index.ts b/packages/cli/src/core/deployments/index.ts new file mode 100644 index 000000000..0a3e99d49 --- /dev/null +++ b/packages/cli/src/core/deployments/index.ts @@ -0,0 +1,6 @@ +export * from "./api.js"; +export * from "./git-hash.js"; +export * from "./manifest.js"; +export * from "./schema.js"; +export * from "./static-site.js"; +export * from "./upload.js"; diff --git a/packages/cli/src/core/deployments/manifest.ts b/packages/cli/src/core/deployments/manifest.ts new file mode 100644 index 000000000..86653dbed --- /dev/null +++ b/packages/cli/src/core/deployments/manifest.ts @@ -0,0 +1,183 @@ +import { createHash } from "node:crypto"; +import { readdir, readFile, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { InvalidInputError } from "@/core/errors.js"; +import { pathExists, readTextFile } from "@/core/utils/fs.js"; +import type { + AssetFile, + AssetManifestEntry, + AssetManifestResult, +} from "./schema.js"; + +const MAX_ASSET_SIZE_BYTES = 25 * 1024 * 1024; // 25 MiB +const MAX_ASSET_COUNT = 100_000; + +const ASSETS_IGNORE_FILE = ".assetsignore"; + +/** Files never uploaded as assets, regardless of .assetsignore. */ +const ALWAYS_SKIPPED_FILES = new Set([ + ASSETS_IGNORE_FILE, + "wrangler.json", + ".dev.vars", +]); + +/** + * Content-addressed asset hash: first 32 hex chars of + * sha256(utf8(app_id) || raw file bytes). Salting with the app id means a + * tenant can only produce hash collisions with their own files, so a + * malicious upload cannot poison another app's asset cache. + */ +export function hashAsset(appId: string, content: Buffer): string { + return createHash("sha256") + .update(Buffer.from(appId, "utf8")) + .update(content) + .digest("hex") + .slice(0, 32); +} + +type IgnoreMatcher = (relativePath: string, isDirectory: boolean) => boolean; + +function globToRegExp(glob: string): RegExp { + let source = ""; + for (let i = 0; i < glob.length; i++) { + const char = glob[i]; + if (char === "*") { + if (glob[i + 1] === "*") { + source += ".*"; + i++; + } else { + source += "[^/]*"; + } + } else if (char === "?") { + source += "[^/]"; + } else { + source += char.replace(/[.+^${}()|[\]\\]/g, "\\$&"); + } + } + return new RegExp(`^${source}$`); +} + +/** + * Minimal gitignore-style matcher for .assetsignore. Supports exact names, + * `*`/`**` globs, directory patterns (trailing `/`), and root-anchored + * patterns (containing `/`). Negation (`!`) is not supported. + */ +function createIgnoreMatcher(lines: string[]): IgnoreMatcher { + const rules = lines + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith("#")) + .map((line) => { + const isDirOnly = line.endsWith("/"); + let pattern = isDirOnly ? line.slice(0, -1) : line; + const anchored = pattern.includes("/"); + pattern = pattern.replace(/^\//, ""); + return { regex: globToRegExp(pattern), anchored, isDirOnly }; + }); + + return (relativePath, isDirectory) => { + const segments = relativePath.split("/"); + return rules.some((rule) => { + if (rule.anchored) { + if (rule.isDirOnly ? isDirectory : true) { + if (rule.regex.test(relativePath)) return true; + } + // A directory pattern also ignores everything under the directory; + // matching directories are pruned during the walk. + return false; + } + // Unanchored: match the basename (and for dir-only rules, any segment — + // but directories are pruned during the walk, so files only need their + // own basename checked). + const basename = segments[segments.length - 1]; + if (rule.isDirOnly && !isDirectory) return false; + return rule.regex.test(basename); + }); + }; +} + +async function loadIgnoreMatcher(assetsDir: string): Promise { + const ignorePath = join(assetsDir, ASSETS_IGNORE_FILE); + if (!(await pathExists(ignorePath))) { + return () => false; + } + const content = await readTextFile(ignorePath); + return createIgnoreMatcher(content.split(/\r?\n/)); +} + +/** + * Walk the assets directory and build the deployment asset manifest. + * Honors `.assetsignore` at the assets root, always skips `.assetsignore`, + * `wrangler.json`, and `.dev.vars`, rejects files larger than 25 MiB, and + * caps the total file count at 100,000. + */ +export async function buildAssetManifest( + assetsDir: string, + appId: string, +): Promise { + const isIgnored = await loadIgnoreMatcher(assetsDir); + const manifest: Record = {}; + const filesByHash = new Map(); + + const relativeFilePaths = await collectFilePaths(assetsDir, "", isIgnored); + + if (relativeFilePaths.length > MAX_ASSET_COUNT) { + throw new InvalidInputError( + `Too many static assets: found ${relativeFilePaths.length}, the limit is ${MAX_ASSET_COUNT} files.`, + ); + } + + for (const relativePath of relativeFilePaths.sort()) { + const absolutePath = join(assetsDir, ...relativePath.split("/")); + const { size } = await stat(absolutePath); + if (size > MAX_ASSET_SIZE_BYTES) { + throw new InvalidInputError( + `Static asset "${relativePath}" is ${size} bytes, which exceeds the 25 MiB per-file limit.`, + ); + } + + const content = await readFile(absolutePath); + const hash = hashAsset(appId, content); + + manifest[`/${relativePath}`] = { hash, size }; + if (!filesByHash.has(hash)) { + filesByHash.set(hash, { absolutePath, hash, size }); + } + } + + return { manifest, filesByHash }; +} + +async function collectFilePaths( + dir: string, + relativeDir: string, + isIgnored: IgnoreMatcher, +): Promise { + const entries = await readdir(dir, { withFileTypes: true }); + const results: string[] = []; + + for (const entry of entries) { + const relativePath = relativeDir + ? `${relativeDir}/${entry.name}` + : entry.name; + + if (entry.isDirectory()) { + if (isIgnored(relativePath, true)) continue; + results.push( + ...(await collectFilePaths( + join(dir, entry.name), + relativePath, + isIgnored, + )), + ); + continue; + } + + if (!entry.isFile()) continue; + if (ALWAYS_SKIPPED_FILES.has(entry.name)) continue; + if (isIgnored(relativePath, false)) continue; + + results.push(relativePath); + } + + return results; +} diff --git a/packages/cli/src/core/deployments/schema.ts b/packages/cli/src/core/deployments/schema.ts new file mode 100644 index 000000000..e9469d0e6 --- /dev/null +++ b/packages/cli/src/core/deployments/schema.ts @@ -0,0 +1,141 @@ +import { z } from "zod"; + +// ─── SHARED ────────────────────────────────────────────────── + +/** Manifest entry keyed by URL-ish path ("/index.html"). */ +export interface AssetManifestEntry { + hash: string; + size: number; +} + +/** A static asset discovered in the assets directory, keyed by hash. */ +export interface AssetFile { + /** Absolute path on disk. */ + absolutePath: string; + hash: string; + size: number; +} + +export interface AssetManifestResult { + /** URL path → { hash, size }, ready for the create-deployment payload. */ + manifest: Record; + /** Hash → file info, used to serve the requested uploads. */ + filesByHash: Map; +} + +/** Progress of an in-flight asset upload set. */ +export interface AssetUploadProgress { + uploadedFiles: number; + totalFiles: number; +} + +/** Progress callbacks a deploy fires as it moves through its stages. */ +export interface DeploymentProgress { + /** Fired for non-fatal issues worth surfacing to the user. */ + onWarning?: (message: string) => void; + /** Fired after the deployment is created: total assets and how many need uploading. */ + onAssets?: (info: { totalAssets: number; newAssets: number }) => void; + /** Fired after each asset upload completes. */ + onAssetUpload?: (progress: AssetUploadProgress) => void; +} + +/** + * A deployment is addressed by the commit that produced it: the server derives + * the deployment id from `git_hash`, so one commit means one deployment and + * re-deploying a commit is idempotent. Same pattern the server validates. + */ +export const GIT_HASH_PATTERN = /^[a-fA-F0-9]{7,64}$/; + +/** + * Request payload for POST deployments (sent as snake_case JSON). A request + * without a worker config is a static-site deployment — the server answers + * it with the `s3` arm of the create response. + */ +export interface CreateDeploymentRequest { + git_hash: string; + asset_manifest: Record; +} + +// ─── RESPONSES ─────────────────────────────────────────────── + +/** A static asset the server wants uploaded, with its presigned S3 URL. */ +export interface PresignedAssetUpload { + /** Manifest path of the asset ("/assets/app.js"). */ + path: string; + /** Content-Type signed into the URL — the PUT must send it verbatim. */ + contentType: string; + /** Byte count signed into the URL — the PUT body must be exactly this long. */ + contentLength: number; + /** Presigned S3 URL — the URL itself is the credential. */ + url: string; +} + +interface S3AssetUploads { + type: "s3"; + uploads: PresignedAssetUpload[]; +} + +/** + * POST deployments answers `{deployment_id, asset_uploads}` where + * `asset_uploads` says where the assets still owed should go, discriminated + * on `type` — a config-less (static-site) request is always answered with + * the `s3` arm: direct presigned PUTs, always excluding `/index.html` + * (finalize carries it) — and is null when nothing is owed (no assets, or + * the build already exists). + */ +export const CreateDeploymentResponseSchema = z + .object({ + deployment_id: z.string(), + asset_uploads: z + .object({ + type: z.literal("s3"), + uploads: z.array( + z.object({ + path: z.string(), + content_type: z.string(), + content_length: z.number(), + url: z.string(), + }), + ), + }) + .nullable() + .optional(), + }) + .transform( + ( + data, + ): { + deploymentId: string; + assetUploads: S3AssetUploads | null; + } => ({ + deploymentId: data.deployment_id, + assetUploads: + data.asset_uploads == null + ? null + : { + type: "s3", + uploads: data.asset_uploads.uploads.map((upload) => ({ + path: upload.path, + contentType: upload.content_type, + contentLength: upload.content_length, + url: upload.url, + })), + }, + }), + ); + +export type CreateDeploymentResponse = z.infer< + typeof CreateDeploymentResponseSchema +>; + +export const FinalizeDeploymentResponseSchema = z + .object({ + deployment_id: z.string(), + }) + .transform((data) => ({ + deploymentId: data.deployment_id, + })); + +export type FinalizeDeploymentResponse = z.infer< + typeof FinalizeDeploymentResponseSchema +>; diff --git a/packages/cli/src/core/deployments/static-site.ts b/packages/cli/src/core/deployments/static-site.ts new file mode 100644 index 000000000..4bbde5979 --- /dev/null +++ b/packages/cli/src/core/deployments/static-site.ts @@ -0,0 +1,79 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { InvalidInputError } from "@/core/errors.js"; +import { getAppContext } from "@/core/project/app-config.js"; +import { createDeployment, finalizeStaticDeployment } from "./api.js"; +import { buildAssetManifest } from "./manifest.js"; +import type { DeploymentProgress } from "./schema.js"; +import { uploadPresignedAssets } from "./upload.js"; + +/** + * Internal gate for the experimental static-site deployments-API lane. Not + * user-facing yet: when set to "1" or "true", `base44 deploy` sends the + * configured site output through the deployments API instead of the legacy + * tar.gz site upload. + */ +const STATIC_DEPLOYMENTS_ENV = "BASE44_STATIC_DEPLOYMENTS"; + +export function staticDeploymentsEnabled( + env: NodeJS.ProcessEnv = process.env, +): boolean { + const value = env[STATIC_DEPLOYMENTS_ENV]; + return value === "1" || value === "true"; +} + +/** + * Deploy a static site build through the deployments API: hash the output + * directory into an asset manifest and create the deployment at the commit's + * address with no worker config — which the server answers with the `s3` arm + * of the discriminated create response — then PUT the requested files + * directly to their presigned URLs and finalize with the index.html bytes. + * + * This is the progressive-upgrade path: when the app later adopts a server + * framework, the create request carries its worker config and the server + * answers with the `cf` arm instead — same CLI protocol, zero CLI change. + */ +export async function deployStaticSite(options: { + outputDir: string; + gitHash: string; + progress?: DeploymentProgress; +}): Promise<{ deploymentId: string; gitHash: string }> { + const { outputDir, gitHash, progress } = options; + + const assets = await buildAssetManifest(outputDir, getAppContext().id); + // Finalize carries the index.html bytes by contract, so its absence is a + // broken build (or a wrong outputDirectory) — fail before any upload. + if (!assets.manifest["/index.html"]) { + throw new InvalidInputError( + `No index.html found in "${outputDir}" — a static site needs one at the output directory root.`, + ); + } + + const created = await createDeployment({ + git_hash: gitHash, + asset_manifest: assets.manifest, + }); + // The uploads always exclude index.html; null means every asset is already + // stored (re-deploying a commit is idempotent). + const totalAssets = Object.keys(assets.manifest).length; + progress?.onAssets?.({ + totalAssets, + newAssets: created.assetUploads?.uploads.length ?? 0, + }); + + if (created.assetUploads) { + await uploadPresignedAssets( + created.assetUploads.uploads, + assets, + progress?.onAssetUpload, + ); + } + + const indexHtml = await readFile(join(outputDir, "index.html")); + const finalized = await finalizeStaticDeployment( + created.deploymentId, + new Uint8Array(indexHtml), + ); + + return { deploymentId: finalized.deploymentId, gitHash }; +} diff --git a/packages/cli/src/core/deployments/upload.ts b/packages/cli/src/core/deployments/upload.ts new file mode 100644 index 000000000..4f0e4e673 --- /dev/null +++ b/packages/cli/src/core/deployments/upload.ts @@ -0,0 +1,83 @@ +import { readFile } from "node:fs/promises"; +import ky from "ky"; +import { ApiError, InternalError } from "@/core/errors.js"; +import type { + AssetManifestResult, + AssetUploadProgress, + PresignedAssetUpload, +} from "./schema.js"; + +const UPLOAD_CONCURRENCY = 3; +const MAX_ATTEMPTS_PER_UPLOAD = 3; +const RETRY_BASE_DELAY_MS = 500; + +/** + * PUT static assets directly to their presigned S3 URLs (the `s3` create + * arm). A presigned URL carries its own authorization in the query string, so + * each request is a plain fetch — never the app client, never an + * Authorization header. Uploads run with concurrency 3; + * each file gets 3 attempts with exponential backoff. + */ +export async function uploadPresignedAssets( + uploads: PresignedAssetUpload[], + assets: AssetManifestResult, + onProgress?: (progress: AssetUploadProgress) => void, +): Promise { + let uploadedFiles = 0; + + let nextUpload = 0; + const worker = async (): Promise => { + while (nextUpload < uploads.length) { + const upload = uploads[nextUpload++]; + await uploadPresignedAssetWithRetry(upload, assets); + uploadedFiles++; + onProgress?.({ uploadedFiles, totalFiles: uploads.length }); + } + }; + + await Promise.all( + Array.from( + { length: Math.min(UPLOAD_CONCURRENCY, uploads.length) }, + worker, + ), + ); +} + +async function uploadPresignedAssetWithRetry( + upload: PresignedAssetUpload, + assets: AssetManifestResult, +): Promise { + const entry = assets.manifest[upload.path]; + const file = entry && assets.filesByHash.get(entry.hash); + if (!file) { + throw new InternalError( + `Server requested upload of unknown asset path: ${upload.path}`, + ); + } + const content = await readFile(file.absolutePath); + + let lastError: unknown; + for (let attempt = 0; attempt < MAX_ATTEMPTS_PER_UPLOAD; attempt++) { + if (attempt > 0) { + await sleep(RETRY_BASE_DELAY_MS * 2 ** (attempt - 1)); + } + try { + await ky.put(upload.url, { + body: new Uint8Array(content), + // The server signed this exact Content-Type into the URL — deriving + // our own value would 403 on any mapping difference. + headers: { "Content-Type": upload.contentType }, + timeout: 120_000, + retry: 0, + }); + return; + } catch (error) { + lastError = error; + } + } + throw await ApiError.fromHttpError(lastError, "uploading static assets"); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/cli/src/core/index.ts b/packages/cli/src/core/index.ts index b6b6250d7..cb9e329b6 100644 --- a/packages/cli/src/core/index.ts +++ b/packages/cli/src/core/index.ts @@ -2,6 +2,7 @@ export * from "./auth/index.js"; export * from "./clients/index.js"; export * from "./config.js"; export * from "./consts.js"; +export * from "./deployments/index.js"; export * from "./errors.js"; export * from "./project/index.js"; export * from "./resources/index.js"; diff --git a/packages/cli/src/core/project/deploy.ts b/packages/cli/src/core/project/deploy.ts index 99ff0ef95..58a55000a 100644 --- a/packages/cli/src/core/project/deploy.ts +++ b/packages/cli/src/core/project/deploy.ts @@ -33,7 +33,11 @@ export function hasResourcesToDeploy(projectData: ProjectData): boolean { connectors, authConfig, } = projectData; - const hasSite = Boolean(project.site?.outputDirectory); + // A build command counts: a full-stack project may configure nothing but + // the build, and a generated artifact won't be on disk until it has run. + const hasSite = Boolean( + project.site?.outputDirectory || project.site?.buildCommand, + ); const hasEntities = entities.length > 0; const hasFunctions = functions.length > 0; const hasAgents = agents.length > 0; @@ -71,6 +75,13 @@ interface DeployAllResult { interface DeployAllOptions { onFunctionStart?: (names: string[]) => void; onFunctionResult?: (result: SingleFunctionDeployResult) => void; + /** + * Deploy the legacy static site (tar.gz upload) when configured. + * The unified deploy command passes false and handles the site itself, + * so full-stack (Workers) projects can take the deployments path instead. + * @default true + */ + site?: boolean; onVisibilitySet?: (visibility: Visibility) => void; } @@ -116,7 +127,7 @@ export async function deployAll( ? [] : (await pushConnectors(connectors)).results; - if (project.site?.outputDirectory) { + if ((options?.site ?? true) && project.site?.outputDirectory) { const outputDir = resolve(project.root, project.site.outputDirectory); const { appUrl } = await deploySite(outputDir); return { appUrl, connectorResults }; diff --git a/packages/cli/src/core/site/deploy-app.ts b/packages/cli/src/core/site/deploy-app.ts new file mode 100644 index 000000000..b9f5e1fbf --- /dev/null +++ b/packages/cli/src/core/site/deploy-app.ts @@ -0,0 +1,83 @@ +import { resolve } from "node:path"; +import type { DeploymentProgress } from "@/core/deployments/index.js"; +import { + deployStaticSite, + resolveGitHash, + staticDeploymentsEnabled, +} from "@/core/deployments/index.js"; +import { deploySite } from "@/core/site/deploy.js"; + +/** The project fields an app deploy reads. */ +export interface AppSiteTarget { + root: string; + site?: { outputDirectory?: string }; +} + +/** Which transport ships this project's built output. */ +type AppDeployKind = "static-deployment" | "static" | "none"; + +export type AppDeployResult = + | { kind: "static-deployment"; deploymentId: string; gitHash: string } + | { kind: "static"; appUrl: string } + | { kind: "none" }; + +type AppDeployPlan = + | { kind: "static-deployment"; outputDir: string } + | { kind: "static"; outputDir: string } + | { kind: "none" }; + +/** + * A static output ships through the deployments API when the lane is + * enabled, and as the legacy tar.gz upload otherwise. + */ +async function planAppDeploy(target: AppSiteTarget): Promise { + const outputDirectory = target.site?.outputDirectory; + if (!outputDirectory) { + return { kind: "none" }; + } + const outputDir = resolve(target.root, outputDirectory); + return staticDeploymentsEnabled() + ? { kind: "static-deployment", outputDir } + : { kind: "static", outputDir }; +} + +/** + * How the project's built output would ship right now. This only answers for + * the current state of the tree — call it again after any build step. + */ +export async function detectAppDeployKind( + target: AppSiteTarget, +): Promise { + return (await planAppDeploy(target)).kind; +} + +/** + * Deploy the project's built output over whichever transport applies — + * a deployments-API static deployment when the lane is enabled, the legacy + * tar.gz upload otherwise. Returns `{ kind: "none" }` when the project has + * nothing to ship. + */ +export async function deployAppSite( + target: AppSiteTarget, + options: { gitHash?: string; progress?: DeploymentProgress } = {}, +): Promise { + const plan = await planAppDeploy(target); + + switch (plan.kind) { + case "static-deployment": { + const gitHash = await resolveGitHash(target.root, options.gitHash); + const { deploymentId } = await deployStaticSite({ + outputDir: plan.outputDir, + gitHash, + progress: options.progress, + }); + return { kind: "static-deployment", deploymentId, gitHash }; + } + case "static": { + const { appUrl } = await deploySite(plan.outputDir); + return { kind: "static", appUrl }; + } + case "none": + return { kind: "none" }; + } +} diff --git a/packages/cli/src/core/site/index.ts b/packages/cli/src/core/site/index.ts index 676ceabda..9012035ad 100644 --- a/packages/cli/src/core/site/index.ts +++ b/packages/cli/src/core/site/index.ts @@ -1,4 +1,5 @@ export * from "./api.js"; export * from "./config.js"; export * from "./deploy.js"; +export * from "./deploy-app.js"; export * from "./schema.js"; diff --git a/packages/cli/tests/cli/static_site_deployments.spec.ts b/packages/cli/tests/cli/static_site_deployments.spec.ts new file mode 100644 index 000000000..3134bd3ab --- /dev/null +++ b/packages/cli/tests/cli/static_site_deployments.spec.ts @@ -0,0 +1,185 @@ +import { readFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { fixture, setupCLITests } from "./testkit/index.js"; + +/** The commit the fixture "build" came from (the fixture is not a git repo). */ +const GIT_HASH = "0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c"; +const DEPLOYMENT_ID = "test-app-git-0f1e2d3c4b5a"; + +/** Server-side content types differ from the CLI's own mapping on purpose — + * the tests prove the signed value wins. */ +const SIGNED_CONTENT_TYPES: Record = { + "/main.js": "application/javascript", + "/styles.css": "text/css", +}; + +/** Byte counts the server signs into the URLs (from the real fixture files). */ +const FIXTURE_SIZES: Record = Object.fromEntries( + ["/main.js", "/styles.css"].map((path) => [ + path, + readFileSync(join(fixture("with-site"), "site-output", path.slice(1))) + .length, + ]), +); + +interface CreateBody { + git_hash: string; + asset_manifest: Record; +} + +describe("deploy command (static site through the deployments API, env-gated)", () => { + const t = setupCLITests(); + + /** Mocks hit by the unified deploy's resource-push phase (no resources). */ + function mockResourcePushes() { + t.api.mockAgentsPush({ created: [], updated: [], deleted: [] }); + t.api.mockConnectorsList({ integrations: [] }); + t.api.mockStripeStatus({ stripe_mode: null }); + } + + /** The s3 create arm: presigned PUT targets for the requested paths. */ + function mockStaticCreate(uploadPaths: string[]) { + t.api.mockDeploymentCreate({ + deployment_id: DEPLOYMENT_ID, + asset_uploads: + uploadPaths.length === 0 + ? null + : { + type: "s3" as const, + uploads: uploadPaths.map((path) => ({ + path, + // The server derives this server-side and signs it into the URL; the + // CLI must echo it verbatim rather than derive its own. + content_type: `${SIGNED_CONTENT_TYPES[path]}; charset=utf-8`, + content_length: FIXTURE_SIZES[path], + url: `${t.api.baseUrl}/presigned${path}`, + })), + }, + }); + for (const path of uploadPaths) { + t.api.mockPresignedUpload(path); + } + } + + async function readSiteFile(name: string): Promise { + return await readFile(join(fixture("with-site"), "site-output", name)); + } + + it("keeps the legacy tar.gz site upload when the gate is off", async () => { + await t.givenLoggedInWithProject(fixture("with-site")); + mockResourcePushes(); + t.api.mockSiteDeploy({ app_url: "https://legacy.example.com" }); + + const result = await t.run("deploy", "-y"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("https://legacy.example.com"); + expect(t.api.deploymentCreateRequests).toHaveLength(0); + }); + + it("deploys the site output through the deployments API when gated on", async () => { + await t.givenLoggedInWithProject(fixture("with-site")); + t.givenEnv({ BASE44_STATIC_DEPLOYMENTS: "1" }); + mockResourcePushes(); + mockStaticCreate(["/main.js", "/styles.css"]); + t.api.mockDeploymentFinalize({ deployment_id: DEPLOYMENT_ID }); + + const result = await t.run("deploy", "-y", "--git-hash", GIT_HASH); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Found 3 static assets (2 new)"); + t.expectResult(result).toContain("Site deployed"); + t.expectResult(result).toContain(`Deployment: ${DEPLOYMENT_ID}`); + + // Create request: the commit address, NO config field at all (that is + // what selects the static arm), and index.html IS in the manifest — it + // is only ever excluded from the uploads. + expect(t.api.deploymentCreateRequests).toHaveLength(1); + const body = t.api.deploymentCreateRequests[0] as CreateBody; + expect(body.git_hash).toBe(GIT_HASH); + expect(body).not.toHaveProperty("config"); + expect(Object.keys(body.asset_manifest).sort()).toEqual([ + "/index.html", + "/main.js", + "/styles.css", + ]); + + // Raw bytes PUT directly to the presigned URLs: the computed content + // type, no auth header (the URL itself is the credential). + expect(t.api.presignedUploadRequests).toHaveLength(2); + const byPath = new Map( + t.api.presignedUploadRequests.map((r) => [r.path, r]), + ); + const mainJs = byPath.get("/main.js"); + expect(mainJs?.data.equals(await readSiteFile("main.js"))).toBe(true); + expect(mainJs?.contentType).toBe("application/javascript; charset=utf-8"); + expect(mainJs?.authorization).toBeUndefined(); + const styles = byPath.get("/styles.css"); + expect(styles?.data.equals(await readSiteFile("styles.css"))).toBe(true); + expect(styles?.contentType).toBe("text/css; charset=utf-8"); + expect(styles?.authorization).toBeUndefined(); + + // Finalize: exactly one file part — the index.html bytes. No payload, + // no modules. + expect(t.api.finalizeRequests).toHaveLength(1); + const fields = t.api.finalizeRequests[0]; + expect(fields.map((f) => f.name)).toEqual(["index.html"]); + expect(fields[0].data.equals(await readSiteFile("index.html"))).toBe(true); + // Bun's compiled binary normalizes Blob types to include the charset. + expect(fields[0].contentType).toMatch(/^text\/html(;\s*charset=utf-8)?$/i); + }); + + it("sends no PUTs and still finalizes when every asset is already stored", async () => { + await t.givenLoggedInWithProject(fixture("with-site")); + t.givenEnv({ BASE44_STATIC_DEPLOYMENTS: "true" }); + mockResourcePushes(); + mockStaticCreate([]); + t.api.mockDeploymentFinalize({ deployment_id: DEPLOYMENT_ID }); + + const result = await t.run("deploy", "-y", "--git-hash", GIT_HASH); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Found 3 static assets (0 new)"); + expect(t.api.presignedUploadRequests).toHaveLength(0); + expect(t.api.finalizeRequests).toHaveLength(1); + expect(t.api.finalizeRequests[0].map((f) => f.name)).toEqual([ + "index.html", + ]); + }); + + it("emits a single JSON document with --json", async () => { + await t.givenLoggedInWithProject(fixture("with-site")); + t.givenEnv({ BASE44_STATIC_DEPLOYMENTS: "1" }); + mockResourcePushes(); + mockStaticCreate(["/main.js", "/styles.css"]); + t.api.mockDeploymentFinalize({ deployment_id: DEPLOYMENT_ID }); + + const result = await t.run( + "deploy", + "-y", + "--git-hash", + GIT_HASH, + "--json", + ); + + t.expectResult(result).toSucceed(); + expect(JSON.parse(result.stdout)).toEqual({ + deploymentId: DEPLOYMENT_ID, + gitHash: GIT_HASH, + }); + }); + + it("requires a commit hash outside a git checkout", async () => { + await t.givenLoggedInWithProject(fixture("with-site")); + t.givenEnv({ BASE44_STATIC_DEPLOYMENTS: "1" }); + mockResourcePushes(); + + const result = await t.run("deploy", "-y"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("--git-hash"); + expect(t.api.deploymentCreateRequests).toHaveLength(0); + }); +}); diff --git a/packages/cli/tests/cli/testkit/TestAPIServer.ts b/packages/cli/tests/cli/testkit/TestAPIServer.ts index d210e5e2e..9779f6c10 100644 --- a/packages/cli/tests/cli/testkit/TestAPIServer.ts +++ b/packages/cli/tests/cli/testkit/TestAPIServer.ts @@ -202,6 +202,88 @@ interface CreateAppResponse { name: string; } +// ─── DEPLOYMENTS TYPES ────────────────────────────────────── + +interface DeploymentCreateResponse { + deployment_id: string; + /** Where the assets still owed should go; null/omitted = nothing owed. */ + asset_uploads?: { + type: "s3"; + uploads: Array<{ + path: string; + content_type: string; + content_length: number; + url: string; + }>; + } | null; +} + +interface DeploymentFinalizeResponse { + deployment_id: string; +} + +/** A parsed part of a multipart/form-data request body. */ +interface MultipartField { + name: string; + filename?: string; + contentType?: string; + data: Buffer; +} + +/** + * Minimal multipart/form-data parser for captured raw request bodies + * (the global express.raw middleware buffers multipart bodies as-is). + */ +function parseMultipart( + body: Buffer, + contentTypeHeader: string, +): MultipartField[] { + const boundaryMatch = /boundary=(?:"([^"]+)"|([^;]+))/.exec( + contentTypeHeader, + ); + if (!boundaryMatch) { + throw new Error(`No multipart boundary in: ${contentTypeHeader}`); + } + const boundary = `--${boundaryMatch[1] ?? boundaryMatch[2]}`; + + const fields: MultipartField[] = []; + const raw = body.toString("binary"); + const sections = raw.split(boundary).slice(1, -1); // drop preamble + closing "--" + + for (const section of sections) { + const part = section.replace(/^\r\n/, ""); + const headerEnd = part.indexOf("\r\n\r\n"); + if (headerEnd === -1) continue; + + const headerBlock = part.slice(0, headerEnd); + const data = Buffer.from( + part.slice(headerEnd + 4).replace(/\r\n$/, ""), + "binary", + ); + + const nameMatch = /name="([^"]*)"/.exec(headerBlock); + const filenameMatch = /filename="([^"]*)"/.exec(headerBlock); + const typeMatch = /content-type:\s*([^\r\n]+)/i.exec(headerBlock); + + fields.push({ + name: nameMatch?.[1] ?? "", + filename: filenameMatch?.[1], + contentType: typeMatch?.[1].trim(), + data, + }); + } + + return fields; +} + +/** A captured presigned-style asset PUT. */ +interface CapturedPresignedUpload { + path: string; + authorization?: string; + contentType?: string; + data: Buffer; +} + interface ListProjectsResponse { id: string; name: string; @@ -569,6 +651,72 @@ export class TestAPIServer { ); } + // ─── DEPLOYMENT ENDPOINTS ───────────────────────────────── + + /** Captured JSON bodies of POST deployments requests. */ + readonly deploymentCreateRequests: unknown[] = []; + /** Captured presigned-style asset PUTs (see mockPresignedUpload). */ + readonly presignedUploadRequests: CapturedPresignedUpload[] = []; + /** Captured multipart fields of finalize requests. */ + readonly finalizeRequests: MultipartField[][] = []; + + /** + * Mock POST /api/apps/{appId}/deployments. Captures the JSON request body + * in `deploymentCreateRequests`. + */ + mockDeploymentCreate(response: DeploymentCreateResponse): this { + this.pendingRoutes.push({ + method: "POST", + path: `/api/apps/${this.appId}/deployments`, + handler: (req, res) => { + this.deploymentCreateRequests.push(req.body); + res.status(200).json(response); + }, + }); + return this; + } + + /** + * Register a presigned-style PUT target for a static asset: serves + * PUT /presigned{path} — point `asset_uploads[].url` at + * `${baseUrl}/presigned{path}` — capturing the raw body, Content-Type, + * and any Authorization header in `presignedUploadRequests`. + */ + mockPresignedUpload(path: string): this { + this.pendingRoutes.push({ + method: "PUT", + path: `/presigned${path}`, + handler: (req, res) => { + this.presignedUploadRequests.push({ + path, + authorization: req.headers.authorization, + contentType: req.headers["content-type"], + data: req.body as Buffer, + }); + res.status(200).end(); + }, + }); + return this; + } + + /** + * Mock POST /api/apps/{appId}/deployments/{id}/finalize. Captures the + * multipart fields in `finalizeRequests`. + */ + mockDeploymentFinalize(response: DeploymentFinalizeResponse): this { + this.pendingRoutes.push({ + method: "POST", + path: `/api/apps/${this.appId}/deployments/:deploymentId/finalize`, + handler: (req, res) => { + this.finalizeRequests.push( + parseMultipart(req.body as Buffer, req.headers["content-type"] ?? ""), + ); + res.status(200).json(response); + }, + }); + return this; + } + // ─── SECRETS ENDPOINTS ─────────────────────────────────── mockSecretsList(response: SecretsListResponse): this { diff --git a/packages/cli/tests/core/deployments-manifest.spec.ts b/packages/cli/tests/core/deployments-manifest.spec.ts new file mode 100644 index 000000000..09d92ff35 --- /dev/null +++ b/packages/cli/tests/core/deployments-manifest.spec.ts @@ -0,0 +1,121 @@ +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, rm, truncate, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { buildAssetManifest, hashAsset } from "@/core/deployments/manifest.js"; + +describe("hashAsset", () => { + it("computes the first 32 hex chars of sha256(utf8(app_id) || bytes)", () => { + // Known vector: sha256("test-app-id" + "hello world") = + // b24ad526981fbac802de45c88c134ba4... (first 32 hex chars) + expect(hashAsset("test-app-id", Buffer.from("hello world"))).toBe( + "b24ad526981fbac802de45c88c134ba4", + ); + }); + + it("matches a locally computed sha256 over the concatenated bytes", () => { + const expected = createHash("sha256") + .update(Buffer.concat([Buffer.from("app-1"), Buffer.from("content")])) + .digest("hex") + .slice(0, 32); + expect(hashAsset("app-1", Buffer.from("content"))).toBe(expected); + }); + + it("salts with the app id so tenants can only collide with themselves", () => { + const content = Buffer.from("hello world"); + expect(hashAsset("test-app-id", content)).not.toBe( + hashAsset("other-app", content), + ); + }); +}); + +describe("buildAssetManifest", () => { + let assetsDir: string; + + beforeEach(async () => { + assetsDir = await mkdtemp(join(tmpdir(), "b44-assets-")); + }); + + afterEach(async () => { + await rm(assetsDir, { recursive: true, force: true }); + }); + + it("builds manifest keys as /-prefixed forward-slash paths with hash and size", async () => { + await writeFile(join(assetsDir, "index.html"), "

Hello

\n"); + await mkdir(join(assetsDir, "assets")); + await writeFile(join(assetsDir, "assets", "app.js"), "console.log(1);"); + + const { manifest, filesByHash } = await buildAssetManifest( + assetsDir, + "test-app-id", + ); + + expect(Object.keys(manifest).sort()).toEqual([ + "/assets/app.js", + "/index.html", + ]); + expect(manifest["/index.html"]).toEqual({ + hash: hashAsset("test-app-id", Buffer.from("

Hello

\n")), + size: 15, + }); + const entry = manifest["/assets/app.js"]; + expect(filesByHash.get(entry.hash)?.size).toBe(entry.size); + }); + + it("honors .assetsignore patterns (exact names, * globs, directory patterns)", async () => { + await writeFile( + join(assetsDir, ".assetsignore"), + ["secret.txt", "*.log", "private/", "# a comment", ""].join("\n"), + ); + await writeFile(join(assetsDir, "keep.txt"), "keep"); + await writeFile(join(assetsDir, "secret.txt"), "drop"); + await writeFile(join(assetsDir, "debug.log"), "drop"); + await mkdir(join(assetsDir, "private")); + await writeFile(join(assetsDir, "private", "notes.txt"), "drop"); + await mkdir(join(assetsDir, "nested")); + await writeFile(join(assetsDir, "nested", "secret.txt"), "drop"); + await writeFile(join(assetsDir, "nested", "keep.js"), "keep"); + + const { manifest } = await buildAssetManifest(assetsDir, "test-app-id"); + + expect(Object.keys(manifest).sort()).toEqual([ + "/keep.txt", + "/nested/keep.js", + ]); + }); + + it("always skips .assetsignore, wrangler.json, and .dev.vars", async () => { + await writeFile(join(assetsDir, "index.html"), "hi"); + await writeFile(join(assetsDir, "wrangler.json"), "{}"); + await writeFile(join(assetsDir, ".dev.vars"), "SECRET=1"); + + const { manifest } = await buildAssetManifest(assetsDir, "test-app-id"); + + expect(Object.keys(manifest)).toEqual(["/index.html"]); + }); + + it("rejects files larger than 25 MiB with a per-file error", async () => { + const bigFile = join(assetsDir, "big.bin"); + await writeFile(bigFile, ""); + await truncate(bigFile, 25 * 1024 * 1024 + 1); + + await expect(buildAssetManifest(assetsDir, "test-app-id")).rejects.toThrow( + /"big\.bin".*exceeds the 25 MiB per-file limit/, + ); + }); + + it("dedupes identical files by hash in filesByHash", async () => { + await writeFile(join(assetsDir, "a.txt"), "same"); + await writeFile(join(assetsDir, "b.txt"), "same"); + + const { manifest, filesByHash } = await buildAssetManifest( + assetsDir, + "test-app-id", + ); + + expect(Object.keys(manifest)).toHaveLength(2); + expect(manifest["/a.txt"].hash).toBe(manifest["/b.txt"].hash); + expect(filesByHash.size).toBe(1); + }); +}); From a588d65fb0c13e87110b4b5065f3e4cd42cdcbf6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 17:16:20 +0000 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20full-stack=20deploy=20=E2=80=94=20b?= =?UTF-8?q?uild=20a=20commit's=20worker=20from=20the=20CLI=20(cf=20arm)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacks the worker lane on the deployments API: detect the @cloudflare/vite-plugin redirect artifact, resolve the generated wrangler config (no_bundle only), collect modules, and create the deployment with the worker config — which the server answers with the cf arm: asset buckets POSTed directly to Cloudflare with the upload-session jwt (never through the app client), finalize with payload{completion_jwt} plus the module parts. A full-stack artifact wins over the static transports; nothing here publishes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DvhQfqxACcq25XAQRpoSh9 --- docs/AGENTS.md | 2 +- docs/deployments.md | 52 ++-- docs/resources.md | 9 +- docs/testing.md | 13 +- .../cli/src/cli/commands/project/deploy.ts | 8 +- packages/cli/src/cli/commands/site/deploy.ts | 17 +- .../src/cli/commands/site/run-app-deploy.ts | 8 + packages/cli/src/core/deployments/api.ts | 59 ++++ packages/cli/src/core/deployments/deploy.ts | 147 ++++++++++ packages/cli/src/core/deployments/index.ts | 3 + packages/cli/src/core/deployments/manifest.ts | 51 +++- packages/cli/src/core/deployments/modules.ts | 138 ++++++++++ packages/cli/src/core/deployments/schema.ts | 121 ++++++--- .../cli/src/core/deployments/static-site.ts | 8 +- packages/cli/src/core/deployments/upload.ts | 126 ++++++++- .../src/core/deployments/wrangler-config.ts | 185 +++++++++++++ packages/cli/src/core/site/deploy-app.ts | 32 ++- .../cli/tests/cli/fullstack_deploy.spec.ts | 254 ++++++++++++++++++ packages/cli/tests/cli/site_deploy.spec.ts | 46 ++++ .../tests/cli/static_site_deployments.spec.ts | 24 ++ .../cli/tests/cli/testkit/TestAPIServer.ts | 69 ++++- .../tests/core/deployments-manifest.spec.ts | 5 +- .../tests/core/deployments-modules.spec.ts | 130 +++++++++ .../core/deployments-wrangler-config.spec.ts | 141 ++++++++++ .../.wrangler/deploy/config.json | 4 + .../fullstack-project/base44/.app.jsonc | 4 + .../fullstack-project/base44/config.jsonc | 3 + .../build/client/.assetsignore | 2 + .../build/client/assets/app-123.js | 1 + .../build/client/ignored.txt | 1 + .../fullstack-project/build/client/index.html | 1 + .../build/server/assets/chunk-abc.js | 3 + .../fullstack-project/build/server/index.js | 2 + .../build/server/index.js.map | 1 + .../build/server/wrangler.json | 31 +++ 35 files changed, 1615 insertions(+), 86 deletions(-) create mode 100644 packages/cli/src/core/deployments/deploy.ts create mode 100644 packages/cli/src/core/deployments/modules.ts create mode 100644 packages/cli/src/core/deployments/wrangler-config.ts create mode 100644 packages/cli/tests/cli/fullstack_deploy.spec.ts create mode 100644 packages/cli/tests/core/deployments-modules.spec.ts create mode 100644 packages/cli/tests/core/deployments-wrangler-config.spec.ts create mode 100644 packages/cli/tests/fixtures/fullstack-project/.wrangler/deploy/config.json create mode 100644 packages/cli/tests/fixtures/fullstack-project/base44/.app.jsonc create mode 100644 packages/cli/tests/fixtures/fullstack-project/base44/config.jsonc create mode 100644 packages/cli/tests/fixtures/fullstack-project/build/client/.assetsignore create mode 100644 packages/cli/tests/fixtures/fullstack-project/build/client/assets/app-123.js create mode 100644 packages/cli/tests/fixtures/fullstack-project/build/client/ignored.txt create mode 100644 packages/cli/tests/fixtures/fullstack-project/build/client/index.html create mode 100644 packages/cli/tests/fixtures/fullstack-project/build/server/assets/chunk-abc.js create mode 100644 packages/cli/tests/fixtures/fullstack-project/build/server/index.js create mode 100644 packages/cli/tests/fixtures/fullstack-project/build/server/index.js.map create mode 100644 packages/cli/tests/fixtures/fullstack-project/build/server/wrangler.json diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 453cff780..3d52b9f28 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -79,7 +79,7 @@ Read these when working on the relevant area: - **[Adding or modifying CLI commands](commands.md)** - Factory pattern, `runCommand()`, `runTask()`, `CLIContext`, theming, `chalk` ban - **[Making API calls](api-patterns.md)** - HTTP clients, Zod snake_case-to-camelCase transforms, `ApiError.fromHttpError()` - **[Working with resources](resources.md)** - `Resource` interface, adding new resources, site module, unified deploy -- **[Deployments API](deployments.md)** - Static-site deploys addressed by commit, asset manifest hashing, presigned uploads, index.html finalize sentinel +- **[Full-stack deployments](deployments.md)** - Workers-based deploys addressed by commit, wrangler config, asset manifest hashing, direct asset uploads - **[Plugins](plugins.md)** - Plugin config, namespaces, entity extension rules, function namespacing, pull/deploy behavior - **[Error handling](error-handling.md)** - Error hierarchy, throwing patterns, error codes, `CLIExitError`, `process.exit` ban - **[Writing tests](testing.md)** - Testkit, Given/When/Then pattern, API mocks, fixtures, test overrides diff --git a/docs/deployments.md b/docs/deployments.md index 9e95e2058..c213d8248 100644 --- a/docs/deployments.md +++ b/docs/deployments.md @@ -1,8 +1,8 @@ -# Deployments API (Static Sites) +# Full-Stack Deployments -**Keywords:** deployments, static site, asset manifest, hash, git hash, commit, presigned, S3, finalize, index.html sentinel, BASE44_STATIC_DEPLOYMENTS, upload +**Keywords:** deployments, full-stack, Cloudflare Workers, wrangler, no_bundle, asset manifest, hash, git hash, commit, buckets, presigned, upload session, finalize, .assetsignore, .wrangler/deploy/config.json, static site, presigned, S3, target -Deployments ship an app's built output addressed by the commit that produced it. The core module is `src/core/deployments/` (`git-hash.ts`, `manifest.ts`, `static-site.ts`, `upload.ts`, `api.ts`, `schema.ts`). Today it carries the env-gated static-site lane; the create response is an ADT designed so a worker (`cf`) arm can slot in next to the static (`s3`) arm without protocol changes — that is the progressive-upgrade path for full-stack apps. +Full-stack deployments upload framework builds (React Router 7, TanStack Start, Astro 6, vinext — anything built with `@cloudflare/vite-plugin`) as Workers on Base44. The core module is `src/core/deployments/` (`wrangler-config.ts`, `manifest.ts`, `modules.ts`, `upload.ts`, `api.ts`, `deploy.ts`). **Deploying builds — it never publishes.** A deployment is addressed by the commit that produced the build: the server derives the deployment id from `git_hash`, so one commit means one deployment and re-deploying a commit is idempotent. What production serves is decided by the platform publish flow, not by this CLI — there is no `--prod`, no promote/rollback, and no deployment list/logs surface. @@ -10,36 +10,56 @@ Deployments ship an app's built output addressed by the commit that produced it. `resolveGitHash(projectRoot, explicit?)` — an explicit `--git-hash` wins; otherwise `git rev-parse HEAD` in the project root. No hash (not a git checkout, no flag) or a non-hex value fails fast with guidance. Pattern: `^[a-fA-F0-9]{7,64}$` (same validation as the server). +## Artifact Detection + +Both `base44 deploy` and `base44 site deploy` route through `deployAppSite()` in `core/site/` (see [resources.md](resources.md#site-module-not-a-resource)), which takes this path whenever an artifact is detected and falls back to the static-site transports otherwise. + +`detectFullStackArtifact(projectRoot)` looks for exactly one thing: `.wrangler/deploy/config.json`, the redirect file emitted by `@cloudflare/vite-plugin` builds. Its `configPath` points at the generated `wrangler.json`, **relative to the redirect file's directory**. + +A hand-authored root `wrangler.jsonc` / `wrangler.json` / `wrangler.toml` is **not** an artifact. Those are written for wrangler's own bundler, which this path never runs — so they'd fail the `no_bundle` gate below anyway, and detecting one would only hijack the deploy away from the static upload the project actually wants. + +The resolved config must have `no_bundle: true`; otherwise the deploy fails with "this framework's output requires bundling; not yet supported". Only the fields a deploy acts on are declared in the schema — bindings (`kv_namespaces`, `d1_databases`, `durable_objects`, `queues`, ...) and the worker `name` are ignored outright: not forwarded, not validated, not warned about. `vars` are **not sent** — a worker's environment is the app's secrets and built-ins — and are surfaced as a warning when present, as are `_headers`/`_redirects` contents and `run_worker_first` route arrays (no server-side support yet). + ## API Contract (app-scoped, via `getAppClient()`) -1. `POST deployments` — JSON body: `git_hash` (required) and `asset_manifest` (`{"/path": {hash, size}}`). The response is `{deployment_id, asset_uploads}` where `deployment_id` is a handle for the rest of the flow and `asset_uploads` says where the assets still owed should go, discriminated on `type`: - - `{type: "s3", uploads: [{path, content_type, content_length, url}]}` — one presigned S3 PUT per asset still to upload, **always excluding `/index.html`** (finalize carries it). +1. `POST deployments` — JSON body: `git_hash` (required), `config` (`main`, `compatibility_date`, `compatibility_flags`, `assets` — Cloudflare's own vocabulary: `html_handling`, `not_found_handling`, `run_worker_first` bool; **omitted entirely for a static-site deploy** — the presence of a worker config is what selects the storage target server-side), `asset_manifest` (`{"/path": {hash, size}}`). The response is `{deployment_id, asset_uploads}` where `deployment_id` is a handle for the rest of the flow (no URL, no script name) and `asset_uploads` says where the assets still owed should go, discriminated on `type`: + - `{type: "cf", url, jwt, buckets}` — a worker deploy: `buckets` are asset hashes grouped by Cloudflare, `url` is Cloudflare's assets upload endpoint, `jwt` is the upload-session token. + - `{type: "s3", uploads: [{path, content_type, content_length, url}]}` — a static deploy: one presigned S3 PUT per asset still to upload, **always excluding `/index.html`** (finalize writes it). - `null` — nothing owed: no assets, or the build already exists (re-deploying a commit is idempotent). -2. **Asset upload — bytes never pass through the backend.** Each upload's raw file bytes are `PUT` directly to its presigned `url` with the signed `content_type` sent verbatim (the URL also signs `content_length`, so the body must be exactly the declared bytes). The URL itself is the credential, so no auth headers and never the app client. Per file: 3 attempts with exponential backoff, concurrency 3. -3. `POST deployments/{id}/finalize` — multipart with exactly one file field named `index.html` carrying the index.html bytes (contentType `text/html`) — no other fields. index.html is the sentinel that completes the deployment, which is also why it never appears in the uploads. Returns `{deployment_id}`. +2. **Asset upload — bytes never pass through the backend.** cf: for each bucket, `POST` multipart/form-data **directly to the given `url`** with `?base64=true` and `Authorization: Bearer `; each field: name = file hash, value = base64 file bytes, contentType = the file's real MIME type. Buckets upload with concurrency 3; each bucket retries up to 3 times with exponential backoff, a 429 waits out the window without burning an attempt, and a 401/403 maps to "upload session expired — rerun deploy". The final bucket's response carries `{"result": {"jwt": ""}}`. s3: each upload's raw file bytes are `PUT` directly to its presigned `url` with the signed `content_type` sent verbatim (the URL also signs `content_length`, so the body must be exactly the declared bytes) — the URL itself is the credential, so no auth headers and never the app client; per file: 3 attempts with exponential backoff, concurrency 3. +3. `POST deployments/{id}/finalize` — multipart, shape follows which arm the request selected: + - **worker**: field `payload` = JSON `{"completion_jwt": string|null}` plus one file field per module (name = module path, contentType `application/javascript+module` for esm / `application/source-map` for `.map`). `completion_jwt` is null when `asset_uploads` came back null — the server holds the session token that completes the asset set. Bundle cap: 50 MB. + - **static**: exactly one file field named `index.html` carrying the index.html bytes (contentType `text/html`) — no `payload`, no modules. index.html is the sentinel that completes the deployment, which is also why it never appears in the uploads. + + Returns `{deployment_id}` for both. ## Asset Manifest & Hashing `hash = first 32 hex chars of sha256(utf8(app_id) || raw file bytes)` — see `hashAsset()` in `src/core/deployments/manifest.ts`. The app-id salt is a cache-poisoning defense: a tenant can only produce hash collisions with its own files. -The output directory is walked recursively. `.assetsignore` at the root is honored (minimal gitignore-style matching: exact names, `*`/`**` globs, directory patterns; no negation). `.assetsignore` itself, `wrangler.json`, and `.dev.vars` are always skipped. Files over 25 MiB fail with a per-file error; total file count is capped at 100,000. Manifest keys are `/`-prefixed forward-slash paths. +The assets directory (from `assets.directory`, relative to the config dir) is walked recursively. `.assetsignore` at the assets root is honored (minimal gitignore-style matching: exact names, `*`/`**` globs, directory patterns; no negation). `.assetsignore` itself, `wrangler.json`, and `.dev.vars` are always skipped. Files over 25 MiB fail with a per-file error; total file count is capped at 100,000. Manifest keys are `/`-prefixed forward-slash paths. + +## Module Collection + +Entry = `main` from the wrangler config. With `no_bundle: true`, every file under the config dir matching the `rules` globs is included, excluding `wrangler.json` and `.dev.vars`, preserving relative paths as module names. `.map` files next to modules (or all of them when `upload_source_maps` is set) are included as `sourcemap`. Total module payload is capped at 40 MB client-side (the server enforces 50 MB). + +## Command UX -Content types are deliberately **not** derived client-side: the server decides each asset's Content-Type, signs it into the presigned URL, and the CLI echoes it verbatim — deriving our own value would 403 on any mapping difference. +**`base44 deploy [--git-hash ] [--build|--no-build]`** — the optional build step is `maybeBuildBeforeDeploy` (`--build` forces it, `--no-build` skips it, otherwise an interactive ask). Then, if a full-stack artifact is detected it replaces the static site upload; otherwise the site ships over the static transport. Progress: "Found N static assets (M new)" → "Uploaded X of Y assets" → "Deploying worker (K modules)…" → summary row `Deployment: (commit )`. Under `--json`, stdout is a single `{deploymentId, gitHash}` document. -## The Static Lane (experimental, env-gated) +The primary automated consumer is the platform's build/deploy sandbox, which runs this command with a scoped `apps:deploy` workspace key and the checkout's commit — so the sandbox and a human at a terminal go through the exact same door. -With `BASE44_STATIC_DEPLOYMENTS=1` (or `true`; internal gate, not user-facing yet), a project with `site.outputDirectory` deploys through the deployments API instead of the legacy tar.gz upload: the output directory becomes the asset manifest (index.html included — it is only ever excluded from uploads), the CLI PUTs each requested file directly to its presigned URL and finalizes with the index.html bytes; today's serving keeps working because the server stores the result the way the legacy site upload does. Same command, same `--git-hash` addressing, same `--json` output (`{deploymentId, gitHash}`). +## Static Sites through the Deployments API (experimental, env-gated) -Both `base44 deploy` and `base44 site deploy` route through `deployAppSite()` in `core/site/` (see [resources.md](resources.md#site-module-not-a-resource)), which picks the transport (`static-deployment` vs legacy `static`). The primary automated consumer is the platform's build/deploy sandbox, which runs `base44 deploy -y --json --git-hash ` with a scoped `apps:deploy` workspace key — so the sandbox and a human at a terminal go through the exact same door. +With `BASE44_STATIC_DEPLOYMENTS=1` (or `true`; internal gate, not user-facing yet), a project with `site.outputDirectory` and **no** full-stack artifact deploys through the deployments API instead of the legacy tar.gz upload: the output directory becomes the asset manifest (index.html included — it is only ever excluded from uploads), and the create request carries **no `config`**, which the server answers with the `s3` arm of the discriminated create response. The CLI PUTs each requested file directly to its presigned URL and finalizes with the index.html bytes; today's serving keeps working because the server stores the result the way the legacy site upload does. Same command, same `--git-hash` addressing, same `--json` output. This is the progressive-upgrade path: when the app later adopts a server framework, its emitted wrangler artifact wins detection, the create request carries the worker config, and the server flips to the `cf` arm — one CLI protocol, zero CLI change (`src/core/deployments/static-site.ts`). ## Testing -`TestAPIServer` mocks: `mockDeploymentCreate` (captures the JSON body in `deploymentCreateRequests`; echoes whatever response shape you pass — `asset_uploads` is `{type: "s3", ...}` or `null`), `mockPresignedUpload(path)` (serves a presigned-style `PUT /presigned{path}` target, captures body/Content-Type/Authorization in `presignedUploadRequests`), `mockDeploymentFinalize` (captures multipart fields in `finalizeRequests`). Fixture: `tests/fixtures/with-site/` (static output dir) — not a git repo, so specs pass `--git-hash`. Unit tests live in `tests/core/deployments-*.spec.ts`. +`TestAPIServer` mocks: `mockDeploymentCreate` (captures the JSON body in `deploymentCreateRequests`; echoes whatever response shape you pass — `asset_uploads` selects the arm: `{type: "cf", ...}`, `{type: "s3", ...}` or `null`), `mockAssetUpload` (serves a Cloudflare-style `POST /cf-assets/upload` target, captures the Authorization header, `?base64=true` query and multipart fields in `assetUploadRequests`, responds 201 with the completion jwt), `mockPresignedUpload(path)` (serves a presigned-style `PUT /presigned{path}` target, captures body/Content-Type/Authorization in `presignedUploadRequests`), `mockDeploymentFinalize` (captures fields in `finalizeRequests`). Fixtures: `tests/fixtures/fullstack-project/` (redirect file + `build/server` worker + `build/client` assets with `.assetsignore`) and `tests/fixtures/with-site/` (static output dir) — not git repos, so specs pass `--git-hash`. Unit tests live in `tests/core/deployments-*.spec.ts`. ## Rules (Deployments-Specific) - **Never re-derive the asset hash** — always go through `hashAsset()` so the app-id salt stays consistent -- **Never derive an upload's Content-Type client-side** — the server signs it into the presigned URL; echo the signed value verbatim -- **Presigned PUTs carry no auth headers and never use the app client** — the URL itself is the scoped credential +- **Asset bytes never pass through the backend** — cf buckets POST directly to Cloudflare authorized by the upload-session jwt (and never through the app client, which would leak app auth); s3 PUTs go directly to the presigned URLs, where the URL itself is the credential and no auth header may be sent - **`git_hash` is required** — a build with no commit behind it has no address and could never be published -- **Legacy behavior stays identical** when the gate is off — the tar.gz site path must not change +- **Legacy behavior stays identical** when no full-stack artifact exists — the tar.gz site path must not change diff --git a/docs/resources.md b/docs/resources.md index 4c7b01718..8f61d18b4 100644 --- a/docs/resources.md +++ b/docs/resources.md @@ -80,18 +80,19 @@ The site module at `packages/cli/src/core/site/` handles deploying an app's buil It owns **which transport ships the build**. `deployAppSite()` in `deploy-app.ts` is the single entry point both `base44 deploy` and `base44 site deploy` call: -- `site.outputDirectory` ships as a static site: through the deployments API when the env-gated lane is enabled (see [deployments.md](deployments.md)), else the legacy path — tar.gz the built files and upload via `POST /api/apps/{app_id}/deploy-dist`. -- No `site.outputDirectory` → `{ kind: "none" }`. +- A full-stack (Workers) artifact wins when one is present — see [deployments.md](deployments.md). It carries the server too, so shipping the static output directory instead would silently drop the worker. +- Otherwise `site.outputDirectory` ships as a static site: through the deployments API when the env-gated lane is enabled, else the legacy path — tar.gz the built files and upload via `POST /api/apps/{app_id}/deploy-dist`. +- Neither applies → `{ kind: "none" }`. ```typescript import { deployAppSite } from "@/core/site/index.js"; const result = await deployAppSite(project, { gitHash }); -// { kind: "static-deployment", deploymentId, gitHash } +// { kind: "full-stack" | "static-deployment", deploymentId, gitHash } // | { kind: "static", appUrl } | { kind: "none" } ``` -`detectAppDeployKind()` answers what would ship right now — used for the deploy summary and spinner labels. It answers for the current state of the tree, so a build step invalidates it. +`detectAppDeployKind()` answers what would ship right now — used for the deploy summary and spinner labels. It answers for the current state of the tree; the full-stack artifact is itself a build output, so a build step invalidates it. ### Deploy Flow diff --git a/docs/testing.md b/docs/testing.md index f34052dc6..fefed8da3 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -72,6 +72,7 @@ tests/ ├── duplicate-function-names/ # Error: duplicate function names ├── with-zero-config-functions/ # Full project: zero-config + path-named functions (CLI integration) ├── with-site/ # Project with site config + ├── fullstack-project/ # Full-stack Workers artifact (.wrangler redirect + build output) ├── full-project/ # All resources combined ├── no-app-config/ # Unlinked project (no .app.jsonc) └── invalid-*/ # Error case fixtures @@ -298,17 +299,19 @@ t.api.mockFunctionLogs("my-function", [ t.api.mockFunctionLogsError("my-function", { status: 500, body: { error: "Server error" } }); ``` -### Deployment Mocks +### Deployment (Full-Stack) Mocks -See [deployments.md](deployments.md) for the API contract. Requests are captured for assertions: `t.api.deploymentCreateRequests` (JSON bodies), `t.api.presignedUploadRequests` (raw body, Content-Type, Authorization), and `t.api.finalizeRequests` (parsed multipart fields). +See [deployments.md](deployments.md) for the API contract. Requests are captured for assertions: `t.api.deploymentCreateRequests` (JSON bodies), `t.api.assetUploadRequests` (Authorization header, `base64` query, multipart fields), and `t.api.finalizeRequests` (parsed multipart fields). ```typescript t.api.mockDeploymentCreate({ deployment_id: "app-1-git-a1b2c3d4e5f6", - // {type: "s3", uploads: [...]} or null (nothing owed) - asset_uploads: { type: "s3", uploads: [{ path, content_type, content_length, url }] }, + // cf arm shown; also {type: "s3", uploads: [...]} or null (nothing owed) + asset_uploads: { type: "cf", url, jwt: "session-jwt", buckets: [[""]] }, }); -t.api.mockPresignedUpload("/main.js"); // serves a presigned-style PUT target +t.api.mockAssetUpload("completion-jwt"); // serves the cf asset-upload target, responds 201 {result:{jwt}} +t.api.mockAssetUploadError({ status: 500, body: { error: "Server error" } }); +t.api.mockPresignedUpload("/main.js"); // serves a presigned-style PUT target (s3 arm) t.api.mockDeploymentFinalize({ deployment_id: "app-1-git-a1b2c3d4e5f6" }); ``` diff --git a/packages/cli/src/cli/commands/project/deploy.ts b/packages/cli/src/cli/commands/project/deploy.ts index 5c3aabc35..46d9bbb0f 100644 --- a/packages/cli/src/cli/commands/project/deploy.ts +++ b/packages/cli/src/cli/commands/project/deploy.ts @@ -86,7 +86,9 @@ export async function deployAction( if (project.visibility) { summaryLines.push(` - Visibility: ${project.visibility}`); } - if (project.site?.outputDirectory) { + if (plannedSite === "full-stack") { + summaryLines.push(" - Full-stack app"); + } else if (project.site?.outputDirectory) { summaryLines.push(` - Site from ${project.site.outputDirectory}`); } @@ -154,7 +156,9 @@ export async function deployAction( ); } const deployment = - siteResult.kind === "static-deployment" ? siteResult : undefined; + siteResult.kind === "full-stack" || siteResult.kind === "static-deployment" + ? siteResult + : undefined; if (deployment) { printDeploymentSummary(deployment, log); } diff --git a/packages/cli/src/cli/commands/site/deploy.ts b/packages/cli/src/cli/commands/site/deploy.ts index 38e1e300b..dcea9eb5f 100644 --- a/packages/cli/src/cli/commands/site/deploy.ts +++ b/packages/cli/src/cli/commands/site/deploy.ts @@ -25,6 +25,8 @@ async function deployAction( const { project } = await readProjectConfig(); + // A full-stack build artifact ships as a Workers deployment; without one + // the site is the configured output directory. const kind = await detectAppDeployKind(project); if (kind === "none") { @@ -34,13 +36,20 @@ async function deployAction( message: 'Add \'site.outputDirectory\' to your config.jsonc (e.g., "site": { "outputDirectory": "dist" })', }, + { + message: + "Full-stack apps ship from their build artifact — run your framework's build first", + }, ], }); } if (!options.yes) { const shouldDeploy = await confirm({ - message: `Deploy site from ${project.site?.outputDirectory}?`, + message: + kind === "full-stack" + ? "Deploy full-stack app?" + : `Deploy site from ${project.site?.outputDirectory}?`, }); if (isCancel(shouldDeploy) || !shouldDeploy) { @@ -54,7 +63,7 @@ async function deployAction( gitHash: options.gitHash, }); - if (result.kind === "static-deployment") { + if (result.kind === "full-stack" || result.kind === "static-deployment") { // A build has no URL of its own: what production serves is decided when // the app is published from the builder, not by this deploy. return { @@ -78,7 +87,9 @@ async function deployAction( export function getSiteDeployCommand(): Command { return new Base44Command("deploy") - .description("Deploy the built site to Base44 hosting") + .description( + "Deploy the built site to Base44 hosting (full-stack apps deploy their Workers build)", + ) .option("-y, --yes", "Skip confirmation prompt") .option( "--git-hash ", diff --git a/packages/cli/src/cli/commands/site/run-app-deploy.ts b/packages/cli/src/cli/commands/site/run-app-deploy.ts index a402b9758..314953c90 100644 --- a/packages/cli/src/cli/commands/site/run-app-deploy.ts +++ b/packages/cli/src/cli/commands/site/run-app-deploy.ts @@ -4,6 +4,11 @@ import type { AppDeployResult, AppSiteTarget } from "@/core/site/index.js"; import { deployAppSite, detectAppDeployKind } from "@/core/site/index.js"; const TASK_LABELS = { + "full-stack": { + start: "Deploying full-stack app...", + success: theme.colors.base44Orange("Full-stack app deployed"), + error: "Full-stack deploy failed", + }, "static-deployment": { start: "Deploying site...", success: "Site deployed", @@ -50,6 +55,9 @@ export async function runAppSiteDeploy( onAssetUpload: ({ uploadedFiles, totalFiles }) => { updateMessage(`Uploaded ${uploadedFiles} of ${totalFiles} assets`); }, + onWorker: ({ moduleCount }) => { + updateMessage(`Deploying worker (${moduleCount} modules)…`); + }, }, }), { diff --git a/packages/cli/src/core/deployments/api.ts b/packages/cli/src/core/deployments/api.ts index 311a92092..1b8056242 100644 --- a/packages/cli/src/core/deployments/api.ts +++ b/packages/cli/src/core/deployments/api.ts @@ -1,16 +1,29 @@ +import { readFile } from "node:fs/promises"; import type { KyResponse } from "ky"; +import ky from "ky"; import { getAppClient } from "@/core/clients/index.js"; import { ApiError, SchemaValidationError } from "@/core/errors.js"; import type { CreateDeploymentRequest, CreateDeploymentResponse, FinalizeDeploymentResponse, + ModuleType, + WorkerModule, } from "./schema.js"; import { + AssetUploadResponseSchema, CreateDeploymentResponseSchema, FinalizeDeploymentResponseSchema, } from "./schema.js"; +const MODULE_CONTENT_TYPES: Record = { + esm: "application/javascript+module", + sourcemap: "application/source-map", + wasm: "application/wasm", + text: "text/plain", + data: "application/octet-stream", +}; + export async function createDeployment( request: CreateDeploymentRequest, ): Promise { @@ -38,6 +51,52 @@ export async function createDeployment( return result.data; } +/** + * POST one bucket of asset bytes directly to Cloudflare's assets endpoint, + * authorized by the upload-session jwt from create. The final bucket's + * response carries the completion token. Errors are NOT wrapped here: the + * caller owns retry and error mapping per bucket. + */ +export async function uploadAssetBucket( + target: { url: string; jwt: string }, + formData: FormData, +): Promise { + // Straight to Cloudflare: the upload-session jwt is the credential, so this + // never goes through the app client (and must not carry app auth). + const response: KyResponse = await ky.post(target.url, { + searchParams: { base64: "true" }, + headers: { Authorization: `Bearer ${target.jwt}` }, + body: formData, + timeout: 120_000, + retry: 0, + }); + + const parsed = AssetUploadResponseSchema.safeParse(await response.json()); + const jwt = parsed.success ? parsed.data.result?.jwt : null; + return jwt || null; +} + +export async function finalizeDeployment( + deploymentId: string, + completionJwt: string | null, + modules: WorkerModule[], +): Promise { + const formData = new FormData(); + formData.append("payload", JSON.stringify({ completion_jwt: completionJwt })); + + for (const module of modules) { + const content = await readFile(module.absolutePath); + formData.append( + module.name, + new File([new Uint8Array(content)], module.name, { + type: MODULE_CONTENT_TYPES[module.type], + }), + ); + } + + return await postFinalize(deploymentId, formData); +} + /** * Finalize a static-site (s3-target) deployment. The form carries exactly one * file part — `index.html` — and nothing else (no `payload`, no modules): diff --git a/packages/cli/src/core/deployments/deploy.ts b/packages/cli/src/core/deployments/deploy.ts new file mode 100644 index 000000000..411398ca3 --- /dev/null +++ b/packages/cli/src/core/deployments/deploy.ts @@ -0,0 +1,147 @@ +import { ApiError } from "@/core/errors.js"; +import { getAppContext } from "@/core/project/app-config.js"; +import { pathExists } from "@/core/utils/fs.js"; +import { createDeployment, finalizeDeployment } from "./api.js"; +import { buildAssetManifest } from "./manifest.js"; +import { collectModules } from "./modules.js"; +import type { AssetManifestResult, DeploymentProgress } from "./schema.js"; +import { uploadAssetBuckets } from "./upload.js"; +import { resolveWranglerConfig } from "./wrangler-config.js"; + +interface FullStackDeployResult { + deploymentId: string; + gitHash: string; +} + +/** + * Deploy a full-stack (Cloudflare Workers) build artifact for a commit: + * resolve the wrangler config, collect worker modules and static assets, + * create the deployment at the commit's address, POST the requested asset + * buckets directly to Cloudflare, then finalize with the worker modules. + * + * Builds only — nothing here publishes. What production serves is decided by + * the platform publish flow, and re-deploying the same commit is idempotent. + */ +export async function deployFullStack(options: { + projectRoot: string; + gitHash: string; + progress?: DeploymentProgress; +}): Promise { + const { projectRoot, gitHash, progress } = options; + + const config = await resolveWranglerConfig(projectRoot); + + // Some frameworks (e.g. Astro 6) emit a wrangler.json without any + // compatibility flags; server code using Node built-ins would then fail at + // runtime. Warn instead of injecting the flag — the config is generated, so + // the fix belongs in the framework/adapter settings. + if (!config.compatibilityFlags.includes("nodejs_compat")) { + progress?.onWarning?.( + "The wrangler config has no 'nodejs_compat' compatibility flag; Node.js built-ins will be unavailable at runtime. Enable it in your framework's Cloudflare adapter settings if your server code needs Node APIs.", + ); + } + + // A worker's environment is the app's secrets and built-ins — a deploy can't + // introduce env of its own, so wrangler `vars` never reach the worker. + if (config.vars && Object.keys(config.vars).length > 0) { + progress?.onWarning?.( + "wrangler 'vars' are not supported and were ignored — a worker's environment comes from the app's secrets (base44 secrets set).", + ); + } + + const modules = await collectModules(config); + + let assets: AssetManifestResult = { manifest: {}, filesByHash: new Map() }; + if (config.assetsDirectory && (await pathExists(config.assetsDirectory))) { + assets = await buildAssetManifest( + config.assetsDirectory, + getAppContext().id, + ); + } + + const created = await createDeployment({ + git_hash: gitHash, + config: { + main: config.main, + compatibility_date: config.compatibilityDate, + compatibility_flags: config.compatibilityFlags, + assets: buildAssetsConfig(config.assetsConfig, progress), + }, + asset_manifest: assets.manifest, + }); + if (created.assetUploads && created.assetUploads.type !== "cf") { + throw new ApiError( + `The server answered a full-stack deploy with the "${created.assetUploads.type}" upload target.`, + ); + } + + const totalAssets = Object.keys(assets.manifest).length; + const newAssets = created.assetUploads + ? new Set(created.assetUploads.buckets.flat()).size + : 0; + progress?.onAssets?.({ totalAssets, newAssets }); + + // No uploads owed means every asset is already stored (or there are none): + // the server holds the token that completes the asset set, so the + // completion JWT stays null. + const completionJwt = created.assetUploads + ? await uploadAssetBuckets( + created.assetUploads, + assets.filesByHash, + progress?.onAssetUpload, + ) + : null; + + progress?.onWorker?.({ moduleCount: modules.length }); + const finalized = await finalizeDeployment( + created.deploymentId, + completionJwt, + modules, + ); + + return { deploymentId: finalized.deploymentId, gitHash }; +} + +/** + * The subset of the wrangler assets config the deployments API accepts. + * `_headers`/`_redirects` contents and `run_worker_first` route arrays have no + * server-side support yet — dropping them silently would change runtime + * behavior, so each drop is surfaced as a warning. + */ +function buildAssetsConfig( + assetsConfig: { + htmlHandling?: string; + notFoundHandling?: string; + runWorkerFirst?: boolean | string[]; + headers?: string; + redirects?: string; + } | null, + progress?: DeploymentProgress, +): { + html_handling?: string; + not_found_handling?: string; + run_worker_first?: boolean; +} | null { + if (!assetsConfig) return null; + + if (assetsConfig.headers || assetsConfig.redirects) { + progress?.onWarning?.( + "_headers/_redirects files are not supported yet and were ignored for this deploy.", + ); + } + + let runWorkerFirst: boolean | undefined; + if (Array.isArray(assetsConfig.runWorkerFirst)) { + progress?.onWarning?.( + "'run_worker_first' route patterns are not supported yet and were ignored for this deploy.", + ); + } else { + runWorkerFirst = assetsConfig.runWorkerFirst; + } + + return { + html_handling: assetsConfig.htmlHandling, + not_found_handling: assetsConfig.notFoundHandling, + run_worker_first: runWorkerFirst, + }; +} diff --git a/packages/cli/src/core/deployments/index.ts b/packages/cli/src/core/deployments/index.ts index 0a3e99d49..39bcdb202 100644 --- a/packages/cli/src/core/deployments/index.ts +++ b/packages/cli/src/core/deployments/index.ts @@ -1,6 +1,9 @@ export * from "./api.js"; +export * from "./deploy.js"; export * from "./git-hash.js"; export * from "./manifest.js"; +export * from "./modules.js"; export * from "./schema.js"; export * from "./static-site.js"; export * from "./upload.js"; +export * from "./wrangler-config.js"; diff --git a/packages/cli/src/core/deployments/manifest.ts b/packages/cli/src/core/deployments/manifest.ts index 86653dbed..650da5f79 100644 --- a/packages/cli/src/core/deployments/manifest.ts +++ b/packages/cli/src/core/deployments/manifest.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; import { readdir, readFile, stat } from "node:fs/promises"; -import { join } from "node:path"; +import { extname, join } from "node:path"; import { InvalidInputError } from "@/core/errors.js"; import { pathExists, readTextFile } from "@/core/utils/fs.js"; import type { @@ -21,6 +21,48 @@ const ALWAYS_SKIPPED_FILES = new Set([ ".dev.vars", ]); +const MIME_TYPES: Record = { + ".html": "text/html", + ".htm": "text/html", + ".css": "text/css", + ".js": "text/javascript", + ".mjs": "text/javascript", + ".json": "application/json", + ".map": "application/json", + ".txt": "text/plain", + ".xml": "application/xml", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".avif": "image/avif", + ".ico": "image/x-icon", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".otf": "font/otf", + ".eot": "application/vnd.ms-fontobject", + ".mp3": "audio/mpeg", + ".mp4": "video/mp4", + ".webm": "video/webm", + ".pdf": "application/pdf", + ".wasm": "application/wasm", + ".webmanifest": "application/manifest+json", +}; + +/** + * Content type for a cf-arm multipart upload part. The s3 arm never uses + * this — there the server signs each Content-Type into the presigned URL + * and the CLI echoes it verbatim. + */ +function getAssetContentType(filePath: string): string { + return ( + MIME_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream" + ); +} + /** * Content-addressed asset hash: first 32 hex chars of * sha256(utf8(app_id) || raw file bytes). Salting with the app id means a @@ -140,7 +182,12 @@ export async function buildAssetManifest( manifest[`/${relativePath}`] = { hash, size }; if (!filesByHash.has(hash)) { - filesByHash.set(hash, { absolutePath, hash, size }); + filesByHash.set(hash, { + absolutePath, + hash, + size, + contentType: getAssetContentType(absolutePath), + }); } } diff --git a/packages/cli/src/core/deployments/modules.ts b/packages/cli/src/core/deployments/modules.ts new file mode 100644 index 000000000..d7e4c2ae7 --- /dev/null +++ b/packages/cli/src/core/deployments/modules.ts @@ -0,0 +1,138 @@ +import { stat } from "node:fs/promises"; +import { relative, resolve, sep } from "node:path"; +import { globby } from "globby"; +import { InvalidInputError } from "@/core/errors.js"; +import { pathExists } from "@/core/utils/fs.js"; +import type { ModuleType, WorkerModule } from "./schema.js"; +import type { ResolvedWranglerConfig } from "./wrangler-config.js"; + +const MAX_TOTAL_MODULE_BYTES = 40 * 1024 * 1024; // 40 MB + +/** Files never collected as worker modules. */ +const MODULE_IGNORE = ["wrangler.json", ".dev.vars"]; + +/** Wrangler rule type → deployments API module type. */ +const RULE_TYPE_TO_MODULE_TYPE: Record = { + ESModule: "esm", + CompiledWasm: "wasm", + Text: "text", + Data: "data", +}; + +function toPosix(path: string): string { + return path.split(sep).join("/"); +} + +/** + * Collect the worker modules for an unbundled (no_bundle) build: the entry + * module plus every file under the config dir matching the config's rules + * globs, preserving relative paths as module names. `.map` files next to + * collected modules (or all of them when `upload_source_maps` is set) are + * included as sourcemaps. Total payload is capped at 40 MB. + */ +export async function collectModules( + config: ResolvedWranglerConfig, +): Promise { + const entryPath = resolve(config.configDir, config.main); + if (!(await pathExists(entryPath))) { + throw new InvalidInputError( + `Worker entry module does not exist: ${entryPath} (from "main" in ${config.configPath})`, + { + hints: [{ message: "Rebuild the project to regenerate the artifact" }], + }, + ); + } + + const modulesByName = new Map(); + const entryName = toPosix(relative(config.configDir, entryPath)); + modulesByName.set(entryName, { + name: entryName, + absolutePath: entryPath, + size: 0, + type: "esm", + }); + + const ignore = [...MODULE_IGNORE]; + if (config.assetsDirectory?.startsWith(config.configDir + sep)) { + ignore.push( + `${toPosix(relative(config.configDir, config.assetsDirectory))}/**`, + ); + } + + for (const rule of config.rules) { + const type = RULE_TYPE_TO_MODULE_TYPE[rule.type]; + if (!type) { + throw new InvalidInputError( + `Unsupported module rule type "${rule.type}" in ${config.configPath}. Supported: ${Object.keys(RULE_TYPE_TO_MODULE_TYPE).join(", ")}.`, + ); + } + + const matches = await globby(rule.globs, { + cwd: config.configDir, + onlyFiles: true, + dot: true, + ignore, + }); + + for (const match of matches.sort()) { + if (!modulesByName.has(match)) { + modulesByName.set(match, { + name: match, + absolutePath: resolve(config.configDir, match), + size: 0, + type, + }); + } + } + } + + // Source maps: all of them when upload_source_maps is set, otherwise only + // the ones sitting next to a collected module. + if (config.uploadSourceMaps) { + const maps = await globby("**/*.map", { + cwd: config.configDir, + onlyFiles: true, + dot: true, + ignore, + }); + for (const map of maps.sort()) { + addSourcemap(modulesByName, config.configDir, map); + } + } else { + for (const name of [...modulesByName.keys()]) { + const mapName = `${name}.map`; + if (await pathExists(resolve(config.configDir, mapName))) { + addSourcemap(modulesByName, config.configDir, mapName); + } + } + } + + const modules = [...modulesByName.values()]; + let totalBytes = 0; + for (const module of modules) { + module.size = (await stat(module.absolutePath)).size; + totalBytes += module.size; + } + + if (totalBytes > MAX_TOTAL_MODULE_BYTES) { + throw new InvalidInputError( + `Worker modules total ${totalBytes} bytes, which exceeds the 40 MB limit for Base44 full-stack deploys.`, + ); + } + + return modules; +} + +function addSourcemap( + modulesByName: Map, + configDir: string, + name: string, +): void { + if (modulesByName.has(name)) return; + modulesByName.set(name, { + name, + absolutePath: resolve(configDir, name), + size: 0, + type: "sourcemap", + }); +} diff --git a/packages/cli/src/core/deployments/schema.ts b/packages/cli/src/core/deployments/schema.ts index e9469d0e6..0f785c651 100644 --- a/packages/cli/src/core/deployments/schema.ts +++ b/packages/cli/src/core/deployments/schema.ts @@ -2,6 +2,19 @@ import { z } from "zod"; // ─── SHARED ────────────────────────────────────────────────── +/** Worker module types accepted by the deployments API. */ +export type ModuleType = "esm" | "sourcemap" | "wasm" | "text" | "data"; + +/** A collected worker module (bytes are read lazily at finalize time). */ +export interface WorkerModule { + /** Module name: path relative to the wrangler config dir (forward slashes). */ + name: string; + /** Absolute path on disk. */ + absolutePath: string; + size: number; + type: ModuleType; +} + /** Manifest entry keyed by URL-ish path ("/index.html"). */ export interface AssetManifestEntry { hash: string; @@ -14,12 +27,13 @@ export interface AssetFile { absolutePath: string; hash: string; size: number; + contentType: string; } export interface AssetManifestResult { /** URL path → { hash, size }, ready for the create-deployment payload. */ manifest: Record; - /** Hash → file info, used to serve the requested uploads. */ + /** Hash → file info, used to serve upload buckets. */ filesByHash: Map; } @@ -37,6 +51,8 @@ export interface DeploymentProgress { onAssets?: (info: { totalAssets: number; newAssets: number }) => void; /** Fired after each asset upload completes. */ onAssetUpload?: (progress: AssetUploadProgress) => void; + /** Fired before the worker modules are uploaded (finalize). */ + onWorker?: (info: { moduleCount: number }) => void; } /** @@ -47,12 +63,23 @@ export interface DeploymentProgress { export const GIT_HASH_PATTERN = /^[a-fA-F0-9]{7,64}$/; /** - * Request payload for POST deployments (sent as snake_case JSON). A request - * without a worker config is a static-site deployment — the server answers - * it with the `s3` arm of the create response. + * Request payload for POST deployments (sent as snake_case JSON). `config` is + * what selects the deploy target server-side: a worker config means a + * Cloudflare deployment; a request with no `config` field at all is a + * static-site deployment. */ export interface CreateDeploymentRequest { git_hash: string; + config?: { + main: string; + compatibility_date: string | null; + compatibility_flags: string[]; + assets: { + html_handling?: string; + not_found_handling?: string; + run_worker_first?: boolean; + } | null; + }; asset_manifest: Record; } @@ -70,6 +97,19 @@ export interface PresignedAssetUpload { url: string; } +/** The `cf` arm's upload target: asset buckets POSTed directly to Cloudflare, + * authorized by the upload-session token. The last bucket's reply carries the + * completion token finalize wants back. */ +export interface CfAssetUploads { + type: "cf"; + /** Cloudflare's assets upload endpoint. */ + url: string; + /** Upload-session token — sent as `Authorization: Bearer`. */ + jwt: string; + /** Asset hashes grouped by Cloudflare, one POST per bucket. */ + buckets: string[][]; +} + interface S3AssetUploads { type: "s3"; uploads: PresignedAssetUpload[]; @@ -77,27 +117,34 @@ interface S3AssetUploads { /** * POST deployments answers `{deployment_id, asset_uploads}` where - * `asset_uploads` says where the assets still owed should go, discriminated - * on `type` — a config-less (static-site) request is always answered with - * the `s3` arm: direct presigned PUTs, always excluding `/index.html` - * (finalize carries it) — and is null when nothing is owed (no assets, or - * the build already exists). + * `asset_uploads` says where the assets still owed should go — `cf` (direct + * bucket POSTs to Cloudflare, when the request carried a worker `config`) or + * `s3` (direct presigned PUTs, when it carried none) — and is null when + * nothing is owed (no assets, or the build already exists). */ export const CreateDeploymentResponseSchema = z .object({ deployment_id: z.string(), asset_uploads: z - .object({ - type: z.literal("s3"), - uploads: z.array( - z.object({ - path: z.string(), - content_type: z.string(), - content_length: z.number(), - url: z.string(), - }), - ), - }) + .discriminatedUnion("type", [ + z.object({ + type: z.literal("cf"), + url: z.string(), + jwt: z.string(), + buckets: z.array(z.array(z.string())), + }), + z.object({ + type: z.literal("s3"), + uploads: z.array( + z.object({ + path: z.string(), + content_type: z.string(), + content_length: z.number(), + url: z.string(), + }), + ), + }), + ]) .nullable() .optional(), }) @@ -106,21 +153,23 @@ export const CreateDeploymentResponseSchema = z data, ): { deploymentId: string; - assetUploads: S3AssetUploads | null; + assetUploads: CfAssetUploads | S3AssetUploads | null; } => ({ deploymentId: data.deployment_id, assetUploads: data.asset_uploads == null ? null - : { - type: "s3", - uploads: data.asset_uploads.uploads.map((upload) => ({ - path: upload.path, - contentType: upload.content_type, - contentLength: upload.content_length, - url: upload.url, - })), - }, + : data.asset_uploads.type === "cf" + ? data.asset_uploads + : { + type: "s3", + uploads: data.asset_uploads.uploads.map((upload) => ({ + path: upload.path, + contentType: upload.content_type, + contentLength: upload.content_length, + url: upload.url, + })), + }, }), ); @@ -128,6 +177,18 @@ export type CreateDeploymentResponse = z.infer< typeof CreateDeploymentResponseSchema >; +/** + * Response of an asset bucket upload — Cloudflare's reply, relayed verbatim + * by the backend. Only the final response carries the completion token, so + * everything is optional here. + */ +export const AssetUploadResponseSchema = z.looseObject({ + result: z + .looseObject({ jwt: z.string().nullable().optional() }) + .nullable() + .optional(), +}); + export const FinalizeDeploymentResponseSchema = z .object({ deployment_id: z.string(), diff --git a/packages/cli/src/core/deployments/static-site.ts b/packages/cli/src/core/deployments/static-site.ts index 4bbde5979..9821718f1 100644 --- a/packages/cli/src/core/deployments/static-site.ts +++ b/packages/cli/src/core/deployments/static-site.ts @@ -1,6 +1,6 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; -import { InvalidInputError } from "@/core/errors.js"; +import { ApiError, InvalidInputError } from "@/core/errors.js"; import { getAppContext } from "@/core/project/app-config.js"; import { createDeployment, finalizeStaticDeployment } from "./api.js"; import { buildAssetManifest } from "./manifest.js"; @@ -53,6 +53,12 @@ export async function deployStaticSite(options: { git_hash: gitHash, asset_manifest: assets.manifest, }); + if (created.assetUploads && created.assetUploads.type !== "s3") { + throw new ApiError( + `The server answered a static-site deploy with the "${created.assetUploads.type}" upload target.`, + ); + } + // The uploads always exclude index.html; null means every asset is already // stored (re-deploying a commit is idempotent). const totalAssets = Object.keys(assets.manifest).length; diff --git a/packages/cli/src/core/deployments/upload.ts b/packages/cli/src/core/deployments/upload.ts index 4f0e4e673..5e8ff4246 100644 --- a/packages/cli/src/core/deployments/upload.ts +++ b/packages/cli/src/core/deployments/upload.ts @@ -1,22 +1,142 @@ import { readFile } from "node:fs/promises"; -import ky from "ky"; +import ky, { HTTPError } from "ky"; import { ApiError, InternalError } from "@/core/errors.js"; +import { uploadAssetBucket } from "./api.js"; import type { + AssetFile, AssetManifestResult, AssetUploadProgress, + CfAssetUploads, PresignedAssetUpload, } from "./schema.js"; const UPLOAD_CONCURRENCY = 3; const MAX_ATTEMPTS_PER_UPLOAD = 3; const RETRY_BASE_DELAY_MS = 500; +// A 429 from the upload endpoint is a pause, not a failure — wait out the +// window and go again, without burning the regular error-retry attempts. +const MAX_RATE_LIMIT_WAITS = 10; +const RATE_LIMIT_DELAY_MS = 15_000; + +/** + * POST the requested asset buckets directly to Cloudflare, authorized by the + * upload-session jwt from create. Buckets are uploaded with concurrency 3; + * each bucket is retried up to 3 times with exponential backoff. The final + * response carries the completion JWT required to finalize. + */ +export async function uploadAssetBuckets( + target: CfAssetUploads, + filesByHash: Map, + onProgress?: (progress: AssetUploadProgress) => void, +): Promise { + const { buckets } = target; + const totalFiles = buckets.reduce((sum, bucket) => sum + bucket.length, 0); + let uploadedFiles = 0; + let completionJwt: string | null = null; + + let nextBucket = 0; + const worker = async (): Promise => { + while (nextBucket < buckets.length) { + const bucket = buckets[nextBucket++]; + const jwt = await uploadBucketWithRetry(target, bucket, filesByHash); + if (jwt) { + completionJwt = jwt; + } + uploadedFiles += bucket.length; + onProgress?.({ uploadedFiles, totalFiles }); + } + }; + + await Promise.all( + Array.from( + { length: Math.min(UPLOAD_CONCURRENCY, buckets.length) }, + worker, + ), + ); + + if (!completionJwt) { + throw new ApiError( + "Asset upload finished but the server did not return a completion token.", + ); + } + + return completionJwt; +} + +async function uploadBucketWithRetry( + target: CfAssetUploads, + bucket: string[], + filesByHash: Map, +): Promise { + let lastError: unknown; + let rateLimitWaits = 0; + const formData = await buildBucketForm(bucket, filesByHash); + + for (let attempt = 0; attempt < MAX_ATTEMPTS_PER_UPLOAD; attempt++) { + if (attempt > 0) { + await sleep(RETRY_BASE_DELAY_MS * 2 ** (attempt - 1)); + } + try { + return await uploadAssetBucket(target, formData); + } catch (error) { + if ( + error instanceof HTTPError && + error.response.status === 429 && + rateLimitWaits < MAX_RATE_LIMIT_WAITS + ) { + rateLimitWaits++; + attempt--; // a throttle is not a failed attempt + await sleep(RATE_LIMIT_DELAY_MS); + continue; + } + lastError = error; + } + } + + if ( + lastError instanceof HTTPError && + (lastError.response.status === 401 || lastError.response.status === 403) + ) { + throw new ApiError( + "This deploy's upload session has expired — rerun deploy. Already-uploaded assets are skipped on the next attempt.", + { statusCode: lastError.response.status, cause: lastError }, + ); + } + throw await ApiError.fromHttpError( + lastError, + "uploading assets to Cloudflare", + ); +} + +async function buildBucketForm( + bucket: string[], + filesByHash: Map, +): Promise { + const formData = new FormData(); + + for (const hash of bucket) { + const file = filesByHash.get(hash); + if (!file) { + throw new InternalError( + `Server requested upload of unknown asset hash: ${hash}`, + ); + } + const content = await readFile(file.absolutePath); + formData.append( + hash, + new File([content.toString("base64")], hash, { type: file.contentType }), + ); + } + + return formData; +} /** * PUT static assets directly to their presigned S3 URLs (the `s3` create * arm). A presigned URL carries its own authorization in the query string, so * each request is a plain fetch — never the app client, never an - * Authorization header. Uploads run with concurrency 3; - * each file gets 3 attempts with exponential backoff. + * Authorization header. Same policy as the bucket uploads: concurrency 3, + * 3 attempts with exponential backoff per file. */ export async function uploadPresignedAssets( uploads: PresignedAssetUpload[], diff --git a/packages/cli/src/core/deployments/wrangler-config.ts b/packages/cli/src/core/deployments/wrangler-config.ts new file mode 100644 index 000000000..a7c4be4ae --- /dev/null +++ b/packages/cli/src/core/deployments/wrangler-config.ts @@ -0,0 +1,185 @@ +import { dirname, join, resolve } from "node:path"; +import { z } from "zod"; +import { ConfigInvalidError, InvalidInputError } from "@/core/errors.js"; +import { pathExists, readJsonFile } from "@/core/utils/fs.js"; + +/** Redirect file emitted by @cloudflare/vite-plugin builds, at project root. */ +const WRANGLER_REDIRECT_PATH = join(".wrangler", "deploy", "config.json"); + +// Loose: extra fields emitted by framework adapters (auxiliaryWorkers, +// Astro 6's prerenderWorkerConfigPath, ...) are ignored for now. +const RedirectConfigSchema = z.looseObject({ + configPath: z.string().min(1), +}); + +// Only the fields a Base44 deploy acts on are declared. Everything else +// (bindings, worker name, ...) rides along in the loose passthrough and is +// ignored — the deploy neither forwards nor validates it. +const WranglerConfigSchema = z.looseObject({ + main: z.string().min(1, "wrangler config is missing a 'main' entry module"), + no_bundle: z.boolean().optional(), + rules: z + .array(z.looseObject({ type: z.string(), globs: z.array(z.string()) })) + .optional(), + assets: z + .looseObject({ + directory: z.string().optional(), + html_handling: z.string().optional(), + not_found_handling: z.string().optional(), + run_worker_first: z.union([z.boolean(), z.array(z.string())]).optional(), + headers: z.string().optional(), + redirects: z.string().optional(), + }) + .optional(), + compatibility_date: z.string().optional(), + compatibility_flags: z.array(z.string()).optional(), + vars: z.record(z.string(), z.unknown()).optional(), + upload_source_maps: z.boolean().optional(), +}); + +type WranglerConfig = z.infer; + +export interface WranglerModuleRule { + type: string; + globs: string[]; +} + +export interface ResolvedAssetsConfig { + htmlHandling?: string; + notFoundHandling?: string; + runWorkerFirst?: boolean | string[]; + headers?: string; + redirects?: string; +} + +export interface ResolvedWranglerConfig { + /** Absolute path of the wrangler.json that was used. */ + configPath: string; + /** Absolute directory containing the wrangler config; module paths are relative to it. */ + configDir: string; + /** Entry module path, relative to configDir (as written in the config). */ + main: string; + /** Absolute path of the static assets directory, or null when no assets. */ + assetsDirectory: string | null; + assetsConfig: ResolvedAssetsConfig | null; + compatibilityDate: string | null; + compatibilityFlags: string[]; + vars: Record; + rules: WranglerModuleRule[]; + uploadSourceMaps: boolean; +} + +/** + * Detect a full-stack (Cloudflare Workers) build artifact in the project: + * the redirect file emitted by @cloudflare/vite-plugin builds. Returns its + * absolute path, or null when the project has no full-stack artifact. + * + * A hand-authored root wrangler config is deliberately not an artifact. Those + * are written for wrangler's own bundler, which this path never runs (see the + * no_bundle gate below), so detecting one would only hijack the deploy away + * from the static upload it was going to do. + */ +export async function detectFullStackArtifact( + projectRoot: string, +): Promise { + const redirectPath = join(projectRoot, WRANGLER_REDIRECT_PATH); + return (await pathExists(redirectPath)) ? redirectPath : null; +} + +/** + * Resolve and validate the wrangler config for a full-stack deploy. + * Throws with a clear message when there is no artifact, or when the build + * still requires bundling. + */ +export async function resolveWranglerConfig( + projectRoot: string, +): Promise { + const redirectPath = await detectFullStackArtifact(projectRoot); + + if (!redirectPath) { + throw new InvalidInputError( + "No full-stack build artifact found. Expected a .wrangler/deploy/config.json redirect file.", + { + hints: [{ message: "Run your framework's build command first" }], + }, + ); + } + + const configPath = await resolveRedirectedConfigPath(redirectPath); + + const parsed = await readJsonFile(configPath); + const result = WranglerConfigSchema.safeParse(parsed); + if (!result.success) { + throw new ConfigInvalidError( + `Invalid wrangler config: ${z.prettifyError(result.error)}`, + configPath, + ); + } + + const config = result.data; + + if (config.no_bundle !== true) { + throw new InvalidInputError( + "This framework's output requires bundling; not yet supported. Base44 full-stack deploys only support pre-bundled Workers output (no_bundle: true).", + ); + } + + const configDir = dirname(configPath); + const assetsDirectory = config.assets?.directory + ? resolve(configDir, config.assets.directory) + : null; + + return { + configPath, + configDir, + main: config.main, + assetsDirectory, + assetsConfig: config.assets ? toResolvedAssetsConfig(config.assets) : null, + compatibilityDate: config.compatibility_date ?? null, + compatibilityFlags: config.compatibility_flags ?? [], + vars: config.vars ?? {}, + rules: (config.rules ?? []).map((rule) => ({ + type: rule.type, + globs: rule.globs, + })), + uploadSourceMaps: config.upload_source_maps ?? false, + }; +} + +async function resolveRedirectedConfigPath( + redirectPath: string, +): Promise { + const parsed = await readJsonFile(redirectPath); + const result = RedirectConfigSchema.safeParse(parsed); + if (!result.success) { + throw new ConfigInvalidError( + `Invalid deploy redirect file: ${z.prettifyError(result.error)}`, + redirectPath, + ); + } + + // configPath is relative to the redirect file's directory (wrangler semantics). + const configPath = resolve(dirname(redirectPath), result.data.configPath); + if (!(await pathExists(configPath))) { + throw new ConfigInvalidError( + `Wrangler config referenced by ${redirectPath} does not exist: ${configPath}`, + redirectPath, + { + hints: [{ message: "Rebuild the project to regenerate the artifact" }], + }, + ); + } + return configPath; +} + +function toResolvedAssetsConfig( + assets: NonNullable, +): ResolvedAssetsConfig { + return { + htmlHandling: assets.html_handling, + notFoundHandling: assets.not_found_handling, + runWorkerFirst: assets.run_worker_first, + headers: assets.headers, + redirects: assets.redirects, + }; +} diff --git a/packages/cli/src/core/site/deploy-app.ts b/packages/cli/src/core/site/deploy-app.ts index b9f5e1fbf..43e45bec6 100644 --- a/packages/cli/src/core/site/deploy-app.ts +++ b/packages/cli/src/core/site/deploy-app.ts @@ -1,7 +1,9 @@ import { resolve } from "node:path"; import type { DeploymentProgress } from "@/core/deployments/index.js"; import { + deployFullStack, deployStaticSite, + detectFullStackArtifact, resolveGitHash, staticDeploymentsEnabled, } from "@/core/deployments/index.js"; @@ -14,23 +16,30 @@ export interface AppSiteTarget { } /** Which transport ships this project's built output. */ -type AppDeployKind = "static-deployment" | "static" | "none"; +type AppDeployKind = "full-stack" | "static-deployment" | "static" | "none"; export type AppDeployResult = + | { kind: "full-stack"; deploymentId: string; gitHash: string } | { kind: "static-deployment"; deploymentId: string; gitHash: string } | { kind: "static"; appUrl: string } | { kind: "none" }; type AppDeployPlan = + | { kind: "full-stack" } | { kind: "static-deployment"; outputDir: string } | { kind: "static"; outputDir: string } | { kind: "none" }; /** - * A static output ships through the deployments API when the lane is - * enabled, and as the legacy tar.gz upload otherwise. + * A full-stack (Workers) artifact wins over the static output directory: it + * carries the server too, so shipping the static output instead would + * silently drop the worker. A static output ships through the deployments + * API when the lane is enabled, and as the legacy tar.gz upload otherwise. */ async function planAppDeploy(target: AppSiteTarget): Promise { + if (await detectFullStackArtifact(target.root)) { + return { kind: "full-stack" }; + } const outputDirectory = target.site?.outputDirectory; if (!outputDirectory) { return { kind: "none" }; @@ -42,8 +51,9 @@ async function planAppDeploy(target: AppSiteTarget): Promise { } /** - * How the project's built output would ship right now. This only answers for - * the current state of the tree — call it again after any build step. + * How the project's built output would ship right now. The full-stack + * artifact is itself a build output, so this only answers for the current + * state of the tree — call it again after any build step. */ export async function detectAppDeployKind( target: AppSiteTarget, @@ -53,7 +63,8 @@ export async function detectAppDeployKind( /** * Deploy the project's built output over whichever transport applies — - * a deployments-API static deployment when the lane is enabled, the legacy + * a Workers deployment addressed by commit for full-stack builds, a + * deployments-API static deployment when the lane is enabled, the legacy * tar.gz upload otherwise. Returns `{ kind: "none" }` when the project has * nothing to ship. */ @@ -64,6 +75,15 @@ export async function deployAppSite( const plan = await planAppDeploy(target); switch (plan.kind) { + case "full-stack": { + const gitHash = await resolveGitHash(target.root, options.gitHash); + const { deploymentId } = await deployFullStack({ + projectRoot: target.root, + gitHash, + progress: options.progress, + }); + return { kind: "full-stack", deploymentId, gitHash }; + } case "static-deployment": { const gitHash = await resolveGitHash(target.root, options.gitHash); const { deploymentId } = await deployStaticSite({ diff --git a/packages/cli/tests/cli/fullstack_deploy.spec.ts b/packages/cli/tests/cli/fullstack_deploy.spec.ts new file mode 100644 index 000000000..66745c5f5 --- /dev/null +++ b/packages/cli/tests/cli/fullstack_deploy.spec.ts @@ -0,0 +1,254 @@ +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { fixture, setupCLITests } from "./testkit/index.js"; + +/** Same algorithm as core: first 32 hex chars of sha256(utf8(appId) || bytes). */ +function assetHash(appId: string, content: string): string { + return createHash("sha256") + .update(Buffer.from(appId, "utf8")) + .update(Buffer.from(content)) + .digest("hex") + .slice(0, 32); +} + +const INDEX_HTML = "

Hello

\n"; +const APP_JS = 'console.log("app");\n'; + +/** The commit the fixture "build" came from (the fixture is not a git repo). */ +const GIT_HASH = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"; +const DEPLOYMENT_ID = "test-app-git-a1b2c3d4e5f6"; + +interface CreateBody { + git_hash: string; + config: { + main: string; + compatibility_date: string | null; + compatibility_flags: string[]; + assets: Record | null; + }; + asset_manifest: Record; +} + +describe("deploy command (full-stack)", () => { + const t = setupCLITests(); + + /** Mocks hit by the unified deploy's resource-push phase (no resources). */ + function mockResourcePushes() { + t.api.mockAgentsPush({ created: [], updated: [], deleted: [] }); + t.api.mockConnectorsList({ integrations: [] }); + t.api.mockStripeStatus({ stripe_mode: null }); + } + + function mockHappyPath(options?: { buckets?: string[][] }) { + mockResourcePushes(); + const htmlHash = assetHash(t.api.appId, INDEX_HTML); + const jsHash = assetHash(t.api.appId, APP_JS); + t.api.mockDeploymentCreate({ + deployment_id: DEPLOYMENT_ID, + asset_uploads: { + type: "cf", + url: `${t.api.baseUrl}/cf-assets/upload`, + jwt: "upload-session-jwt", + buckets: options?.buckets ?? [[htmlHash], [jsHash]], + }, + }); + t.api.mockAssetUpload("completion-jwt"); + t.api.mockDeploymentFinalize({ deployment_id: DEPLOYMENT_ID }); + return { htmlHash, jsHash }; + } + + it("deploys a full-stack artifact: manifest hashes, bucket relay, finalize modules", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + const { htmlHash, jsHash } = mockHappyPath(); + + const result = await t.run("deploy", "-y", "--git-hash", GIT_HASH); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Found 2 static assets (2 new)"); + t.expectResult(result).toContain("Full-stack app deployed"); + t.expectResult(result).toContain(`Deployment: ${DEPLOYMENT_ID}`); + + // Create request: the commit address + manifest (salted hashes) + expect(t.api.deploymentCreateRequests).toHaveLength(1); + const body = t.api.deploymentCreateRequests[0] as CreateBody; + expect(body.git_hash).toBe(GIT_HASH); + expect(body.config.main).toBe("index.js"); + expect(body.config.compatibility_date).toBe("2025-04-01"); + expect(body.config.compatibility_flags).toEqual(["nodejs_compat"]); + // vars / modules metadata are deliberately not part of the payload + expect(body).not.toHaveProperty("modules"); + expect(body.config).not.toHaveProperty("vars"); + expect(body.asset_manifest).toEqual({ + "/index.html": { hash: htmlHash, size: INDEX_HTML.length }, + "/assets/app-123.js": { hash: jsHash, size: APP_JS.length }, + }); + // .assetsignore honored: ignored.txt and .assetsignore itself excluded + expect(Object.keys(body.asset_manifest)).not.toContain("/ignored.txt"); + expect(Object.keys(body.asset_manifest)).not.toContain("/.assetsignore"); + + // The fixture's wrangler config carries vars — surfaced, not sent + t.expectResult(result).toContain("wrangler 'vars' are not supported"); + + // Direct bucket uploads: two buckets, base64 form fields named by hash, + // POSTed straight to the given URL under the upload-session jwt (never + // the app's own auth). + expect(t.api.assetUploadRequests).toHaveLength(2); + for (const upload of t.api.assetUploadRequests) { + expect(upload.authorization).toBe("Bearer upload-session-jwt"); + expect(upload.base64Query).toBe("true"); + } + const uploadedFields = t.api.assetUploadRequests.flatMap((r) => r.fields); + const uploadedByName = new Map(uploadedFields.map((f) => [f.name, f])); + expect([...uploadedByName.keys()].sort()).toEqual( + [htmlHash, jsHash].sort(), + ); + expect( + Buffer.from( + uploadedByName.get(htmlHash)?.data.toString() ?? "", + "base64", + ).toString(), + ).toBe(INDEX_HTML); + // Bun's compiled binary normalizes Blob types to include the charset. + expect(uploadedByName.get(htmlHash)?.contentType).toMatch( + /^text\/html(;\s*charset=utf-8)?$/i, + ); + + // Finalize: payload carries the completion jwt + one field per module + expect(t.api.finalizeRequests).toHaveLength(1); + const finalizeFields = t.api.finalizeRequests[0]; + const payloadField = finalizeFields.find((f) => f.name === "payload"); + expect(JSON.parse(payloadField?.data.toString() ?? "{}")).toEqual({ + completion_jwt: "completion-jwt", + }); + const fieldNames = finalizeFields.map((f) => f.name).sort(); + expect(fieldNames).toEqual([ + "assets/chunk-abc.js", + "index.js", + "index.js.map", + "payload", + ]); + expect(finalizeFields.find((f) => f.name === "index.js")?.contentType).toBe( + "application/javascript+module", + ); + expect( + finalizeFields.find((f) => f.name === "index.js.map")?.contentType, + ).toBe("application/source-map"); + }); + + it("finalizes with a null completion token when every asset is already stored", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + mockResourcePushes(); + t.api.mockDeploymentCreate({ + deployment_id: DEPLOYMENT_ID, + asset_uploads: null, + }); + t.api.mockDeploymentFinalize({ deployment_id: DEPLOYMENT_ID }); + + const result = await t.run("deploy", "-y", "--git-hash", GIT_HASH); + + t.expectResult(result).toSucceed(); + // Nothing owed: nothing to upload — the server holds the session token + // that completes the asset set, so the client sends null. + expect(t.api.assetUploadRequests).toHaveLength(0); + const payloadField = t.api.finalizeRequests[0].find( + (f) => f.name === "payload", + ); + expect(JSON.parse(payloadField?.data.toString() ?? "{}")).toEqual({ + completion_jwt: null, + }); + }); + + it("normalizes and requires a commit hash", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + mockResourcePushes(); + + // The fixture is not a git checkout, so a deploy without --git-hash has + // no commit to address the deployment by. + const noHash = await t.run("deploy", "-y"); + t.expectResult(noHash).toFail(); + t.expectResult(noHash).toContain("--git-hash"); + + const badHash = await t.run("deploy", "-y", "--git-hash", "not-a-hash"); + t.expectResult(badHash).toFail(); + t.expectResult(badHash).toContain("not a git commit hash"); + }); + + it("outputs a single JSON document with --json", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + mockHappyPath(); + + const result = await t.run( + "deploy", + "-y", + "--json", + "--git-hash", + GIT_HASH, + ); + + t.expectResult(result).toSucceed(); + const parsed = JSON.parse(result.stdout); + expect(parsed).toEqual({ + deploymentId: DEPLOYMENT_ID, + gitHash: GIT_HASH, + }); + }); + + it("warns when the wrangler config lacks the nodejs_compat flag (e.g. Astro 6)", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + // Astro 6's generated wrangler.json can ship without compatibility flags. + const configPath = join( + t.getTempDir(), + "project", + "build", + "server", + "wrangler.json", + ); + const config = JSON.parse(await readFile(configPath, "utf-8")); + config.compatibility_flags = []; + await writeFile(configPath, JSON.stringify(config)); + mockHappyPath(); + + const result = await t.run("deploy", "-y", "--git-hash", GIT_HASH); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("no 'nodejs_compat' compatibility flag"); + const body = t.api.deploymentCreateRequests[0] as CreateBody; + expect(body.config.compatibility_flags).toEqual([]); + }); + + it("surfaces a session-expired error when Cloudflare rejects the session jwt", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + mockResourcePushes(); + t.api.mockDeploymentCreate({ + deployment_id: DEPLOYMENT_ID, + asset_uploads: { + type: "cf", + url: `${t.api.baseUrl}/cf-assets/upload`, + jwt: "expired-jwt", + buckets: [[assetHash(t.api.appId, INDEX_HTML)]], + }, + }); + t.api.mockAssetUploadError({ status: 401, body: { error: "expired" } }); + + const result = await t.run("deploy", "-y", "--git-hash", GIT_HASH); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("upload session has expired"); + }, 20_000); + + it("fails when the deployment API rejects the create call", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + mockResourcePushes(); + t.api.mockError("post", `/api/apps/${t.api.appId}/deployments`, { + status: 422, + body: { message: "unsupported artifact" }, + }); + + const result = await t.run("deploy", "-y", "--git-hash", GIT_HASH); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("unsupported artifact"); + }); +}); diff --git a/packages/cli/tests/cli/site_deploy.spec.ts b/packages/cli/tests/cli/site_deploy.spec.ts index aff60681f..c10ce2cfd 100644 --- a/packages/cli/tests/cli/site_deploy.spec.ts +++ b/packages/cli/tests/cli/site_deploy.spec.ts @@ -1,6 +1,12 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; import { describe, it } from "vitest"; import { fixture, setupCLITests } from "./testkit/index.js"; +/** The commit the fullstack fixture's "build" came from (not a git repo). */ +const GIT_HASH = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"; +const DEPLOYMENT_ID = "test-app-git-a1b2c3d4e5f6"; + describe("site deploy command", () => { const t = setupCLITests(); @@ -44,6 +50,46 @@ describe("site deploy command", () => { t.expectResult(result).toContain("https://my-app.base44.app"); }); + it("deploys the Workers build for a full-stack project", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + t.api.mockDeploymentCreate({ + deployment_id: DEPLOYMENT_ID, + asset_uploads: null, + }); + t.api.mockDeploymentFinalize({ deployment_id: DEPLOYMENT_ID }); + + const result = await t.run("site", "deploy", "-y", "--git-hash", GIT_HASH); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Full-stack app deployed"); + t.expectResult(result).toContain(DEPLOYMENT_ID); + }); + + it("prefers the Workers build over the tar.gz upload when both are possible", async () => { + // A full-stack artifact carries the server too, so uploading the static + // output directory instead would silently drop the worker. + await t.givenLoggedInWithProject(fixture("fullstack-project")); + await writeFile( + join(t.getTempDir(), "project", "base44", "config.jsonc"), + JSON.stringify({ + name: "Fullstack Project", + site: { outputDirectory: "build/client" }, + }), + ); + t.api.mockSiteDeploy({ app_url: "https://legacy.base44.app" }); + t.api.mockDeploymentCreate({ + deployment_id: DEPLOYMENT_ID, + asset_uploads: null, + }); + t.api.mockDeploymentFinalize({ deployment_id: DEPLOYMENT_ID }); + + const result = await t.run("site", "deploy", "-y", "--git-hash", GIT_HASH); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Full-stack app deployed"); + t.expectResult(result).toNotContain("https://legacy.base44.app"); + }); + it("fails when API returns error", async () => { await t.givenLoggedInWithProject(fixture("with-site")); t.api.mockSiteDeployError({ diff --git a/packages/cli/tests/cli/static_site_deployments.spec.ts b/packages/cli/tests/cli/static_site_deployments.spec.ts index 3134bd3ab..3a57dcdc4 100644 --- a/packages/cli/tests/cli/static_site_deployments.spec.ts +++ b/packages/cli/tests/cli/static_site_deployments.spec.ts @@ -26,6 +26,7 @@ const FIXTURE_SIZES: Record = Object.fromEntries( interface CreateBody { git_hash: string; + config?: { main?: string; compatibility_flags?: string[] }; asset_manifest: Record; } @@ -182,4 +183,27 @@ describe("deploy command (static site through the deployments API, env-gated)", t.expectResult(result).toContain("--git-hash"); expect(t.api.deploymentCreateRequests).toHaveLength(0); }); + + it("prefers a full-stack artifact over the static lane (cf arm)", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + t.givenEnv({ BASE44_STATIC_DEPLOYMENTS: "1" }); + mockResourcePushes(); + // Nothing owed on the cf arm: asset_uploads is null either way, and the + // request carrying a worker config is what selects the arm. + t.api.mockDeploymentCreate({ + deployment_id: DEPLOYMENT_ID, + asset_uploads: null, + }); + t.api.mockDeploymentFinalize({ deployment_id: DEPLOYMENT_ID }); + + const result = await t.run("deploy", "-y", "--git-hash", GIT_HASH); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Full-stack app deployed"); + // The framework-emitted wrangler config wins detection and is sent. + const body = t.api.deploymentCreateRequests[0] as CreateBody; + expect(body.config?.main).toBe("index.js"); + expect(body.config?.compatibility_flags).toEqual(["nodejs_compat"]); + expect(t.api.presignedUploadRequests).toHaveLength(0); + }); }); diff --git a/packages/cli/tests/cli/testkit/TestAPIServer.ts b/packages/cli/tests/cli/testkit/TestAPIServer.ts index 9779f6c10..8d10f02a1 100644 --- a/packages/cli/tests/cli/testkit/TestAPIServer.ts +++ b/packages/cli/tests/cli/testkit/TestAPIServer.ts @@ -202,20 +202,23 @@ interface CreateAppResponse { name: string; } -// ─── DEPLOYMENTS TYPES ────────────────────────────────────── +// ─── DEPLOYMENTS (FULL-STACK) TYPES ───────────────────────── interface DeploymentCreateResponse { deployment_id: string; /** Where the assets still owed should go; null/omitted = nothing owed. */ - asset_uploads?: { - type: "s3"; - uploads: Array<{ - path: string; - content_type: string; - content_length: number; - url: string; - }>; - } | null; + asset_uploads?: + | { type: "cf"; url: string; jwt: string; buckets: string[][] } + | { + type: "s3"; + uploads: Array<{ + path: string; + content_type: string; + content_length: number; + url: string; + }>; + } + | null; } interface DeploymentFinalizeResponse { @@ -276,6 +279,13 @@ function parseMultipart( return fields; } +/** A captured asset bucket upload request. */ +interface CapturedAssetUpload { + authorization?: string; + base64Query?: string; + fields: MultipartField[]; +} + /** A captured presigned-style asset PUT. */ interface CapturedPresignedUpload { path: string; @@ -651,10 +661,12 @@ export class TestAPIServer { ); } - // ─── DEPLOYMENT ENDPOINTS ───────────────────────────────── + // ─── DEPLOYMENT (FULL-STACK) ENDPOINTS ─────────────────── /** Captured JSON bodies of POST deployments requests. */ readonly deploymentCreateRequests: unknown[] = []; + /** Captured asset bucket uploads (POST to the upload_url). */ + readonly assetUploadRequests: CapturedAssetUpload[] = []; /** Captured presigned-style asset PUTs (see mockPresignedUpload). */ readonly presignedUploadRequests: CapturedPresignedUpload[] = []; /** Captured multipart fields of finalize requests. */ @@ -676,6 +688,33 @@ export class TestAPIServer { return this; } + /** + * Register a Cloudflare-style assets upload endpoint: serves + * POST /cf-assets/upload — point the cf arm's `url` at + * `${baseUrl}/cf-assets/upload` — capturing each request's Authorization + * header, base64 query param, and multipart fields in + * `assetUploadRequests`, responding 201 with the completion JWT + * (Cloudflare's reply shape). + */ + mockAssetUpload(completionJwt: string): this { + this.pendingRoutes.push({ + method: "POST", + path: "/cf-assets/upload", + handler: (req, res) => { + this.assetUploadRequests.push({ + authorization: req.headers.authorization, + base64Query: String(req.query.base64 ?? ""), + fields: parseMultipart( + req.body as Buffer, + req.headers["content-type"] ?? "", + ), + }); + res.status(201).json({ result: { jwt: completionJwt } }); + }, + }); + return this; + } + /** * Register a presigned-style PUT target for a static asset: serves * PUT /presigned{path} — point `asset_uploads[].url` at @@ -699,9 +738,15 @@ export class TestAPIServer { return this; } + /** Mock the Cloudflare assets endpoint to always fail with the given error. */ + mockAssetUploadError(error: ErrorResponse): this { + return this.addErrorRoute("POST", "/cf-assets/upload", error); + } + /** * Mock POST /api/apps/{appId}/deployments/{id}/finalize. Captures the - * multipart fields in `finalizeRequests`. + * multipart fields (payload JSON + one file field per module) in + * `finalizeRequests`. */ mockDeploymentFinalize(response: DeploymentFinalizeResponse): this { this.pendingRoutes.push({ diff --git a/packages/cli/tests/core/deployments-manifest.spec.ts b/packages/cli/tests/core/deployments-manifest.spec.ts index 09d92ff35..0a95fff21 100644 --- a/packages/cli/tests/core/deployments-manifest.spec.ts +++ b/packages/cli/tests/core/deployments-manifest.spec.ts @@ -60,7 +60,10 @@ describe("buildAssetManifest", () => { size: 15, }); const entry = manifest["/assets/app.js"]; - expect(filesByHash.get(entry.hash)?.size).toBe(entry.size); + expect(filesByHash.get(entry.hash)?.contentType).toBe("text/javascript"); + expect(filesByHash.get(manifest["/index.html"].hash)?.contentType).toBe( + "text/html", + ); }); it("honors .assetsignore patterns (exact names, * globs, directory patterns)", async () => { diff --git a/packages/cli/tests/core/deployments-modules.spec.ts b/packages/cli/tests/core/deployments-modules.spec.ts new file mode 100644 index 000000000..24347c65f --- /dev/null +++ b/packages/cli/tests/core/deployments-modules.spec.ts @@ -0,0 +1,130 @@ +import { mkdir, mkdtemp, rm, truncate, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { collectModules } from "@/core/deployments/modules.js"; +import type { ResolvedWranglerConfig } from "@/core/deployments/wrangler-config.js"; + +describe("collectModules", () => { + let configDir: string; + + beforeEach(async () => { + configDir = await mkdtemp(join(tmpdir(), "b44-modules-")); + }); + + afterEach(async () => { + await rm(configDir, { recursive: true, force: true }); + }); + + function makeConfig( + overrides: Partial = {}, + ): ResolvedWranglerConfig { + return { + configPath: join(configDir, "wrangler.json"), + configDir, + main: "index.js", + assetsDirectory: null, + assetsConfig: null, + compatibilityDate: null, + compatibilityFlags: [], + vars: {}, + rules: [{ type: "ESModule", globs: ["**/*.js", "**/*.mjs"] }], + uploadSourceMaps: false, + ...overrides, + }; + } + + it("collects the entry first plus rules glob matches, preserving relative names", async () => { + await writeFile(join(configDir, "index.js"), "export default {};"); + await mkdir(join(configDir, "assets")); + await writeFile(join(configDir, "assets", "chunk.js"), "export {};"); + await writeFile(join(configDir, "helper.mjs"), "export {};"); + await writeFile(join(configDir, "readme.txt"), "not a module"); + + const modules = await collectModules(makeConfig()); + + expect(modules[0].name).toBe("index.js"); + expect(modules[0].type).toBe("esm"); + expect(modules.map((m) => m.name).sort()).toEqual([ + "assets/chunk.js", + "helper.mjs", + "index.js", + ]); + expect(modules.every((m) => m.size > 0)).toBe(true); + }); + + it("excludes wrangler.json and .dev.vars", async () => { + await writeFile(join(configDir, "index.js"), "export default {};"); + await writeFile(join(configDir, "wrangler.json"), "{}"); + await writeFile(join(configDir, ".dev.vars"), "SECRET=1"); + + const modules = await collectModules(makeConfig()); + + expect(modules.map((m) => m.name)).toEqual(["index.js"]); + }); + + it("includes .map files next to modules as sourcemap modules", async () => { + await writeFile(join(configDir, "index.js"), "export default {};"); + await writeFile(join(configDir, "index.js.map"), "{}"); + await writeFile(join(configDir, "orphan.map"), "{}"); + + const modules = await collectModules(makeConfig()); + + const map = modules.find((m) => m.name === "index.js.map"); + expect(map?.type).toBe("sourcemap"); + // orphan.map is not adjacent to any module and upload_source_maps is off + expect(modules.find((m) => m.name === "orphan.map")).toBeUndefined(); + }); + + it("includes all .map files when upload_source_maps is set", async () => { + await writeFile(join(configDir, "index.js"), "export default {};"); + await writeFile(join(configDir, "orphan.map"), "{}"); + + const modules = await collectModules( + makeConfig({ uploadSourceMaps: true }), + ); + + expect(modules.find((m) => m.name === "orphan.map")?.type).toBe( + "sourcemap", + ); + }); + + it("skips modules under the assets directory when it is inside the config dir", async () => { + await writeFile(join(configDir, "index.js"), "export default {};"); + await mkdir(join(configDir, "client")); + await writeFile(join(configDir, "client", "app.js"), "console.log(1);"); + + const modules = await collectModules( + makeConfig({ assetsDirectory: join(configDir, "client") }), + ); + + expect(modules.map((m) => m.name)).toEqual(["index.js"]); + }); + + it("fails when the entry module does not exist", async () => { + await expect(collectModules(makeConfig())).rejects.toThrow( + /entry module does not exist/, + ); + }); + + it("fails on unknown rule types", async () => { + await writeFile(join(configDir, "index.js"), "export default {};"); + + await expect( + collectModules( + makeConfig({ rules: [{ type: "CommonJS", globs: ["**/*.cjs"] }] }), + ), + ).rejects.toThrow(/Unsupported module rule type "CommonJS"/); + }); + + it("enforces the 40 MB total module payload limit", async () => { + await writeFile(join(configDir, "index.js"), "export default {};"); + const bigModule = join(configDir, "big.js"); + await writeFile(bigModule, ""); + await truncate(bigModule, 40 * 1024 * 1024 + 1); + + await expect(collectModules(makeConfig())).rejects.toThrow( + /exceeds the 40 MB limit/, + ); + }); +}); diff --git a/packages/cli/tests/core/deployments-wrangler-config.spec.ts b/packages/cli/tests/core/deployments-wrangler-config.spec.ts new file mode 100644 index 000000000..58e750fc7 --- /dev/null +++ b/packages/cli/tests/core/deployments-wrangler-config.spec.ts @@ -0,0 +1,141 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + detectFullStackArtifact, + resolveWranglerConfig, +} from "@/core/deployments/wrangler-config.js"; + +const FIXTURES_DIR = resolve(__dirname, "../fixtures"); + +const BASE_CONFIG = { + name: "test-worker", + main: "index.js", + no_bundle: true, + rules: [{ type: "ESModule", globs: ["**/*.js"] }], + compatibility_date: "2025-04-01", +}; + +describe("wrangler config resolution", () => { + let root: string; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "b44-wrangler-")); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + async function writeRedirect(configPath: string): Promise { + await mkdir(join(root, ".wrangler", "deploy"), { recursive: true }); + await writeFile( + join(root, ".wrangler", "deploy", "config.json"), + JSON.stringify({ configPath, auxiliaryWorkers: [] }), + ); + } + + /** A complete build artifact: the redirect file plus the config it names. */ + async function writeArtifact(config: object): Promise { + await writeRedirect("../../out/wrangler.json"); + await mkdir(join(root, "out"), { recursive: true }); + await writeFile(join(root, "out", "wrangler.json"), JSON.stringify(config)); + } + + it("resolves the config through the redirect file (path relative to the redirect dir)", async () => { + await writeRedirect("../../dist/worker/wrangler.json"); + await mkdir(join(root, "dist", "worker"), { recursive: true }); + await writeFile( + join(root, "dist", "worker", "wrangler.json"), + JSON.stringify({ + ...BASE_CONFIG, + assets: { directory: "../client" }, + vars: { FOO: "bar" }, + compatibility_flags: ["nodejs_compat"], + }), + ); + + const config = await resolveWranglerConfig(root); + + expect(config.configDir).toBe(join(root, "dist", "worker")); + expect(config.main).toBe("index.js"); + expect(config.assetsDirectory).toBe(join(root, "dist", "client")); + expect(config.compatibilityDate).toBe("2025-04-01"); + expect(config.compatibilityFlags).toEqual(["nodejs_compat"]); + expect(config.vars).toEqual({ FOO: "bar" }); + expect(config.rules).toEqual([{ type: "ESModule", globs: ["**/*.js"] }]); + }); + + it("resolves the fullstack-project fixture", async () => { + const config = await resolveWranglerConfig( + resolve(FIXTURES_DIR, "fullstack-project"), + ); + + expect(config.main).toBe("index.js"); + expect(config.assetsDirectory).toBe( + resolve(FIXTURES_DIR, "fullstack-project", "build", "client"), + ); + }); + + it("ignores extra redirect-file fields like prerenderWorkerConfigPath (Astro 6)", async () => { + await mkdir(join(root, ".wrangler", "deploy"), { recursive: true }); + await writeFile( + join(root, ".wrangler", "deploy", "config.json"), + JSON.stringify({ + configPath: "../../out/wrangler.json", + auxiliaryWorkers: [], + prerenderWorkerConfigPath: "../../out/prerender/wrangler.json", + }), + ); + await mkdir(join(root, "out"), { recursive: true }); + await writeFile( + join(root, "out", "wrangler.json"), + JSON.stringify(BASE_CONFIG), + ); + + const config = await resolveWranglerConfig(root); + + expect(config.configDir).toBe(join(root, "out")); + expect(config.main).toBe("index.js"); + }); + + it("fails clearly when the config lacks no_bundle: true", async () => { + await writeArtifact({ ...BASE_CONFIG, no_bundle: undefined }); + + await expect(resolveWranglerConfig(root)).rejects.toThrow( + /requires bundling; not yet supported/, + ); + }); + + it("ignores bindings instead of failing on them", async () => { + await writeArtifact({ + ...BASE_CONFIG, + vars: { A: "1" }, + kv_namespaces: [{ binding: "KV", id: "abc" }], + durable_objects: { bindings: [{ name: "DO", class_name: "Foo" }] }, + queues: { producers: [{ binding: "Q", queue: "q" }], consumers: [] }, + }); + + const config = await resolveWranglerConfig(root); + expect(config.main).toBe("index.js"); + expect(config.vars).toEqual({ A: "1" }); + }); + + it("detects nothing in a plain project", async () => { + expect(await detectFullStackArtifact(root)).toBeNull(); + }); + + it("does not treat a hand-authored root wrangler config as an artifact", async () => { + // Root configs target wrangler's own bundler; detecting one would hijack + // the deploy away from the static upload the project actually wants. + await writeFile(join(root, "wrangler.jsonc"), JSON.stringify(BASE_CONFIG)); + await writeFile(join(root, "wrangler.json"), JSON.stringify(BASE_CONFIG)); + await writeFile(join(root, "wrangler.toml"), 'name = "test-worker"\n'); + + expect(await detectFullStackArtifact(root)).toBeNull(); + await expect(resolveWranglerConfig(root)).rejects.toThrow( + /No full-stack build artifact found/, + ); + }); +}); diff --git a/packages/cli/tests/fixtures/fullstack-project/.wrangler/deploy/config.json b/packages/cli/tests/fixtures/fullstack-project/.wrangler/deploy/config.json new file mode 100644 index 000000000..ef993576d --- /dev/null +++ b/packages/cli/tests/fixtures/fullstack-project/.wrangler/deploy/config.json @@ -0,0 +1,4 @@ +{ + "configPath": "../../build/server/wrangler.json", + "auxiliaryWorkers": [] +} diff --git a/packages/cli/tests/fixtures/fullstack-project/base44/.app.jsonc b/packages/cli/tests/fixtures/fullstack-project/base44/.app.jsonc new file mode 100644 index 000000000..d7852426c --- /dev/null +++ b/packages/cli/tests/fixtures/fullstack-project/base44/.app.jsonc @@ -0,0 +1,4 @@ +// Base44 App Configuration +{ + "id": "test-app-id" +} diff --git a/packages/cli/tests/fixtures/fullstack-project/base44/config.jsonc b/packages/cli/tests/fixtures/fullstack-project/base44/config.jsonc new file mode 100644 index 000000000..07684f53c --- /dev/null +++ b/packages/cli/tests/fixtures/fullstack-project/base44/config.jsonc @@ -0,0 +1,3 @@ +{ + "name": "Fullstack Project" +} diff --git a/packages/cli/tests/fixtures/fullstack-project/build/client/.assetsignore b/packages/cli/tests/fixtures/fullstack-project/build/client/.assetsignore new file mode 100644 index 000000000..31164dd17 --- /dev/null +++ b/packages/cli/tests/fixtures/fullstack-project/build/client/.assetsignore @@ -0,0 +1,2 @@ +ignored.txt +*.log diff --git a/packages/cli/tests/fixtures/fullstack-project/build/client/assets/app-123.js b/packages/cli/tests/fixtures/fullstack-project/build/client/assets/app-123.js new file mode 100644 index 000000000..702645f13 --- /dev/null +++ b/packages/cli/tests/fixtures/fullstack-project/build/client/assets/app-123.js @@ -0,0 +1 @@ +console.log("app"); diff --git a/packages/cli/tests/fixtures/fullstack-project/build/client/ignored.txt b/packages/cli/tests/fixtures/fullstack-project/build/client/ignored.txt new file mode 100644 index 000000000..c95db47d5 --- /dev/null +++ b/packages/cli/tests/fixtures/fullstack-project/build/client/ignored.txt @@ -0,0 +1 @@ +should not be uploaded diff --git a/packages/cli/tests/fixtures/fullstack-project/build/client/index.html b/packages/cli/tests/fixtures/fullstack-project/build/client/index.html new file mode 100644 index 000000000..986a4a1a2 --- /dev/null +++ b/packages/cli/tests/fixtures/fullstack-project/build/client/index.html @@ -0,0 +1 @@ +

Hello

diff --git a/packages/cli/tests/fixtures/fullstack-project/build/server/assets/chunk-abc.js b/packages/cli/tests/fixtures/fullstack-project/build/server/assets/chunk-abc.js new file mode 100644 index 000000000..4dc009f65 --- /dev/null +++ b/packages/cli/tests/fixtures/fullstack-project/build/server/assets/chunk-abc.js @@ -0,0 +1,3 @@ +export default function handler() { + return new Response("ok"); +} diff --git a/packages/cli/tests/fixtures/fullstack-project/build/server/index.js b/packages/cli/tests/fixtures/fullstack-project/build/server/index.js new file mode 100644 index 000000000..e304b45bd --- /dev/null +++ b/packages/cli/tests/fixtures/fullstack-project/build/server/index.js @@ -0,0 +1,2 @@ +import handler from "./assets/chunk-abc.js"; +export default { fetch: handler }; diff --git a/packages/cli/tests/fixtures/fullstack-project/build/server/index.js.map b/packages/cli/tests/fixtures/fullstack-project/build/server/index.js.map new file mode 100644 index 000000000..c75fce6ed --- /dev/null +++ b/packages/cli/tests/fixtures/fullstack-project/build/server/index.js.map @@ -0,0 +1 @@ +{"version":3,"file":"index.js","sources":[],"mappings":""} diff --git a/packages/cli/tests/fixtures/fullstack-project/build/server/wrangler.json b/packages/cli/tests/fixtures/fullstack-project/build/server/wrangler.json new file mode 100644 index 000000000..7c548bc63 --- /dev/null +++ b/packages/cli/tests/fixtures/fullstack-project/build/server/wrangler.json @@ -0,0 +1,31 @@ +{ + "name": "fullstack-project", + "main": "index.js", + "no_bundle": true, + "rules": [ + { + "type": "ESModule", + "globs": ["**/*.js", "**/*.mjs"] + } + ], + "assets": { + "directory": "../client" + }, + "compatibility_date": "2025-04-01", + "compatibility_flags": ["nodejs_compat"], + "vars": { + "MY_VAR": "my-value" + }, + "kv_namespaces": [], + "d1_databases": [], + "r2_buckets": [], + "durable_objects": { + "bindings": [] + }, + "services": [], + "queues": { + "producers": [], + "consumers": [] + }, + "hyperdrive": [] +} From 1d0a709526a51378430ce2063a10fd639dc96a44 Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Tue, 4 Aug 2026 11:56:20 +0300 Subject: [PATCH 3/7] refactor: fold the deployments module into core/site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge kept the full-stack deploy code in `core/deployments/`, but main had already settled this: deployments are a transport of the site module, not a module of their own, and they live in `core/site/`. Follow main. `core/deployments/` is gone. One flow per file — `full-stack.ts` (Workers, the cf arm), `static-site.ts` (deployments-API static, the s3 arm), `deploy.ts` (legacy tar.gz) — over shared `manifest.ts`, `upload.ts`, `modules.ts`, `wrangler-config.ts`, and `git-hash.ts`. The deployment requests and schemas merge into the module's existing `api.ts` / `schema.ts` next to the tar.gz upload, as main has them. `deploy-app.ts` stays the transport picker. The Workers flow is `full-stack.ts` rather than `deploy.ts` because that name is already the legacy tar.gz path; it reads as a pair with `static-site.ts`. Unit tests follow the same naming: `tests/core/site-*.spec.ts`. No behavior change — moves, import rewrites, and the barrel/doc updates that follow from them. While merging the two `api.ts` files, the worker-module read switched to the existing `@/core/utils/fs.js` helper instead of a second raw `readFile` binding, for typed FileNotFound/FileRead errors. typecheck, lint, and knip clean; the 8 deploy spec files pass (77 tests). Co-Authored-By: Claude Opus 5 (1M context) --- docs/deployments.md | 8 +- docs/resources.md | 2 + .../src/cli/commands/site/deploy-options.ts | 2 +- packages/cli/src/core/deployments/api.ts | 144 ------------- packages/cli/src/core/deployments/index.ts | 9 - packages/cli/src/core/deployments/schema.ts | 195 ------------------ packages/cli/src/core/index.ts | 1 - packages/cli/src/core/site/api.ts | 144 ++++++++++++- packages/cli/src/core/site/deploy-app.ts | 13 +- .../deploy.ts => site/full-stack.ts} | 0 .../core/{deployments => site}/git-hash.ts | 0 packages/cli/src/core/site/index.ts | 7 + .../core/{deployments => site}/manifest.ts | 0 .../src/core/{deployments => site}/modules.ts | 0 packages/cli/src/core/site/schema.ts | 194 +++++++++++++++++ .../core/{deployments => site}/static-site.ts | 0 .../src/core/{deployments => site}/upload.ts | 0 .../{deployments => site}/wrangler-config.ts | 0 ...manifest.spec.ts => site-manifest.spec.ts} | 2 +- ...s-modules.spec.ts => site-modules.spec.ts} | 4 +- ...g.spec.ts => site-wrangler-config.spec.ts} | 2 +- 21 files changed, 359 insertions(+), 368 deletions(-) delete mode 100644 packages/cli/src/core/deployments/api.ts delete mode 100644 packages/cli/src/core/deployments/index.ts delete mode 100644 packages/cli/src/core/deployments/schema.ts rename packages/cli/src/core/{deployments/deploy.ts => site/full-stack.ts} (100%) rename packages/cli/src/core/{deployments => site}/git-hash.ts (100%) rename packages/cli/src/core/{deployments => site}/manifest.ts (100%) rename packages/cli/src/core/{deployments => site}/modules.ts (100%) rename packages/cli/src/core/{deployments => site}/static-site.ts (100%) rename packages/cli/src/core/{deployments => site}/upload.ts (100%) rename packages/cli/src/core/{deployments => site}/wrangler-config.ts (100%) rename packages/cli/tests/core/{deployments-manifest.spec.ts => site-manifest.spec.ts} (98%) rename packages/cli/tests/core/{deployments-modules.spec.ts => site-modules.spec.ts} (96%) rename packages/cli/tests/core/{deployments-wrangler-config.spec.ts => site-wrangler-config.spec.ts} (99%) diff --git a/docs/deployments.md b/docs/deployments.md index 8b93205e5..e5bb84265 100644 --- a/docs/deployments.md +++ b/docs/deployments.md @@ -2,7 +2,7 @@ **Keywords:** deployments, full-stack, Cloudflare Workers, wrangler, no_bundle, asset manifest, hash, git hash, commit, buckets, presigned, S3, upload session, finalize, .assetsignore, negation, concurrency, .wrangler/deploy/config.json, static site, BASE44_STATIC_DEPLOYMENTS, target -Deployments ship an app's built output addressed by the commit that produced it. The core module is `src/core/deployments/` (`wrangler-config.ts`, `manifest.ts`, `modules.ts`, `upload.ts`, `api.ts`, `deploy.ts`, `static-site.ts`, `git-hash.ts`). +Deployments ship an app's built output addressed by the commit that produced it. This is a transport of the site module, not a module of its own, so it lives directly in `src/core/site/`: `wrangler-config.ts` (artifact detection), `modules.ts` (worker module collection), `manifest.ts` (asset walk + hashing), `upload.ts` (bucket and presigned uploads), `full-stack.ts` and `static-site.ts` (the two flows), `git-hash.ts` (the commit address), with the requests and responses in the shared `api.ts` / `schema.ts` next to the legacy tar.gz upload. Two kinds go through the same protocol, and the create request decides which: a **full-stack** deploy sends a worker `config` (framework builds — React Router 7, TanStack Start, Astro 6, vinext — anything built with `@cloudflare/vite-plugin`) and the server answers with the `cf` arm; a **static-site** deploy sends no `config` at all and the server answers with the `s3` arm. That is the progressive-upgrade path: when a static app adopts a server framework, its emitted wrangler artifact wins detection, the create request starts carrying the worker config, and the server flips arms — one CLI protocol, zero CLI change. @@ -39,7 +39,7 @@ The resolved config must have `no_bundle: true`; otherwise the deploy fails with ## Asset Manifest & Hashing -`hash = first 32 hex chars of sha256(utf8(app_id) || raw file bytes)` — see `hashAsset()` in `src/core/deployments/manifest.ts`. The app-id salt is a cache-poisoning defense: a tenant can only produce hash collisions with its own files. +`hash = first 32 hex chars of sha256(utf8(app_id) || raw file bytes)` — see `hashAsset()` in `src/core/site/manifest.ts`. The app-id salt is a cache-poisoning defense: a tenant can only produce hash collisions with its own files. The assets directory (from `assets.directory` relative to the config dir for full-stack, or `site.outputDirectory` for a static site) is walked with `globby` (`**/*`, dotfiles included, symlinks not followed). `.assetsignore` at the root is honored via globby's `ignoreFiles`, which parses it with the `ignore` package — the same library wrangler uses — so it gets real gitignore semantics: anchoring, directory patterns, `**`, literal braces/extglobs, and **negation** (`!.dev.vars.example` after `.dev.vars*`). Do not translate the patterns by hand, and do not pass globby's `ignore` option alongside `ignoreFiles`: globby globs for ignore files using that option, so it would then find none and silently apply no patterns at all. `.assetsignore` itself, `wrangler.json`, and `.dev.vars` are dropped from the results by name instead. Files over 25 MiB fail with a per-file error; total file count is capped at 100,000. Manifest keys are `/`-prefixed forward-slash paths. @@ -61,13 +61,13 @@ The primary automated consumer is the platform's build/deploy sandbox, which run Full-stack deploys are ungated — an artifact is always shipped as a Workers deployment. The **static** lane is gated: with `BASE44_STATIC_DEPLOYMENTS=1` (or `true`; internal gate, not user-facing yet), a project with `site.outputDirectory` and **no** full-stack artifact deploys through the deployments API instead of the legacy tar.gz upload. `staticDeploymentsEnabled()` is consulted in exactly one place — `planAppDeploy()` in `core/site/deploy-app.ts` — so the gate decides a transport, never a flag's existence. -On the lane, the output directory becomes the asset manifest (index.html included — it is only ever excluded from uploads), and the create request carries **no `config`**, which the server answers with the `s3` arm. The CLI PUTs each requested file directly to its presigned URL and finalizes with the index.html bytes; today's serving keeps working because the server stores the result the way the legacy site upload does. Same commands, same `--git-hash` addressing, same `--json` output (`src/core/deployments/static-site.ts`). +On the lane, the output directory becomes the asset manifest (index.html included — it is only ever excluded from uploads), and the create request carries **no `config`**, which the server answers with the `s3` arm. The CLI PUTs each requested file directly to its presigned URL and finalizes with the index.html bytes; today's serving keeps working because the server stores the result the way the legacy site upload does. Same commands, same `--git-hash` addressing, same `--json` output (`src/core/site/static-site.ts`). With the gate off, a static site takes the legacy tar.gz path unchanged. ## Testing -`TestAPIServer` mocks: `mockDeploymentCreate` (captures the JSON body in `deploymentCreateRequests`; echoes whatever response shape you pass — `asset_uploads` selects the arm: `{type: "cf", ...}`, `{type: "s3", ...}` or `null`), `mockAssetUpload` (serves a Cloudflare-style `POST /cf-assets/upload` target, captures the Authorization header, `?base64=true` query and multipart fields in `assetUploadRequests`, responds 201 with the completion jwt), `mockPresignedUpload(path)` (serves a presigned-style `PUT /presigned{path}` target, captures body/Content-Type/Authorization in `presignedUploadRequests`), `mockDeploymentFinalize` (captures fields in `finalizeRequests`). Fixtures: `tests/fixtures/fullstack-project/` (redirect file + `build/server` worker + `build/client` assets with `.assetsignore`) and `tests/fixtures/with-site/` (static output dir) — not git repos, so specs pass `--git-hash`. Unit tests live in `tests/core/deployments-*.spec.ts`. +`TestAPIServer` mocks: `mockDeploymentCreate` (captures the JSON body in `deploymentCreateRequests`; echoes whatever response shape you pass — `asset_uploads` selects the arm: `{type: "cf", ...}`, `{type: "s3", ...}` or `null`), `mockAssetUpload` (serves a Cloudflare-style `POST /cf-assets/upload` target, captures the Authorization header, `?base64=true` query and multipart fields in `assetUploadRequests`, responds 201 with the completion jwt), `mockPresignedUpload(path)` (serves a presigned-style `PUT /presigned{path}` target, captures body/Content-Type/Authorization in `presignedUploadRequests`), `mockDeploymentFinalize` (captures fields in `finalizeRequests`). Fixtures: `tests/fixtures/fullstack-project/` (redirect file + `build/server` worker + `build/client` assets with `.assetsignore`) and `tests/fixtures/with-site/` (static output dir) — not git repos, so specs pass `--git-hash`. Unit tests live in `tests/core/site-*.spec.ts`. ## Rules (Deployments-Specific) diff --git a/docs/resources.md b/docs/resources.md index 8f61d18b4..9a1261fdb 100644 --- a/docs/resources.md +++ b/docs/resources.md @@ -94,6 +94,8 @@ const result = await deployAppSite(project, { gitHash }); `detectAppDeployKind()` answers what would ship right now — used for the deploy summary and spinner labels. It answers for the current state of the tree; the full-stack artifact is itself a build output, so a build step invalidates it. +One flow per file: `full-stack.ts` (Workers), `static-site.ts` (deployments-API static), `deploy.ts` (legacy tar.gz). The first two share `manifest.ts`, `upload.ts`, `git-hash.ts`, and the module's `api.ts` / `schema.ts`; see [deployments.md](deployments.md). + ### Deploy Flow 1. Validate output directory exists and has files diff --git a/packages/cli/src/cli/commands/site/deploy-options.ts b/packages/cli/src/cli/commands/site/deploy-options.ts index e60222239..102c8e21b 100644 --- a/packages/cli/src/cli/commands/site/deploy-options.ts +++ b/packages/cli/src/cli/commands/site/deploy-options.ts @@ -2,7 +2,7 @@ import { InvalidArgumentError, Option } from "commander"; import { DEFAULT_UPLOAD_CONCURRENCY, MAX_UPLOAD_CONCURRENCY, -} from "@/core/deployments/index.js"; +} from "@/core/site/index.js"; import { isGitCommitHash } from "@/core/utils/git.js"; /** diff --git a/packages/cli/src/core/deployments/api.ts b/packages/cli/src/core/deployments/api.ts deleted file mode 100644 index 1b8056242..000000000 --- a/packages/cli/src/core/deployments/api.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { readFile } from "node:fs/promises"; -import type { KyResponse } from "ky"; -import ky from "ky"; -import { getAppClient } from "@/core/clients/index.js"; -import { ApiError, SchemaValidationError } from "@/core/errors.js"; -import type { - CreateDeploymentRequest, - CreateDeploymentResponse, - FinalizeDeploymentResponse, - ModuleType, - WorkerModule, -} from "./schema.js"; -import { - AssetUploadResponseSchema, - CreateDeploymentResponseSchema, - FinalizeDeploymentResponseSchema, -} from "./schema.js"; - -const MODULE_CONTENT_TYPES: Record = { - esm: "application/javascript+module", - sourcemap: "application/source-map", - wasm: "application/wasm", - text: "text/plain", - data: "application/octet-stream", -}; - -export async function createDeployment( - request: CreateDeploymentRequest, -): Promise { - const appClient = getAppClient(); - - let response: KyResponse; - try { - response = await appClient.post("deployments", { - json: request, - timeout: 120_000, - }); - } catch (error) { - throw await ApiError.fromHttpError(error, "creating deployment"); - } - - const result = CreateDeploymentResponseSchema.safeParse( - await response.json(), - ); - if (!result.success) { - throw new SchemaValidationError( - "Invalid response from server", - result.error, - ); - } - return result.data; -} - -/** - * POST one bucket of asset bytes directly to Cloudflare's assets endpoint, - * authorized by the upload-session jwt from create. The final bucket's - * response carries the completion token. Errors are NOT wrapped here: the - * caller owns retry and error mapping per bucket. - */ -export async function uploadAssetBucket( - target: { url: string; jwt: string }, - formData: FormData, -): Promise { - // Straight to Cloudflare: the upload-session jwt is the credential, so this - // never goes through the app client (and must not carry app auth). - const response: KyResponse = await ky.post(target.url, { - searchParams: { base64: "true" }, - headers: { Authorization: `Bearer ${target.jwt}` }, - body: formData, - timeout: 120_000, - retry: 0, - }); - - const parsed = AssetUploadResponseSchema.safeParse(await response.json()); - const jwt = parsed.success ? parsed.data.result?.jwt : null; - return jwt || null; -} - -export async function finalizeDeployment( - deploymentId: string, - completionJwt: string | null, - modules: WorkerModule[], -): Promise { - const formData = new FormData(); - formData.append("payload", JSON.stringify({ completion_jwt: completionJwt })); - - for (const module of modules) { - const content = await readFile(module.absolutePath); - formData.append( - module.name, - new File([new Uint8Array(content)], module.name, { - type: MODULE_CONTENT_TYPES[module.type], - }), - ); - } - - return await postFinalize(deploymentId, formData); -} - -/** - * Finalize a static-site (s3-target) deployment. The form carries exactly one - * file part — `index.html` — and nothing else (no `payload`, no modules): - * index.html is always excluded from the presigned uploads and travels - * through finalize as the sentinel that completes the deployment. - */ -export async function finalizeStaticDeployment( - deploymentId: string, - indexHtml: Uint8Array, -): Promise { - const formData = new FormData(); - formData.append( - "index.html", - new File([indexHtml], "index.html", { type: "text/html" }), - ); - return await postFinalize(deploymentId, formData); -} - -async function postFinalize( - deploymentId: string, - formData: FormData, -): Promise { - const appClient = getAppClient(); - - let response: KyResponse; - try { - response = await appClient.post( - `deployments/${encodeURIComponent(deploymentId)}/finalize`, - { body: formData, timeout: 180_000 }, - ); - } catch (error) { - throw await ApiError.fromHttpError(error, "finalizing deployment"); - } - - const result = FinalizeDeploymentResponseSchema.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/deployments/index.ts b/packages/cli/src/core/deployments/index.ts deleted file mode 100644 index 39bcdb202..000000000 --- a/packages/cli/src/core/deployments/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export * from "./api.js"; -export * from "./deploy.js"; -export * from "./git-hash.js"; -export * from "./manifest.js"; -export * from "./modules.js"; -export * from "./schema.js"; -export * from "./static-site.js"; -export * from "./upload.js"; -export * from "./wrangler-config.js"; diff --git a/packages/cli/src/core/deployments/schema.ts b/packages/cli/src/core/deployments/schema.ts deleted file mode 100644 index df9ee5a7a..000000000 --- a/packages/cli/src/core/deployments/schema.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { z } from "zod"; - -// ─── SHARED ────────────────────────────────────────────────── - -/** Worker module types accepted by the deployments API. */ -export type ModuleType = "esm" | "sourcemap" | "wasm" | "text" | "data"; - -/** A collected worker module (bytes are read lazily at finalize time). */ -export interface WorkerModule { - /** Module name: path relative to the wrangler config dir (forward slashes). */ - name: string; - /** Absolute path on disk. */ - absolutePath: string; - size: number; - type: ModuleType; -} - -/** Manifest entry keyed by URL-ish path ("/index.html"). */ -export interface AssetManifestEntry { - hash: string; - size: number; -} - -/** A static asset discovered in the assets directory, keyed by hash. */ -export interface AssetFile { - /** Absolute path on disk. */ - absolutePath: string; - hash: string; - size: number; - contentType: string; -} - -export interface AssetManifestResult { - /** URL path → { hash, size }, ready for the create-deployment payload. */ - manifest: Record; - /** Hash → file info, used to serve upload buckets. */ - filesByHash: Map; -} - -/** Progress of an in-flight asset upload set. */ -export interface AssetUploadProgress { - uploadedFiles: number; - totalFiles: number; -} - -/** Progress callbacks a deploy fires as it moves through its stages. */ -export interface DeploymentProgress { - /** Fired for non-fatal issues worth surfacing to the user. */ - onWarning?: (message: string) => void; - /** Fired after the deployment is created: total assets and how many need uploading. */ - onAssets?: (info: { totalAssets: number; newAssets: number }) => void; - /** Fired after each asset upload completes. */ - onAssetUpload?: (progress: AssetUploadProgress) => void; - /** Fired before the worker modules are uploaded (finalize). */ - onWorker?: (info: { moduleCount: number }) => void; -} - -/** - * Request payload for POST deployments (sent as snake_case JSON). `config` is - * what selects the deploy target server-side: a worker config means a - * Cloudflare deployment; a request with no `config` field at all is a - * static-site deployment. - */ -export interface CreateDeploymentRequest { - git_hash: string; - config?: { - main: string; - compatibility_date: string | null; - compatibility_flags: string[]; - assets: { - html_handling?: string; - not_found_handling?: string; - run_worker_first?: boolean; - } | null; - }; - asset_manifest: Record; -} - -// ─── RESPONSES ─────────────────────────────────────────────── - -/** A static asset the server wants uploaded, with its presigned S3 URL. */ -export interface PresignedAssetUpload { - /** Manifest path of the asset ("/assets/app.js"). */ - path: string; - /** Content-Type signed into the URL — the PUT must send it verbatim. */ - contentType: string; - /** Byte count signed into the URL — the PUT body must be exactly this long. */ - contentLength: number; - /** Presigned S3 URL — the URL itself is the credential. */ - url: string; -} - -/** The `cf` arm's upload target: asset buckets POSTed directly to Cloudflare, - * authorized by the upload-session token. The last bucket's reply carries the - * completion token finalize wants back. */ -export interface CfAssetUploads { - type: "cf"; - /** Cloudflare's assets upload endpoint. */ - url: string; - /** Upload-session token — sent as `Authorization: Bearer`. */ - jwt: string; - /** Asset hashes grouped by Cloudflare, one POST per bucket. */ - buckets: string[][]; -} - -interface S3AssetUploads { - type: "s3"; - uploads: PresignedAssetUpload[]; -} - -/** - * POST deployments answers `{deployment_id, asset_uploads}` where - * `asset_uploads` says where the assets still owed should go — `cf` (direct - * bucket POSTs to Cloudflare, when the request carried a worker `config`) or - * `s3` (direct presigned PUTs, when it carried none) — and is null when - * nothing is owed (no assets, or the build already exists). - */ -export const CreateDeploymentResponseSchema = z - .object({ - deployment_id: z.string(), - asset_uploads: z - .discriminatedUnion("type", [ - z.object({ - type: z.literal("cf"), - url: z.string(), - jwt: z.string(), - buckets: z.array(z.array(z.string())), - }), - z.object({ - type: z.literal("s3"), - uploads: z.array( - z.object({ - path: z.string(), - content_type: z.string(), - content_length: z.number(), - url: z.string(), - }), - ), - }), - ]) - .nullable() - .optional(), - }) - .transform( - ( - data, - ): { - deploymentId: string; - assetUploads: CfAssetUploads | S3AssetUploads | null; - } => ({ - deploymentId: data.deployment_id, - assetUploads: - data.asset_uploads == null - ? null - : data.asset_uploads.type === "cf" - ? data.asset_uploads - : { - type: "s3", - uploads: data.asset_uploads.uploads.map((upload) => ({ - path: upload.path, - contentType: upload.content_type, - contentLength: upload.content_length, - url: upload.url, - })), - }, - }), - ); - -export type CreateDeploymentResponse = z.infer< - typeof CreateDeploymentResponseSchema ->; - -/** - * Response of an asset bucket upload — Cloudflare's reply, relayed verbatim - * by the backend. Only the final response carries the completion token, so - * everything is optional here. - */ -export const AssetUploadResponseSchema = z.looseObject({ - result: z - .looseObject({ jwt: z.string().nullable().optional() }) - .nullable() - .optional(), -}); - -export const FinalizeDeploymentResponseSchema = z - .object({ - deployment_id: z.string(), - }) - .transform((data) => ({ - deploymentId: data.deployment_id, - })); - -export type FinalizeDeploymentResponse = z.infer< - typeof FinalizeDeploymentResponseSchema ->; diff --git a/packages/cli/src/core/index.ts b/packages/cli/src/core/index.ts index cb9e329b6..b6b6250d7 100644 --- a/packages/cli/src/core/index.ts +++ b/packages/cli/src/core/index.ts @@ -2,7 +2,6 @@ export * from "./auth/index.js"; export * from "./clients/index.js"; export * from "./config.js"; export * from "./consts.js"; -export * from "./deployments/index.js"; export * from "./errors.js"; export * from "./project/index.js"; export * from "./resources/index.js"; diff --git a/packages/cli/src/core/site/api.ts b/packages/cli/src/core/site/api.ts index 9683316e2..2544c17a9 100644 --- a/packages/cli/src/core/site/api.ts +++ b/packages/cli/src/core/site/api.ts @@ -1,8 +1,21 @@ import type { KyResponse } from "ky"; +import ky from "ky"; import { getAppClient } from "@/core/clients/index.js"; import { ApiError, SchemaValidationError } from "@/core/errors.js"; -import type { DeployResponse } from "@/core/site/schema.js"; -import { DeployResponseSchema } from "@/core/site/schema.js"; +import type { + CreateDeploymentRequest, + CreateDeploymentResponse, + DeployResponse, + FinalizeDeploymentResponse, + ModuleType, + WorkerModule, +} from "@/core/site/schema.js"; +import { + AssetUploadResponseSchema, + CreateDeploymentResponseSchema, + DeployResponseSchema, + FinalizeDeploymentResponseSchema, +} from "@/core/site/schema.js"; import { readFile } from "@/core/utils/fs.js"; /** @@ -40,3 +53,130 @@ export async function uploadSite(archivePath: string): Promise { return result.data; } + +const MODULE_CONTENT_TYPES: Record = { + esm: "application/javascript+module", + sourcemap: "application/source-map", + wasm: "application/wasm", + text: "text/plain", + data: "application/octet-stream", +}; + +export async function createDeployment( + request: CreateDeploymentRequest, +): Promise { + const appClient = getAppClient(); + + let response: KyResponse; + try { + response = await appClient.post("deployments", { + json: request, + timeout: 120_000, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, "creating deployment"); + } + + const result = CreateDeploymentResponseSchema.safeParse( + await response.json(), + ); + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error, + ); + } + return result.data; +} + +/** + * POST one bucket of asset bytes directly to Cloudflare's assets endpoint, + * authorized by the upload-session jwt from create. The final bucket's + * response carries the completion token. Errors are NOT wrapped here: the + * caller owns retry and error mapping per bucket. + */ +export async function uploadAssetBucket( + target: { url: string; jwt: string }, + formData: FormData, +): Promise { + // Straight to Cloudflare: the upload-session jwt is the credential, so this + // never goes through the app client (and must not carry app auth). + const response: KyResponse = await ky.post(target.url, { + searchParams: { base64: "true" }, + headers: { Authorization: `Bearer ${target.jwt}` }, + body: formData, + timeout: 120_000, + retry: 0, + }); + + const parsed = AssetUploadResponseSchema.safeParse(await response.json()); + const jwt = parsed.success ? parsed.data.result?.jwt : null; + return jwt || null; +} + +export async function finalizeDeployment( + deploymentId: string, + completionJwt: string | null, + modules: WorkerModule[], +): Promise { + const formData = new FormData(); + formData.append("payload", JSON.stringify({ completion_jwt: completionJwt })); + + for (const module of modules) { + const content = await readFile(module.absolutePath); + formData.append( + module.name, + new File([new Uint8Array(content)], module.name, { + type: MODULE_CONTENT_TYPES[module.type], + }), + ); + } + + return await postFinalize(deploymentId, formData); +} + +/** + * Finalize a static-site (s3-target) deployment. The form carries exactly one + * file part — `index.html` — and nothing else (no `payload`, no modules): + * index.html is always excluded from the presigned uploads and travels + * through finalize as the sentinel that completes the deployment. + */ +export async function finalizeStaticDeployment( + deploymentId: string, + indexHtml: Uint8Array, +): Promise { + const formData = new FormData(); + formData.append( + "index.html", + new File([indexHtml], "index.html", { type: "text/html" }), + ); + return await postFinalize(deploymentId, formData); +} + +async function postFinalize( + deploymentId: string, + formData: FormData, +): Promise { + const appClient = getAppClient(); + + let response: KyResponse; + try { + response = await appClient.post( + `deployments/${encodeURIComponent(deploymentId)}/finalize`, + { body: formData, timeout: 180_000 }, + ); + } catch (error) { + throw await ApiError.fromHttpError(error, "finalizing deployment"); + } + + const result = FinalizeDeploymentResponseSchema.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/site/deploy-app.ts b/packages/cli/src/core/site/deploy-app.ts index c724faf01..ddf542e7c 100644 --- a/packages/cli/src/core/site/deploy-app.ts +++ b/packages/cli/src/core/site/deploy-app.ts @@ -1,13 +1,10 @@ import { resolve } from "node:path"; -import type { DeploymentProgress } from "@/core/deployments/index.js"; -import { - deployFullStack, - deployStaticSite, - detectFullStackArtifact, - resolveGitHash, - staticDeploymentsEnabled, -} from "@/core/deployments/index.js"; import { deploySite } from "@/core/site/deploy.js"; +import { deployFullStack } from "./full-stack.js"; +import { resolveGitHash } from "./git-hash.js"; +import type { DeploymentProgress } from "./schema.js"; +import { deployStaticSite, staticDeploymentsEnabled } from "./static-site.js"; +import { detectFullStackArtifact } from "./wrangler-config.js"; /** The project fields an app deploy reads. */ export interface AppSiteTarget { diff --git a/packages/cli/src/core/deployments/deploy.ts b/packages/cli/src/core/site/full-stack.ts similarity index 100% rename from packages/cli/src/core/deployments/deploy.ts rename to packages/cli/src/core/site/full-stack.ts diff --git a/packages/cli/src/core/deployments/git-hash.ts b/packages/cli/src/core/site/git-hash.ts similarity index 100% rename from packages/cli/src/core/deployments/git-hash.ts rename to packages/cli/src/core/site/git-hash.ts diff --git a/packages/cli/src/core/site/index.ts b/packages/cli/src/core/site/index.ts index 9012035ad..e57020862 100644 --- a/packages/cli/src/core/site/index.ts +++ b/packages/cli/src/core/site/index.ts @@ -2,4 +2,11 @@ export * from "./api.js"; export * from "./config.js"; export * from "./deploy.js"; export * from "./deploy-app.js"; +export * from "./full-stack.js"; +export * from "./git-hash.js"; +export * from "./manifest.js"; +export * from "./modules.js"; export * from "./schema.js"; +export * from "./static-site.js"; +export * from "./upload.js"; +export * from "./wrangler-config.js"; diff --git a/packages/cli/src/core/deployments/manifest.ts b/packages/cli/src/core/site/manifest.ts similarity index 100% rename from packages/cli/src/core/deployments/manifest.ts rename to packages/cli/src/core/site/manifest.ts diff --git a/packages/cli/src/core/deployments/modules.ts b/packages/cli/src/core/site/modules.ts similarity index 100% rename from packages/cli/src/core/deployments/modules.ts rename to packages/cli/src/core/site/modules.ts diff --git a/packages/cli/src/core/site/schema.ts b/packages/cli/src/core/site/schema.ts index 52afcd312..72f51c303 100644 --- a/packages/cli/src/core/site/schema.ts +++ b/packages/cli/src/core/site/schema.ts @@ -16,3 +16,197 @@ export type DeployResponse = z.infer; export const PublishedUrlResponseSchema = z.object({ url: z.string(), }); + +// ─── SHARED ────────────────────────────────────────────────── + +/** Worker module types accepted by the deployments API. */ +export type ModuleType = "esm" | "sourcemap" | "wasm" | "text" | "data"; + +/** A collected worker module (bytes are read lazily at finalize time). */ +export interface WorkerModule { + /** Module name: path relative to the wrangler config dir (forward slashes). */ + name: string; + /** Absolute path on disk. */ + absolutePath: string; + size: number; + type: ModuleType; +} + +/** Manifest entry keyed by URL-ish path ("/index.html"). */ +export interface AssetManifestEntry { + hash: string; + size: number; +} + +/** A static asset discovered in the assets directory, keyed by hash. */ +export interface AssetFile { + /** Absolute path on disk. */ + absolutePath: string; + hash: string; + size: number; + contentType: string; +} + +export interface AssetManifestResult { + /** URL path → { hash, size }, ready for the create-deployment payload. */ + manifest: Record; + /** Hash → file info, used to serve upload buckets. */ + filesByHash: Map; +} + +/** Progress of an in-flight asset upload set. */ +export interface AssetUploadProgress { + uploadedFiles: number; + totalFiles: number; +} + +/** Progress callbacks a deploy fires as it moves through its stages. */ +export interface DeploymentProgress { + /** Fired for non-fatal issues worth surfacing to the user. */ + onWarning?: (message: string) => void; + /** Fired after the deployment is created: total assets and how many need uploading. */ + onAssets?: (info: { totalAssets: number; newAssets: number }) => void; + /** Fired after each asset upload completes. */ + onAssetUpload?: (progress: AssetUploadProgress) => void; + /** Fired before the worker modules are uploaded (finalize). */ + onWorker?: (info: { moduleCount: number }) => void; +} + +/** + * Request payload for POST deployments (sent as snake_case JSON). `config` is + * what selects the deploy target server-side: a worker config means a + * Cloudflare deployment; a request with no `config` field at all is a + * static-site deployment. + */ +export interface CreateDeploymentRequest { + git_hash: string; + config?: { + main: string; + compatibility_date: string | null; + compatibility_flags: string[]; + assets: { + html_handling?: string; + not_found_handling?: string; + run_worker_first?: boolean; + } | null; + }; + asset_manifest: Record; +} + +// ─── RESPONSES ─────────────────────────────────────────────── + +/** A static asset the server wants uploaded, with its presigned S3 URL. */ +export interface PresignedAssetUpload { + /** Manifest path of the asset ("/assets/app.js"). */ + path: string; + /** Content-Type signed into the URL — the PUT must send it verbatim. */ + contentType: string; + /** Byte count signed into the URL — the PUT body must be exactly this long. */ + contentLength: number; + /** Presigned S3 URL — the URL itself is the credential. */ + url: string; +} + +/** The `cf` arm's upload target: asset buckets POSTed directly to Cloudflare, + * authorized by the upload-session token. The last bucket's reply carries the + * completion token finalize wants back. */ +export interface CfAssetUploads { + type: "cf"; + /** Cloudflare's assets upload endpoint. */ + url: string; + /** Upload-session token — sent as `Authorization: Bearer`. */ + jwt: string; + /** Asset hashes grouped by Cloudflare, one POST per bucket. */ + buckets: string[][]; +} + +interface S3AssetUploads { + type: "s3"; + uploads: PresignedAssetUpload[]; +} + +/** + * POST deployments answers `{deployment_id, asset_uploads}` where + * `asset_uploads` says where the assets still owed should go — `cf` (direct + * bucket POSTs to Cloudflare, when the request carried a worker `config`) or + * `s3` (direct presigned PUTs, when it carried none) — and is null when + * nothing is owed (no assets, or the build already exists). + */ +export const CreateDeploymentResponseSchema = z + .object({ + deployment_id: z.string(), + asset_uploads: z + .discriminatedUnion("type", [ + z.object({ + type: z.literal("cf"), + url: z.string(), + jwt: z.string(), + buckets: z.array(z.array(z.string())), + }), + z.object({ + type: z.literal("s3"), + uploads: z.array( + z.object({ + path: z.string(), + content_type: z.string(), + content_length: z.number(), + url: z.string(), + }), + ), + }), + ]) + .nullable() + .optional(), + }) + .transform( + ( + data, + ): { + deploymentId: string; + assetUploads: CfAssetUploads | S3AssetUploads | null; + } => ({ + deploymentId: data.deployment_id, + assetUploads: + data.asset_uploads == null + ? null + : data.asset_uploads.type === "cf" + ? data.asset_uploads + : { + type: "s3", + uploads: data.asset_uploads.uploads.map((upload) => ({ + path: upload.path, + contentType: upload.content_type, + contentLength: upload.content_length, + url: upload.url, + })), + }, + }), + ); + +export type CreateDeploymentResponse = z.infer< + typeof CreateDeploymentResponseSchema +>; + +/** + * Response of an asset bucket upload — Cloudflare's reply, relayed verbatim + * by the backend. Only the final response carries the completion token, so + * everything is optional here. + */ +export const AssetUploadResponseSchema = z.looseObject({ + result: z + .looseObject({ jwt: z.string().nullable().optional() }) + .nullable() + .optional(), +}); + +export const FinalizeDeploymentResponseSchema = z + .object({ + deployment_id: z.string(), + }) + .transform((data) => ({ + deploymentId: data.deployment_id, + })); + +export type FinalizeDeploymentResponse = z.infer< + typeof FinalizeDeploymentResponseSchema +>; diff --git a/packages/cli/src/core/deployments/static-site.ts b/packages/cli/src/core/site/static-site.ts similarity index 100% rename from packages/cli/src/core/deployments/static-site.ts rename to packages/cli/src/core/site/static-site.ts diff --git a/packages/cli/src/core/deployments/upload.ts b/packages/cli/src/core/site/upload.ts similarity index 100% rename from packages/cli/src/core/deployments/upload.ts rename to packages/cli/src/core/site/upload.ts diff --git a/packages/cli/src/core/deployments/wrangler-config.ts b/packages/cli/src/core/site/wrangler-config.ts similarity index 100% rename from packages/cli/src/core/deployments/wrangler-config.ts rename to packages/cli/src/core/site/wrangler-config.ts diff --git a/packages/cli/tests/core/deployments-manifest.spec.ts b/packages/cli/tests/core/site-manifest.spec.ts similarity index 98% rename from packages/cli/tests/core/deployments-manifest.spec.ts rename to packages/cli/tests/core/site-manifest.spec.ts index dc5977b5c..063a3a141 100644 --- a/packages/cli/tests/core/deployments-manifest.spec.ts +++ b/packages/cli/tests/core/site-manifest.spec.ts @@ -3,7 +3,7 @@ import { mkdir, mkdtemp, rm, truncate, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { buildAssetManifest, hashAsset } from "@/core/deployments/manifest.js"; +import { buildAssetManifest, hashAsset } from "@/core/site/manifest.js"; describe("hashAsset", () => { it("computes the first 32 hex chars of sha256(utf8(app_id) || bytes)", () => { diff --git a/packages/cli/tests/core/deployments-modules.spec.ts b/packages/cli/tests/core/site-modules.spec.ts similarity index 96% rename from packages/cli/tests/core/deployments-modules.spec.ts rename to packages/cli/tests/core/site-modules.spec.ts index 24347c65f..c8c9593be 100644 --- a/packages/cli/tests/core/deployments-modules.spec.ts +++ b/packages/cli/tests/core/site-modules.spec.ts @@ -2,8 +2,8 @@ import { mkdir, mkdtemp, rm, truncate, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { collectModules } from "@/core/deployments/modules.js"; -import type { ResolvedWranglerConfig } from "@/core/deployments/wrangler-config.js"; +import { collectModules } from "@/core/site/modules.js"; +import type { ResolvedWranglerConfig } from "@/core/site/wrangler-config.js"; describe("collectModules", () => { let configDir: string; diff --git a/packages/cli/tests/core/deployments-wrangler-config.spec.ts b/packages/cli/tests/core/site-wrangler-config.spec.ts similarity index 99% rename from packages/cli/tests/core/deployments-wrangler-config.spec.ts rename to packages/cli/tests/core/site-wrangler-config.spec.ts index 58e750fc7..c7163b13e 100644 --- a/packages/cli/tests/core/deployments-wrangler-config.spec.ts +++ b/packages/cli/tests/core/site-wrangler-config.spec.ts @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { detectFullStackArtifact, resolveWranglerConfig, -} from "@/core/deployments/wrangler-config.js"; +} from "@/core/site/wrangler-config.js"; const FIXTURES_DIR = resolve(__dirname, "../fixtures"); From 3e89244bd204fa8f0f52face91f1b33f2e347d15 Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Tue, 4 Aug 2026 12:09:52 +0300 Subject: [PATCH 4/7] =?UTF-8?q?refactor(site):=20give=20both=20upload=20ar?= =?UTF-8?q?ms=20one=20retry=20idiom=20=E2=80=94=20ky's?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The s3 arm already used pMap for concurrency and ky's own retry. The cf arm had a hand-rolled attempt loop, a `sleep()` setTimeout helper, and bespoke 429 bookkeeping alongside it. Two idioms in one file for the same job. Both arms now share `UPLOAD_RETRY` and let ky do the retrying. ky retries network errors and its default status codes only (408/413/429/500/502/503/504), which is what these uploads actually want: an expired credential (401/403) fails fast instead of burning every attempt, and a 429 waits out the server's `Retry-After` instead of the flat 15s the old loop invented. The 401/403 "upload session expired" mapping is unchanged. Two things worth knowing, both now covered: - POST is absent from ky's default retry `methods`, so the cf arm has to name it explicitly or bucket uploads would silently never retry. The new test fails if that option is dropped. - ky clones a pristine request before sending, so the FormData body survives being resent. The test asserts every attempt carried the full body, since a consumed body would fail as "Body is unusable" only under real retries. `uploadAssetBucket` moved from api.ts into upload.ts, where it can share the retry config without a cycle. That also sharpens the split: api.ts is the app-client (authenticated Base44 API) calls, upload.ts is the direct-to-storage uploads that deliberately bypass that client. Dropped: MAX_RATE_LIMIT_WAITS, RATE_LIMIT_DELAY_MS, uploadBucketWithRetry, sleep(). The 429 fixed-wait behavior they implemented had no test. typecheck, lint, and knip clean; the deploy specs pass (51 tests, 3 runs). Co-Authored-By: Claude Opus 5 (1M context) --- docs/deployments.md | 7 +- packages/cli/src/core/site/api.ts | 27 ----- packages/cli/src/core/site/upload.ts | 99 +++++++++---------- .../cli/tests/cli/fullstack_deploy.spec.ts | 38 +++++++ .../cli/tests/cli/testkit/TestAPIServer.ts | 16 +++ 5 files changed, 105 insertions(+), 82 deletions(-) diff --git a/docs/deployments.md b/docs/deployments.md index e5bb84265..c44add732 100644 --- a/docs/deployments.md +++ b/docs/deployments.md @@ -28,9 +28,11 @@ The resolved config must have `no_bundle: true`; otherwise the deploy fails with - `{type: "cf", url, jwt, buckets}` — a worker deploy: `buckets` are asset hashes grouped by Cloudflare, `url` is Cloudflare's assets upload endpoint, `jwt` is the upload-session token. - `{type: "s3", uploads: [{path, content_type, content_length, url}]}` — a static deploy: one presigned S3 PUT per asset still to upload, **always excluding `/index.html`** (finalize writes it). - `null` — nothing owed: no assets, or the build already exists (re-deploying a commit is idempotent). -2. **Asset upload — bytes never pass through the backend.** cf: for each bucket, `POST` multipart/form-data **directly to the given `url`** with `?base64=true` and `Authorization: Bearer `; each field: name = file hash, value = base64 file bytes, contentType = the file's real MIME type. Each bucket retries up to 3 times with exponential backoff, a 429 waits out the window without burning an attempt, and a 401/403 maps to "upload session expired — rerun deploy". The final bucket's response carries `{"result": {"jwt": ""}}`. s3: each upload's raw file bytes are `PUT` directly to its presigned `url` with the signed `content_type` sent verbatim (the URL also signs `content_length`, so the body must be exactly the declared bytes) — the URL itself is the credential, so no auth headers and never the app client; retries are ky's, which cover network errors and 408/429/5xx only, so a 403 from an expired URL fails fast instead of burning every attempt. +2. **Asset upload — bytes never pass through the backend.** cf: for each bucket, `POST` multipart/form-data **directly to the given `url`** with `?base64=true` and `Authorization: Bearer `; each field: name = file hash, value = base64 file bytes, contentType = the file's real MIME type. A 401/403 maps to "upload session expired — rerun deploy". The final bucket's response carries `{"result": {"jwt": ""}}`. s3: each upload's raw file bytes are `PUT` directly to its presigned `url` with the signed `content_type` sent verbatim (the URL also signs `content_length`, so the body must be exactly the declared bytes) — the URL itself is the credential, so no auth headers and never the app client. - Both arms upload with `pMap`. Concurrency defaults to `DEFAULT_UPLOAD_CONCURRENCY` (3) and is overridable with `--concurrency `, capped at `MAX_UPLOAD_CONCURRENCY` (50) because each worker holds a whole file in memory. + Both arms are the same shape: `pMap` for concurrency and ky's own retry (`UPLOAD_RETRY`) for attempts — no hand-rolled loops, no `setTimeout` sleeps. ky retries network errors and its default status codes only (408/413/429/500/502/503/504), which is exactly what these uploads want: an expired credential (401/403) fails fast instead of burning every attempt, and a 429 waits out the server's `Retry-After` rather than a delay we invented. **The cf arm must name `methods: ["post"]`** — POST is absent from ky's default retry methods, so bucket uploads would otherwise never retry at all. ky clones a pristine request before sending, so a FormData body survives being resent. + + Concurrency defaults to `DEFAULT_UPLOAD_CONCURRENCY` (3) and is overridable with `--concurrency `, capped at `MAX_UPLOAD_CONCURRENCY` (50) because each worker holds a whole file in memory. 3. `POST deployments/{id}/finalize` — multipart, shape follows which arm the request selected: - **worker**: field `payload` = JSON `{"completion_jwt": string|null}` plus one file field per module (name = module path, contentType `application/javascript+module` for esm / `application/source-map` for `.map`). `completion_jwt` is null when `asset_uploads` came back null — the server holds the session token that completes the asset set. Bundle cap: 50 MB. - **static**: exactly one file field named `index.html` carrying the index.html bytes (contentType `text/html`) — no `payload`, no modules. index.html is the sentinel that completes the deployment, which is also why it never appears in the uploads. @@ -74,6 +76,7 @@ With the gate off, a static site takes the legacy tar.gz path unchanged. - **Never re-derive the asset hash** — always go through `hashAsset()` so the app-id salt stays consistent - **Asset bytes never pass through the backend** — cf buckets POST directly to Cloudflare authorized by the upload-session jwt (and never through the app client, which would leak app auth); s3 PUTs go directly to the presigned URLs, where the URL itself is the credential and no auth header may be sent - **Never derive an upload's Content-Type on the s3 arm** — the server signs it into the presigned URL; echo the signed value verbatim +- **Never hand-roll upload retry or backoff** — configure ky's `retry`; both arms share `UPLOAD_RETRY`, and a non-default method (POST) must be named in `methods` - **Never hand-roll `.assetsignore` matching** — let globby's `ignoreFiles` parse it, and never pass `ignore` alongside it - **`git_hash` is required** — a build with no commit behind it has no address and could never be published - **Legacy behavior stays identical** when no full-stack artifact exists and the static gate is off — the tar.gz site path must not change diff --git a/packages/cli/src/core/site/api.ts b/packages/cli/src/core/site/api.ts index 2544c17a9..9861627d1 100644 --- a/packages/cli/src/core/site/api.ts +++ b/packages/cli/src/core/site/api.ts @@ -1,5 +1,4 @@ import type { KyResponse } from "ky"; -import ky from "ky"; import { getAppClient } from "@/core/clients/index.js"; import { ApiError, SchemaValidationError } from "@/core/errors.js"; import type { @@ -11,7 +10,6 @@ import type { WorkerModule, } from "@/core/site/schema.js"; import { - AssetUploadResponseSchema, CreateDeploymentResponseSchema, DeployResponseSchema, FinalizeDeploymentResponseSchema, @@ -89,31 +87,6 @@ export async function createDeployment( return result.data; } -/** - * POST one bucket of asset bytes directly to Cloudflare's assets endpoint, - * authorized by the upload-session jwt from create. The final bucket's - * response carries the completion token. Errors are NOT wrapped here: the - * caller owns retry and error mapping per bucket. - */ -export async function uploadAssetBucket( - target: { url: string; jwt: string }, - formData: FormData, -): Promise { - // Straight to Cloudflare: the upload-session jwt is the credential, so this - // never goes through the app client (and must not carry app auth). - const response: KyResponse = await ky.post(target.url, { - searchParams: { base64: "true" }, - headers: { Authorization: `Bearer ${target.jwt}` }, - body: formData, - timeout: 120_000, - retry: 0, - }); - - const parsed = AssetUploadResponseSchema.safeParse(await response.json()); - const jwt = parsed.success ? parsed.data.result?.jwt : null; - return jwt || null; -} - export async function finalizeDeployment( deploymentId: string, completionJwt: string | null, diff --git a/packages/cli/src/core/site/upload.ts b/packages/cli/src/core/site/upload.ts index 30393aad2..a45f5a031 100644 --- a/packages/cli/src/core/site/upload.ts +++ b/packages/cli/src/core/site/upload.ts @@ -1,8 +1,8 @@ import { readFile } from "node:fs/promises"; +import type { KyResponse } from "ky"; import ky, { HTTPError } from "ky"; import pMap from "p-map"; import { ApiError, InternalError } from "@/core/errors.js"; -import { uploadAssetBucket } from "./api.js"; import type { AssetFile, AssetManifestResult, @@ -10,6 +10,7 @@ import type { CfAssetUploads, PresignedAssetUpload, } from "./schema.js"; +import { AssetUploadResponseSchema } from "./schema.js"; export const DEFAULT_UPLOAD_CONCURRENCY = 3; @@ -18,16 +19,23 @@ export const MAX_UPLOAD_CONCURRENCY = 50; const MAX_UPLOAD_ATTEMPTS = 3; const RETRY_BASE_DELAY_MS = 500; -// A 429 from the upload endpoint is a pause, not a failure — wait out the -// window and go again, without burning the regular error-retry attempts. -const MAX_RATE_LIMIT_WAITS = 10; -const RATE_LIMIT_DELAY_MS = 15_000; + +/** + * Retry policy shared by both upload arms. ky retries network errors and its + * default status codes only (408/413/429/500/502/503/504), which is exactly + * what these uploads want: an expired credential (401/403) fails fast instead + * of burning every attempt, and a 429 waits out the server's `Retry-After` + * rather than a delay we invented. + */ +const UPLOAD_RETRY = { + limit: MAX_UPLOAD_ATTEMPTS - 1, + delay: (attempt: number) => RETRY_BASE_DELAY_MS * 2 ** (attempt - 1), +} as const; /** * POST the requested asset buckets directly to Cloudflare, authorized by the - * upload-session jwt from create. Each bucket is retried up to 3 times with - * exponential backoff. The final response carries the completion JWT required - * to finalize. + * upload-session jwt from create. The final response carries the completion + * JWT required to finalize. */ export async function uploadAssetBuckets( target: CfAssetUploads, @@ -46,7 +54,7 @@ export async function uploadAssetBuckets( await pMap( buckets, async (bucket) => { - const jwt = await uploadBucketWithRetry(target, bucket, filesByHash); + const jwt = await uploadAssetBucket(target, bucket, filesByHash); if (jwt) { completionJwt = jwt; } @@ -65,49 +73,42 @@ export async function uploadAssetBuckets( return completionJwt; } -async function uploadBucketWithRetry( +async function uploadAssetBucket( target: CfAssetUploads, bucket: string[], filesByHash: Map, ): Promise { - let lastError: unknown; - let rateLimitWaits = 0; const formData = await buildBucketForm(bucket, filesByHash); - for (let attempt = 0; attempt < MAX_UPLOAD_ATTEMPTS; attempt++) { - if (attempt > 0) { - await sleep(RETRY_BASE_DELAY_MS * 2 ** (attempt - 1)); - } - try { - return await uploadAssetBucket(target, formData); - } catch (error) { - if ( - error instanceof HTTPError && - error.response.status === 429 && - rateLimitWaits < MAX_RATE_LIMIT_WAITS - ) { - rateLimitWaits++; - attempt--; // a throttle is not a failed attempt - await sleep(RATE_LIMIT_DELAY_MS); - continue; - } - lastError = error; + let response: KyResponse; + try { + // Straight to Cloudflare: the upload-session jwt is the credential, so this + // never goes through the app client (and must not carry app auth). + response = await ky.post(target.url, { + searchParams: { base64: "true" }, + headers: { Authorization: `Bearer ${target.jwt}` }, + body: formData, + timeout: 120_000, + // POST is absent from ky's default retry methods, so it must be named + // explicitly or these uploads would never retry at all. + retry: { ...UPLOAD_RETRY, methods: ["post"] }, + }); + } catch (error) { + if ( + error instanceof HTTPError && + (error.response.status === 401 || error.response.status === 403) + ) { + throw new ApiError( + "This deploy's upload session has expired — rerun deploy. Already-uploaded assets are skipped on the next attempt.", + { statusCode: error.response.status, cause: error }, + ); } + throw await ApiError.fromHttpError(error, "uploading assets to Cloudflare"); } - if ( - lastError instanceof HTTPError && - (lastError.response.status === 401 || lastError.response.status === 403) - ) { - throw new ApiError( - "This deploy's upload session has expired — rerun deploy. Already-uploaded assets are skipped on the next attempt.", - { statusCode: lastError.response.status, cause: lastError }, - ); - } - throw await ApiError.fromHttpError( - lastError, - "uploading assets to Cloudflare", - ); + const parsed = AssetUploadResponseSchema.safeParse(await response.json()); + const jwt = parsed.success ? parsed.data.result?.jwt : null; + return jwt || null; } async function buildBucketForm( @@ -181,18 +182,10 @@ async function uploadPresignedAsset( // our own value would 403 on any mapping difference. headers: { "Content-Type": upload.contentType }, timeout: 120_000, - // ky retries network errors and 408/429/5xx only, so a 403 from an - // expired URL fails fast instead of burning every attempt. - retry: { - limit: MAX_UPLOAD_ATTEMPTS - 1, - delay: (attempt) => RETRY_BASE_DELAY_MS * 2 ** (attempt - 1), - }, + // PUT is already a default retry method. + retry: UPLOAD_RETRY, }); } catch (error) { throw await ApiError.fromHttpError(error, "uploading static assets"); } } - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} diff --git a/packages/cli/tests/cli/fullstack_deploy.spec.ts b/packages/cli/tests/cli/fullstack_deploy.spec.ts index 650debf50..12aab751c 100644 --- a/packages/cli/tests/cli/fullstack_deploy.spec.ts +++ b/packages/cli/tests/cli/fullstack_deploy.spec.ts @@ -221,6 +221,44 @@ describe("deploy command (full-stack)", () => { expect(body.config.compatibility_flags).toEqual([]); }); + it("retries a bucket upload after a transient failure, resending the body", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + mockResourcePushes(); + const htmlHash = assetHash(t.api.appId, INDEX_HTML); + t.api.mockDeploymentCreate({ + deployment_id: DEPLOYMENT_ID, + asset_uploads: { + type: "cf", + url: `${t.api.baseUrl}/cf-assets/upload`, + jwt: "upload-session-jwt", + buckets: [[htmlHash]], + }, + }); + // 503 twice, then succeed — the bucket POST must be retried, which only + // happens because "post" is named in the retry methods. + t.api.mockAssetUploadAfterFailures(2, "completion-jwt"); + t.api.mockDeploymentFinalize({ deployment_id: DEPLOYMENT_ID }); + + const result = await t.run("deploy", "-y", "--git-hash", GIT_HASH); + + t.expectResult(result).toSucceed(); + expect(t.api.assetUploadRequests).toHaveLength(3); + // Every attempt carried the full multipart body: ky keeps a pristine + // request to clone from, so the FormData is not consumed by attempt one. + for (const upload of t.api.assetUploadRequests) { + expect(upload.authorization).toBe("Bearer upload-session-jwt"); + const field = upload.fields.find((f) => f.name === htmlHash); + expect( + Buffer.from(field?.data.toString() ?? "", "base64").toString(), + ).toBe(INDEX_HTML); + } + // The completion token from the successful attempt reaches finalize. + const payload = t.api.finalizeRequests[0].find((f) => f.name === "payload"); + expect(JSON.parse(payload?.data.toString() ?? "{}")).toEqual({ + completion_jwt: "completion-jwt", + }); + }, 20_000); + it("surfaces a session-expired error when Cloudflare rejects the session jwt", async () => { await t.givenLoggedInWithProject(fixture("fullstack-project")); mockResourcePushes(); diff --git a/packages/cli/tests/cli/testkit/TestAPIServer.ts b/packages/cli/tests/cli/testkit/TestAPIServer.ts index 8d10f02a1..d921d91fc 100644 --- a/packages/cli/tests/cli/testkit/TestAPIServer.ts +++ b/packages/cli/tests/cli/testkit/TestAPIServer.ts @@ -697,6 +697,17 @@ export class TestAPIServer { * (Cloudflare's reply shape). */ mockAssetUpload(completionJwt: string): this { + return this.mockAssetUploadAfterFailures(0, completionJwt); + } + + /** + * Same target as {@link mockAssetUpload}, but the first `failures` requests + * answer 503 before it starts succeeding — every attempt is still recorded in + * `assetUploadRequests`, so a spec can assert the upload was retried and that + * the retried request carried the same body. + */ + mockAssetUploadAfterFailures(failures: number, completionJwt: string): this { + let seen = 0; this.pendingRoutes.push({ method: "POST", path: "/cf-assets/upload", @@ -709,6 +720,11 @@ export class TestAPIServer { req.headers["content-type"] ?? "", ), }); + seen++; + if (seen <= failures) { + res.status(503).json({ error: "Service Unavailable" }); + return; + } res.status(201).json({ result: { jwt: completionJwt } }); }, }); From 6f5ac21a62a0ffeedca00d7c3eaee8bc9a9a0ff5 Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Tue, 4 Aug 2026 12:43:39 +0300 Subject: [PATCH 5/7] refactor(site): trim the deploy module's comments; fix the cf token comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comment and docstring cleanup across the site deploy module — the prose was carrying its own weight badly: restating what the next line of code already said, re-explaining the same protocol fact at every call site, and narrating obvious parameters. Net -108 lines with no behavior change. Also corrects one comment that was actively wrong. CfAssetUploads said the *last bucket's* reply carries the completion token. Verified against Cloudflare's direct-upload docs and wrangler's syncAssets(): the server decides completeness by manifest membership ("once every file in the manifest has been uploaded"), so the token goes to whichever request completes the set. Buckets upload concurrently, so that is usually not buckets[n-1] — indexing the final bucket would read an empty result and discard a token already in hand. The implementation was already right; only the comment lied. typecheck, lint, and knip clean; full suite green (714 tests, 71 files). Co-Authored-By: Claude Opus 5 (1M context) --- .../cli/src/cli/commands/project/deploy.ts | 14 ++--- .../src/cli/commands/site/deploy-options.ts | 5 +- packages/cli/src/cli/commands/site/deploy.ts | 6 +-- .../src/cli/commands/site/run-app-deploy.ts | 3 +- packages/cli/src/core/project/deploy.ts | 9 ++-- packages/cli/src/core/site/api.ts | 6 +-- packages/cli/src/core/site/deploy-app.ts | 24 +++------ packages/cli/src/core/site/full-stack.ts | 24 ++++----- packages/cli/src/core/site/git-hash.ts | 6 +-- packages/cli/src/core/site/manifest.ts | 13 ++--- packages/cli/src/core/site/modules.ts | 7 +-- packages/cli/src/core/site/schema.ts | 51 ++++++------------- packages/cli/src/core/site/static-site.ts | 23 +++------ packages/cli/src/core/site/upload.ts | 26 ++++------ packages/cli/src/core/site/wrangler-config.ts | 32 ++++-------- .../cli/tests/cli/fullstack_deploy.spec.ts | 29 +++-------- packages/cli/tests/cli/site_deploy.spec.ts | 2 +- .../tests/cli/static_site_deployments.spec.ts | 16 +----- .../cli/tests/cli/testkit/TestAPIServer.ts | 19 +++---- .../tests/core/site-wrangler-config.spec.ts | 3 +- 20 files changed, 105 insertions(+), 213 deletions(-) diff --git a/packages/cli/src/cli/commands/project/deploy.ts b/packages/cli/src/cli/commands/project/deploy.ts index 5469da426..7c773fc7d 100644 --- a/packages/cli/src/cli/commands/project/deploy.ts +++ b/packages/cli/src/cli/commands/project/deploy.ts @@ -49,9 +49,9 @@ export async function deployAction( const { project, entities, functions, agents, connectors, authConfig } = projectData; - // Best-effort pre-build look at what the site step would ship, for the - // summary and the no-resources check. The build below can change the - // answer, so the deploy itself decides again. + // Pre-build look at what the site step would ship, for the summary and the + // no-resources check. The build below can change the answer, so the deploy + // decides again for itself. const plannedSite = await detectAppDeployKind(project); if (!hasResourcesToDeploy(projectData) && plannedSite === "none") { @@ -113,8 +113,8 @@ export async function deployAction( await maybeBuildBeforeDeploy(ctx, project, options.build); - // Deploy resources with per-function progress. The site ships below, - // from whatever the build produced. + // Deploy resources with per-function progress; the site ships below, from + // whatever the build produced. let functionCompleted = 0; const functionTotal = functions.length; @@ -186,8 +186,8 @@ function printDeploymentSummary( deployment: { deploymentId: string; gitHash: string }, log: Logger, ): void { - // A build has no URL of its own: what production serves is decided when the - // app is published from the builder, not by this deploy. + // No URL: what production serves is decided when the app is published from + // the builder, not by this deploy. log.message( `${theme.styles.header("Deployment")}: ${deployment.deploymentId} ${theme.styles.dim(`(commit ${deployment.gitHash.slice(0, 12)})`)}`, ); diff --git a/packages/cli/src/cli/commands/site/deploy-options.ts b/packages/cli/src/cli/commands/site/deploy-options.ts index 102c8e21b..4daa3ba42 100644 --- a/packages/cli/src/cli/commands/site/deploy-options.ts +++ b/packages/cli/src/cli/commands/site/deploy-options.ts @@ -6,9 +6,8 @@ import { import { isGitCommitHash } from "@/core/utils/git.js"; /** - * The deployment flags shared by `base44 deploy` and `base44 site deploy`. - * Both ship the project's built output through the same path, so the commit - * address and the upload concurrency have to mean the same thing on each. + * Shared by `base44 deploy` and `base44 site deploy`: both ship the built output + * through the same path, so these have to mean the same thing on each. */ export function addDeploymentOptions< T extends { addOption: (option: Option) => T }, diff --git a/packages/cli/src/cli/commands/site/deploy.ts b/packages/cli/src/cli/commands/site/deploy.ts index 7f0db973e..125419097 100644 --- a/packages/cli/src/cli/commands/site/deploy.ts +++ b/packages/cli/src/cli/commands/site/deploy.ts @@ -27,8 +27,6 @@ async function deployAction( const { project } = await readProjectConfig(); - // A full-stack build artifact ships as a Workers deployment; without one - // the site is the configured output directory. const kind = await detectAppDeployKind(project); if (kind === "none") { @@ -67,8 +65,8 @@ async function deployAction( }); if (result.kind === "full-stack" || result.kind === "static-deployment") { - // A build has no URL of its own: what production serves is decided when - // the app is published from the builder, not by this deploy. + // No URL: what production serves is decided when the app is published from + // the builder, not by this deploy. return { outroMessage: `Deployment ${result.deploymentId} (commit ${result.gitHash.slice(0, 12)})`, stdout: ctx.jsonMode diff --git a/packages/cli/src/cli/commands/site/run-app-deploy.ts b/packages/cli/src/cli/commands/site/run-app-deploy.ts index 5dcf4bace..2cd553aff 100644 --- a/packages/cli/src/cli/commands/site/run-app-deploy.ts +++ b/packages/cli/src/cli/commands/site/run-app-deploy.ts @@ -23,8 +23,7 @@ const TASK_LABELS = { /** * Run the project's site deploy behind a spinner, adapting the labels and the - * progress stream to whichever transport applies. The kind is detected here - * only to pick the messages; `deployAppSite` decides for itself what to ship. + * progress stream to whichever transport applies. */ export async function runAppSiteDeploy( { runTask, log }: CLIContext, diff --git a/packages/cli/src/core/project/deploy.ts b/packages/cli/src/core/project/deploy.ts index 58a55000a..6dd659c1c 100644 --- a/packages/cli/src/core/project/deploy.ts +++ b/packages/cli/src/core/project/deploy.ts @@ -33,8 +33,8 @@ export function hasResourcesToDeploy(projectData: ProjectData): boolean { connectors, authConfig, } = projectData; - // A build command counts: a full-stack project may configure nothing but - // the build, and a generated artifact won't be on disk until it has run. + // A build command counts: a full-stack project may configure nothing else, + // and its artifact is not on disk until the build has run. const hasSite = Boolean( project.site?.outputDirectory || project.site?.buildCommand, ); @@ -76,9 +76,8 @@ interface DeployAllOptions { onFunctionStart?: (names: string[]) => void; onFunctionResult?: (result: SingleFunctionDeployResult) => void; /** - * Deploy the legacy static site (tar.gz upload) when configured. - * The unified deploy command passes false and handles the site itself, - * so full-stack (Workers) projects can take the deployments path instead. + * Deploy the legacy static site (tar.gz upload) when configured. The unified + * deploy command passes false and handles the site itself. * @default true */ site?: boolean; diff --git a/packages/cli/src/core/site/api.ts b/packages/cli/src/core/site/api.ts index 9861627d1..7a396d806 100644 --- a/packages/cli/src/core/site/api.ts +++ b/packages/cli/src/core/site/api.ts @@ -109,10 +109,8 @@ export async function finalizeDeployment( } /** - * Finalize a static-site (s3-target) deployment. The form carries exactly one - * file part — `index.html` — and nothing else (no `payload`, no modules): - * index.html is always excluded from the presigned uploads and travels - * through finalize as the sentinel that completes the deployment. + * The form carries exactly one file part — `index.html`, the sentinel that + * completes the deployment — and nothing else. */ export async function finalizeStaticDeployment( deploymentId: string, diff --git a/packages/cli/src/core/site/deploy-app.ts b/packages/cli/src/core/site/deploy-app.ts index ddf542e7c..3925a2f19 100644 --- a/packages/cli/src/core/site/deploy-app.ts +++ b/packages/cli/src/core/site/deploy-app.ts @@ -6,13 +6,11 @@ import type { DeploymentProgress } from "./schema.js"; import { deployStaticSite, staticDeploymentsEnabled } from "./static-site.js"; import { detectFullStackArtifact } from "./wrangler-config.js"; -/** The project fields an app deploy reads. */ export interface AppSiteTarget { root: string; site?: { outputDirectory?: string }; } -/** Which transport ships this project's built output. */ type AppDeployKind = "full-stack" | "static-deployment" | "static" | "none"; export type AppDeployResult = @@ -28,10 +26,9 @@ type AppDeployPlan = | { kind: "none" }; /** - * A full-stack (Workers) artifact wins over the static output directory: it - * carries the server too, so shipping the static output instead would - * silently drop the worker. A static output ships through the deployments - * API when the lane is enabled, and as the legacy tar.gz upload otherwise. + * A full-stack artifact wins over the static output directory: it carries the + * server too, so shipping the static output instead would silently drop the + * worker. */ async function planAppDeploy(target: AppSiteTarget): Promise { if (await detectFullStackArtifact(target.root)) { @@ -47,11 +44,7 @@ async function planAppDeploy(target: AppSiteTarget): Promise { : { kind: "static", outputDir }; } -/** - * How the project's built output would ship right now. The full-stack - * artifact is itself a build output, so this only answers for the current - * state of the tree — call it again after any build step. - */ +/** How the built output would ship right now — a build step invalidates it. */ export async function detectAppDeployKind( target: AppSiteTarget, ): Promise { @@ -59,11 +52,10 @@ export async function detectAppDeployKind( } /** - * Deploy the project's built output over whichever transport applies — - * a Workers deployment addressed by commit for full-stack builds, a - * deployments-API static deployment when the lane is enabled, the legacy - * tar.gz upload otherwise. Returns `{ kind: "none" }` when the project has - * nothing to ship. + * Deploy the project's built output over whichever transport applies — a + * Workers deployment addressed by commit for full-stack builds, a + * deployments-API static deployment when the lane is enabled, the legacy tar.gz + * upload otherwise. */ export async function deployAppSite( target: AppSiteTarget, diff --git a/packages/cli/src/core/site/full-stack.ts b/packages/cli/src/core/site/full-stack.ts index 69f44f88e..98ae8056e 100644 --- a/packages/cli/src/core/site/full-stack.ts +++ b/packages/cli/src/core/site/full-stack.ts @@ -20,7 +20,7 @@ interface FullStackDeployResult { * buckets directly to Cloudflare, then finalize with the worker modules. * * Builds only — nothing here publishes. What production serves is decided by - * the platform publish flow, and re-deploying the same commit is idempotent. + * the platform publish flow. */ export async function deployFullStack(options: { projectRoot: string; @@ -32,18 +32,16 @@ export async function deployFullStack(options: { const config = await resolveWranglerConfig(projectRoot); - // Some frameworks (e.g. Astro 6) emit a wrangler.json without any - // compatibility flags; server code using Node built-ins would then fail at - // runtime. Warn instead of injecting the flag — the config is generated, so - // the fix belongs in the framework/adapter settings. + // Warn rather than inject the flag: the config is generated, so the fix + // belongs in the framework's adapter settings. if (!config.compatibilityFlags.includes("nodejs_compat")) { progress?.onWarning?.( "The wrangler config has no 'nodejs_compat' compatibility flag; Node.js built-ins will be unavailable at runtime. Enable it in your framework's Cloudflare adapter settings if your server code needs Node APIs.", ); } - // A worker's environment is the app's secrets and built-ins — a deploy can't - // introduce env of its own, so wrangler `vars` never reach the worker. + // A deploy can't introduce env of its own, so wrangler `vars` never reach + // the worker. if (config.vars && Object.keys(config.vars).length > 0) { progress?.onWarning?.( "wrangler 'vars' are not supported and were ignored — a worker's environment comes from the app's secrets (base44 secrets set).", @@ -82,9 +80,8 @@ export async function deployFullStack(options: { : 0; progress?.onAssets?.({ totalAssets, newAssets }); - // No uploads owed means every asset is already stored (or there are none): - // the server holds the token that completes the asset set, so the - // completion JWT stays null. + // Nothing owed means the server already holds the token that completes the + // asset set, so the completion JWT stays null. const completionJwt = created.assetUploads ? await uploadAssetBuckets(created.assetUploads, assets.filesByHash, { concurrency, @@ -103,10 +100,9 @@ export async function deployFullStack(options: { } /** - * The subset of the wrangler assets config the deployments API accepts. - * `_headers`/`_redirects` contents and `run_worker_first` route arrays have no - * server-side support yet — dropping them silently would change runtime - * behavior, so each drop is surfaced as a warning. + * The subset of the wrangler assets config the deployments API accepts. The + * unsupported fields would change runtime behavior if dropped silently, so each + * drop is surfaced as a warning. */ function buildAssetsConfig( assetsConfig: { diff --git a/packages/cli/src/core/site/git-hash.ts b/packages/cli/src/core/site/git-hash.ts index f44842f2c..0b858e235 100644 --- a/packages/cli/src/core/site/git-hash.ts +++ b/packages/cli/src/core/site/git-hash.ts @@ -2,11 +2,7 @@ import { execa } from "execa"; import { InvalidInputError } from "@/core/errors.js"; import { isGitCommitHash } from "@/core/utils/git.js"; -/** - * The commit this build came from — a deployment is addressed by it, so the - * hash is required. An explicit hash (flag/automation) wins; otherwise it - * comes from the git checkout at the project root. - */ +/** An explicit hash (flag/automation) wins over the checkout's HEAD. */ export async function resolveGitHash( projectRoot: string, explicit?: string, diff --git a/packages/cli/src/core/site/manifest.ts b/packages/cli/src/core/site/manifest.ts index 34d32140a..b5cfebdb3 100644 --- a/packages/cli/src/core/site/manifest.ts +++ b/packages/cli/src/core/site/manifest.ts @@ -52,11 +52,7 @@ const MIME_TYPES: Record = { ".webmanifest": "application/manifest+json", }; -/** - * Content type for a cf-arm multipart upload part. The s3 arm never uses - * this — there the server signs each Content-Type into the presigned URL - * and the CLI echoes it verbatim. - */ +/** Only the cf arm reads this; the s3 arm echoes the signed Content-Type. */ function getAssetContentType(filePath: string): string { return ( MIME_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream" @@ -64,10 +60,9 @@ function getAssetContentType(filePath: string): string { } /** - * Content-addressed asset hash: first 32 hex chars of - * sha256(utf8(app_id) || raw file bytes). Salting with the app id means a - * tenant can only produce hash collisions with their own files, so a - * malicious upload cannot poison another app's asset cache. + * First 32 hex chars of sha256(utf8(app_id) || raw file bytes). The app-id salt + * means a tenant can only collide with their own files, so a malicious upload + * cannot poison another app's asset cache. */ export function hashAsset(appId: string, content: Buffer): string { return createHash("sha256") diff --git a/packages/cli/src/core/site/modules.ts b/packages/cli/src/core/site/modules.ts index d7e4c2ae7..d55d1eb54 100644 --- a/packages/cli/src/core/site/modules.ts +++ b/packages/cli/src/core/site/modules.ts @@ -26,9 +26,8 @@ function toPosix(path: string): string { /** * Collect the worker modules for an unbundled (no_bundle) build: the entry * module plus every file under the config dir matching the config's rules - * globs, preserving relative paths as module names. `.map` files next to - * collected modules (or all of them when `upload_source_maps` is set) are - * included as sourcemaps. Total payload is capped at 40 MB. + * globs, keyed by relative path. `.map` files next to collected modules (or all + * of them when `upload_source_maps` is set) ride along as sourcemaps. */ export async function collectModules( config: ResolvedWranglerConfig, @@ -86,8 +85,6 @@ export async function collectModules( } } - // Source maps: all of them when upload_source_maps is set, otherwise only - // the ones sitting next to a collected module. if (config.uploadSourceMaps) { const maps = await globby("**/*.map", { cwd: config.configDir, diff --git a/packages/cli/src/core/site/schema.ts b/packages/cli/src/core/site/schema.ts index 72f51c303..56ec976e4 100644 --- a/packages/cli/src/core/site/schema.ts +++ b/packages/cli/src/core/site/schema.ts @@ -17,16 +17,11 @@ export const PublishedUrlResponseSchema = z.object({ url: z.string(), }); -// ─── SHARED ────────────────────────────────────────────────── - -/** Worker module types accepted by the deployments API. */ export type ModuleType = "esm" | "sourcemap" | "wasm" | "text" | "data"; -/** A collected worker module (bytes are read lazily at finalize time). */ export interface WorkerModule { - /** Module name: path relative to the wrangler config dir (forward slashes). */ + /** Path relative to the wrangler config dir, forward slashes. */ name: string; - /** Absolute path on disk. */ absolutePath: string; size: number; type: ModuleType; @@ -38,9 +33,7 @@ export interface AssetManifestEntry { size: number; } -/** A static asset discovered in the assets directory, keyed by hash. */ export interface AssetFile { - /** Absolute path on disk. */ absolutePath: string; hash: string; size: number; @@ -48,35 +41,26 @@ export interface AssetFile { } export interface AssetManifestResult { - /** URL path → { hash, size }, ready for the create-deployment payload. */ manifest: Record; - /** Hash → file info, used to serve upload buckets. */ filesByHash: Map; } -/** Progress of an in-flight asset upload set. */ export interface AssetUploadProgress { uploadedFiles: number; totalFiles: number; } -/** Progress callbacks a deploy fires as it moves through its stages. */ export interface DeploymentProgress { - /** Fired for non-fatal issues worth surfacing to the user. */ onWarning?: (message: string) => void; - /** Fired after the deployment is created: total assets and how many need uploading. */ onAssets?: (info: { totalAssets: number; newAssets: number }) => void; - /** Fired after each asset upload completes. */ onAssetUpload?: (progress: AssetUploadProgress) => void; - /** Fired before the worker modules are uploaded (finalize). */ onWorker?: (info: { moduleCount: number }) => void; } /** * Request payload for POST deployments (sent as snake_case JSON). `config` is * what selects the deploy target server-side: a worker config means a - * Cloudflare deployment; a request with no `config` field at all is a - * static-site deployment. + * Cloudflare deployment, no `config` field at all a static-site deployment. */ export interface CreateDeploymentRequest { git_hash: string; @@ -93,26 +77,26 @@ export interface CreateDeploymentRequest { asset_manifest: Record; } -// ─── RESPONSES ─────────────────────────────────────────────── - -/** A static asset the server wants uploaded, with its presigned S3 URL. */ export interface PresignedAssetUpload { - /** Manifest path of the asset ("/assets/app.js"). */ path: string; /** Content-Type signed into the URL — the PUT must send it verbatim. */ contentType: string; - /** Byte count signed into the URL — the PUT body must be exactly this long. */ contentLength: number; /** Presigned S3 URL — the URL itself is the credential. */ url: string; } -/** The `cf` arm's upload target: asset buckets POSTed directly to Cloudflare, - * authorized by the upload-session token. The last bucket's reply carries the - * completion token finalize wants back. */ +/** + * The `cf` arm's upload target. Exactly one bucket reply carries the completion + * token finalize wants back: the server decides completeness by manifest + * membership ("every file in the manifest has been uploaded"), so the token + * goes to whichever request completes the set — NOT to the last bucket in this + * array. Buckets upload concurrently, so read the token opportunistically from + * whichever reply carries one; indexing the final bucket would usually read an + * empty result and throw away a token already in hand. + */ export interface CfAssetUploads { type: "cf"; - /** Cloudflare's assets upload endpoint. */ url: string; /** Upload-session token — sent as `Authorization: Bearer`. */ jwt: string; @@ -126,11 +110,9 @@ interface S3AssetUploads { } /** - * POST deployments answers `{deployment_id, asset_uploads}` where - * `asset_uploads` says where the assets still owed should go — `cf` (direct - * bucket POSTs to Cloudflare, when the request carried a worker `config`) or - * `s3` (direct presigned PUTs, when it carried none) — and is null when - * nothing is owed (no assets, or the build already exists). + * `asset_uploads` says where the assets still owed should go, discriminated on + * `type` — `cf` when the request carried a worker config, `s3` when it carried + * none — and is null when nothing is owed. */ export const CreateDeploymentResponseSchema = z .object({ @@ -188,9 +170,8 @@ export type CreateDeploymentResponse = z.infer< >; /** - * Response of an asset bucket upload — Cloudflare's reply, relayed verbatim - * by the backend. Only the final response carries the completion token, so - * everything is optional here. + * Cloudflare's reply to a bucket upload, relayed verbatim by the backend. Only + * the reply that completes the asset set carries a token, hence all-optional. */ export const AssetUploadResponseSchema = z.looseObject({ result: z diff --git a/packages/cli/src/core/site/static-site.ts b/packages/cli/src/core/site/static-site.ts index 03f8ef8a0..4ef808fa0 100644 --- a/packages/cli/src/core/site/static-site.ts +++ b/packages/cli/src/core/site/static-site.ts @@ -8,10 +8,9 @@ import type { DeploymentProgress } from "./schema.js"; import { uploadPresignedAssets } from "./upload.js"; /** - * Internal gate for the experimental static-site deployments-API lane. Not - * user-facing yet: when set to "1" or "true", `base44 deploy` sends the - * configured site output through the deployments API instead of the legacy - * tar.gz site upload. + * Internal gate for the experimental static-site deployments-API lane, not + * user-facing yet: with it off, a static output keeps taking the legacy tar.gz + * upload. */ const STATIC_DEPLOYMENTS_ENV = "BASE44_STATIC_DEPLOYMENTS"; @@ -24,14 +23,9 @@ export function staticDeploymentsEnabled( /** * Deploy a static site build through the deployments API: hash the output - * directory into an asset manifest and create the deployment at the commit's - * address with no worker config — which the server answers with the `s3` arm - * of the discriminated create response — then PUT the requested files - * directly to their presigned URLs and finalize with the index.html bytes. - * - * This is the progressive-upgrade path: when the app later adopts a server - * framework, the create request carries its worker config and the server - * answers with the `cf` arm instead — same CLI protocol, zero CLI change. + * directory into an asset manifest, create the deployment at the commit's + * address with no worker config, PUT the requested files to their presigned + * URLs, and finalize with the index.html bytes. */ export async function deployStaticSite(options: { outputDir: string; @@ -60,11 +54,8 @@ export async function deployStaticSite(options: { ); } - // The uploads always exclude index.html; null means every asset is already - // stored (re-deploying a commit is idempotent). - const totalAssets = Object.keys(assets.manifest).length; progress?.onAssets?.({ - totalAssets, + totalAssets: Object.keys(assets.manifest).length, newAssets: created.assetUploads?.uploads.length ?? 0, }); diff --git a/packages/cli/src/core/site/upload.ts b/packages/cli/src/core/site/upload.ts index a45f5a031..6c14d6971 100644 --- a/packages/cli/src/core/site/upload.ts +++ b/packages/cli/src/core/site/upload.ts @@ -21,11 +21,9 @@ const MAX_UPLOAD_ATTEMPTS = 3; const RETRY_BASE_DELAY_MS = 500; /** - * Retry policy shared by both upload arms. ky retries network errors and its - * default status codes only (408/413/429/500/502/503/504), which is exactly - * what these uploads want: an expired credential (401/403) fails fast instead - * of burning every attempt, and a 429 waits out the server's `Retry-After` - * rather than a delay we invented. + * Shared by both upload arms. ky's default status codes are exactly what these + * uploads want: an expired credential (401/403) fails fast instead of burning + * every attempt, and a 429 waits out the server's `Retry-After`. */ const UPLOAD_RETRY = { limit: MAX_UPLOAD_ATTEMPTS - 1, @@ -34,8 +32,7 @@ const UPLOAD_RETRY = { /** * POST the requested asset buckets directly to Cloudflare, authorized by the - * upload-session jwt from create. The final response carries the completion - * JWT required to finalize. + * upload-session jwt from create, and return the completion JWT finalize needs. */ export async function uploadAssetBuckets( target: CfAssetUploads, @@ -82,15 +79,15 @@ async function uploadAssetBucket( let response: KyResponse; try { - // Straight to Cloudflare: the upload-session jwt is the credential, so this - // never goes through the app client (and must not carry app auth). + // Straight to Cloudflare under the upload-session jwt — never the app + // client, and never app auth. response = await ky.post(target.url, { searchParams: { base64: "true" }, headers: { Authorization: `Bearer ${target.jwt}` }, body: formData, timeout: 120_000, - // POST is absent from ky's default retry methods, so it must be named - // explicitly or these uploads would never retry at all. + // POST is absent from ky's default retry methods, so naming it is what + // makes these uploads retry at all. retry: { ...UPLOAD_RETRY, methods: ["post"] }, }); } catch (error) { @@ -135,10 +132,9 @@ async function buildBucketForm( } /** - * PUT static assets directly to their presigned S3 URLs (the `s3` create - * arm). A presigned URL carries its own authorization in the query string, so - * each request is a plain fetch — never the app client, never an - * Authorization header. + * PUT static assets directly to their presigned S3 URLs. A presigned URL + * carries its own authorization in the query string, so each request is a plain + * fetch — never the app client, never an Authorization header. */ export async function uploadPresignedAssets( uploads: PresignedAssetUpload[], diff --git a/packages/cli/src/core/site/wrangler-config.ts b/packages/cli/src/core/site/wrangler-config.ts index a7c4be4ae..dda3ae70f 100644 --- a/packages/cli/src/core/site/wrangler-config.ts +++ b/packages/cli/src/core/site/wrangler-config.ts @@ -6,15 +6,13 @@ import { pathExists, readJsonFile } from "@/core/utils/fs.js"; /** Redirect file emitted by @cloudflare/vite-plugin builds, at project root. */ const WRANGLER_REDIRECT_PATH = join(".wrangler", "deploy", "config.json"); -// Loose: extra fields emitted by framework adapters (auxiliaryWorkers, -// Astro 6's prerenderWorkerConfigPath, ...) are ignored for now. const RedirectConfigSchema = z.looseObject({ configPath: z.string().min(1), }); -// Only the fields a Base44 deploy acts on are declared. Everything else -// (bindings, worker name, ...) rides along in the loose passthrough and is -// ignored — the deploy neither forwards nor validates it. +// Only the fields a Base44 deploy acts on. Everything else (bindings, worker +// name, ...) rides along in the loose passthrough, neither forwarded nor +// validated. const WranglerConfigSchema = z.looseObject({ main: z.string().min(1, "wrangler config is missing a 'main' entry module"), no_bundle: z.boolean().optional(), @@ -53,13 +51,10 @@ export interface ResolvedAssetsConfig { } export interface ResolvedWranglerConfig { - /** Absolute path of the wrangler.json that was used. */ configPath: string; - /** Absolute directory containing the wrangler config; module paths are relative to it. */ + /** Module paths — `main` and the rules globs — are relative to this. */ configDir: string; - /** Entry module path, relative to configDir (as written in the config). */ main: string; - /** Absolute path of the static assets directory, or null when no assets. */ assetsDirectory: string | null; assetsConfig: ResolvedAssetsConfig | null; compatibilityDate: string | null; @@ -70,14 +65,13 @@ export interface ResolvedWranglerConfig { } /** - * Detect a full-stack (Cloudflare Workers) build artifact in the project: - * the redirect file emitted by @cloudflare/vite-plugin builds. Returns its - * absolute path, or null when the project has no full-stack artifact. + * Detect a full-stack (Cloudflare Workers) build artifact: the redirect file + * emitted by @cloudflare/vite-plugin builds. * * A hand-authored root wrangler config is deliberately not an artifact. Those - * are written for wrangler's own bundler, which this path never runs (see the - * no_bundle gate below), so detecting one would only hijack the deploy away - * from the static upload it was going to do. + * target wrangler's own bundler, which this path never runs (see the no_bundle + * gate below), so detecting one would hijack the deploy away from the static + * upload it was going to do. */ export async function detectFullStackArtifact( projectRoot: string, @@ -86,11 +80,7 @@ export async function detectFullStackArtifact( return (await pathExists(redirectPath)) ? redirectPath : null; } -/** - * Resolve and validate the wrangler config for a full-stack deploy. - * Throws with a clear message when there is no artifact, or when the build - * still requires bundling. - */ +/** Throws when there is no artifact, or when the build still needs bundling. */ export async function resolveWranglerConfig( projectRoot: string, ): Promise { @@ -158,7 +148,7 @@ async function resolveRedirectedConfigPath( ); } - // configPath is relative to the redirect file's directory (wrangler semantics). + // Relative to the redirect file's own directory (wrangler semantics). const configPath = resolve(dirname(redirectPath), result.data.configPath); if (!(await pathExists(configPath))) { throw new ConfigInvalidError( diff --git a/packages/cli/tests/cli/fullstack_deploy.spec.ts b/packages/cli/tests/cli/fullstack_deploy.spec.ts index 12aab751c..7abc5318b 100644 --- a/packages/cli/tests/cli/fullstack_deploy.spec.ts +++ b/packages/cli/tests/cli/fullstack_deploy.spec.ts @@ -4,7 +4,6 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { fixture, setupCLITests } from "./testkit/index.js"; -/** Same algorithm as core: first 32 hex chars of sha256(utf8(appId) || bytes). */ function assetHash(appId: string, content: string): string { return createHash("sha256") .update(Buffer.from(appId, "utf8")) @@ -16,7 +15,7 @@ function assetHash(appId: string, content: string): string { const INDEX_HTML = "

Hello

\n"; const APP_JS = 'console.log("app");\n'; -/** The commit the fixture "build" came from (the fixture is not a git repo). */ +/** The fixture is not a git repo, so every deploy passes --git-hash. */ const GIT_HASH = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"; const DEPLOYMENT_ID = "test-app-git-a1b2c3d4e5f6"; @@ -34,7 +33,6 @@ interface CreateBody { describe("deploy command (full-stack)", () => { const t = setupCLITests(); - /** Mocks hit by the unified deploy's resource-push phase (no resources). */ function mockResourcePushes() { t.api.mockAgentsPush({ created: [], updated: [], deleted: [] }); t.api.mockConnectorsList({ integrations: [] }); @@ -70,30 +68,24 @@ describe("deploy command (full-stack)", () => { t.expectResult(result).toContain("Full-stack app deployed"); t.expectResult(result).toContain(`Deployment: ${DEPLOYMENT_ID}`); - // Create request: the commit address + manifest (salted hashes) expect(t.api.deploymentCreateRequests).toHaveLength(1); const body = t.api.deploymentCreateRequests[0] as CreateBody; expect(body.git_hash).toBe(GIT_HASH); expect(body.config.main).toBe("index.js"); expect(body.config.compatibility_date).toBe("2025-04-01"); expect(body.config.compatibility_flags).toEqual(["nodejs_compat"]); - // vars / modules metadata are deliberately not part of the payload expect(body).not.toHaveProperty("modules"); expect(body.config).not.toHaveProperty("vars"); expect(body.asset_manifest).toEqual({ "/index.html": { hash: htmlHash, size: INDEX_HTML.length }, "/assets/app-123.js": { hash: jsHash, size: APP_JS.length }, }); - // .assetsignore honored: ignored.txt and .assetsignore itself excluded expect(Object.keys(body.asset_manifest)).not.toContain("/ignored.txt"); expect(Object.keys(body.asset_manifest)).not.toContain("/.assetsignore"); - // The fixture's wrangler config carries vars — surfaced, not sent + // The fixture's wrangler config carries vars — surfaced, not sent. t.expectResult(result).toContain("wrangler 'vars' are not supported"); - // Direct bucket uploads: two buckets, base64 form fields named by hash, - // POSTed straight to the given URL under the upload-session jwt (never - // the app's own auth). expect(t.api.assetUploadRequests).toHaveLength(2); for (const upload of t.api.assetUploadRequests) { expect(upload.authorization).toBe("Bearer upload-session-jwt"); @@ -115,7 +107,6 @@ describe("deploy command (full-stack)", () => { /^text\/html(;\s*charset=utf-8)?$/i, ); - // Finalize: payload carries the completion jwt + one field per module expect(t.api.finalizeRequests).toHaveLength(1); const finalizeFields = t.api.finalizeRequests[0]; const payloadField = finalizeFields.find((f) => f.name === "payload"); @@ -149,8 +140,6 @@ describe("deploy command (full-stack)", () => { const result = await t.run("deploy", "-y", "--git-hash", GIT_HASH); t.expectResult(result).toSucceed(); - // Nothing owed: nothing to upload — the server holds the session token - // that completes the asset set, so the client sends null. expect(t.api.assetUploadRequests).toHaveLength(0); const payloadField = t.api.finalizeRequests[0].find( (f) => f.name === "payload", @@ -164,14 +153,12 @@ describe("deploy command (full-stack)", () => { await t.givenLoggedInWithProject(fixture("fullstack-project")); mockResourcePushes(); - // The fixture is not a git checkout, so a deploy without --git-hash has - // no commit to address the deployment by. const noHash = await t.run("deploy", "-y"); t.expectResult(noHash).toFail(); t.expectResult(noHash).toContain("--git-hash"); - // A malformed hash is rejected by the option's argParser, before the - // action (and any resource push) runs. + // Rejected by the option's argParser, before the action (and any resource + // push) runs. const badHash = await t.run("deploy", "-y", "--git-hash", "not-a-hash"); t.expectResult(badHash).toFail(); t.expectResult(badHash).toContain("Expected a git commit hash"); @@ -200,7 +187,6 @@ describe("deploy command (full-stack)", () => { it("warns when the wrangler config lacks the nodejs_compat flag (e.g. Astro 6)", async () => { await t.givenLoggedInWithProject(fixture("fullstack-project")); - // Astro 6's generated wrangler.json can ship without compatibility flags. const configPath = join( t.getTempDir(), "project", @@ -234,8 +220,6 @@ describe("deploy command (full-stack)", () => { buckets: [[htmlHash]], }, }); - // 503 twice, then succeed — the bucket POST must be retried, which only - // happens because "post" is named in the retry methods. t.api.mockAssetUploadAfterFailures(2, "completion-jwt"); t.api.mockDeploymentFinalize({ deployment_id: DEPLOYMENT_ID }); @@ -243,8 +227,8 @@ describe("deploy command (full-stack)", () => { t.expectResult(result).toSucceed(); expect(t.api.assetUploadRequests).toHaveLength(3); - // Every attempt carried the full multipart body: ky keeps a pristine - // request to clone from, so the FormData is not consumed by attempt one. + // Every attempt carried the full body: ky clones a pristine request, so + // attempt one does not consume the FormData. for (const upload of t.api.assetUploadRequests) { expect(upload.authorization).toBe("Bearer upload-session-jwt"); const field = upload.fields.find((f) => f.name === htmlHash); @@ -252,7 +236,6 @@ describe("deploy command (full-stack)", () => { Buffer.from(field?.data.toString() ?? "", "base64").toString(), ).toBe(INDEX_HTML); } - // The completion token from the successful attempt reaches finalize. const payload = t.api.finalizeRequests[0].find((f) => f.name === "payload"); expect(JSON.parse(payload?.data.toString() ?? "{}")).toEqual({ completion_jwt: "completion-jwt", diff --git a/packages/cli/tests/cli/site_deploy.spec.ts b/packages/cli/tests/cli/site_deploy.spec.ts index c10ce2cfd..b6f5a1e0f 100644 --- a/packages/cli/tests/cli/site_deploy.spec.ts +++ b/packages/cli/tests/cli/site_deploy.spec.ts @@ -3,7 +3,7 @@ import { join } from "node:path"; import { describe, it } from "vitest"; import { fixture, setupCLITests } from "./testkit/index.js"; -/** The commit the fullstack fixture's "build" came from (not a git repo). */ +/** The fullstack fixture is not a git repo, so these deploys pass --git-hash. */ const GIT_HASH = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"; const DEPLOYMENT_ID = "test-app-git-a1b2c3d4e5f6"; diff --git a/packages/cli/tests/cli/static_site_deployments.spec.ts b/packages/cli/tests/cli/static_site_deployments.spec.ts index e788b69f7..bd8b7d35d 100644 --- a/packages/cli/tests/cli/static_site_deployments.spec.ts +++ b/packages/cli/tests/cli/static_site_deployments.spec.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { fixture, setupCLITests } from "./testkit/index.js"; -/** The commit the fixture "build" came from (the fixture is not a git repo). */ +/** The commit the fixture "build" came from. */ const GIT_HASH = "0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c"; const DEPLOYMENT_ID = "test-app-git-0f1e2d3c4b5a"; @@ -33,7 +33,6 @@ interface CreateBody { describe("deploy command (static site through the deployments API, env-gated)", () => { const t = setupCLITests(); - /** Mocks hit by the unified deploy's resource-push phase (no resources). */ function mockResourcePushes() { t.api.mockAgentsPush({ created: [], updated: [], deleted: [] }); t.api.mockConnectorsList({ integrations: [] }); @@ -51,8 +50,7 @@ describe("deploy command (static site through the deployments API, env-gated)", type: "s3" as const, uploads: uploadPaths.map((path) => ({ path, - // The server derives this server-side and signs it into the URL; the - // CLI must echo it verbatim rather than derive its own. + // Deliberately not what the CLI would derive: the signed value wins. content_type: `${SIGNED_CONTENT_TYPES[path]}; charset=utf-8`, content_length: FIXTURE_SIZES[path], url: `${t.api.baseUrl}/presigned${path}`, @@ -94,9 +92,6 @@ describe("deploy command (static site through the deployments API, env-gated)", t.expectResult(result).toContain("Site deployed"); t.expectResult(result).toContain(`Deployment: ${DEPLOYMENT_ID}`); - // Create request: the commit address, NO config field at all (that is - // what selects the static arm), and index.html IS in the manifest — it - // is only ever excluded from the uploads. expect(t.api.deploymentCreateRequests).toHaveLength(1); const body = t.api.deploymentCreateRequests[0] as CreateBody; expect(body.git_hash).toBe(GIT_HASH); @@ -107,8 +102,6 @@ describe("deploy command (static site through the deployments API, env-gated)", "/styles.css", ]); - // Raw bytes PUT directly to the presigned URLs: the computed content - // type, no auth header (the URL itself is the credential). expect(t.api.presignedUploadRequests).toHaveLength(2); const byPath = new Map( t.api.presignedUploadRequests.map((r) => [r.path, r]), @@ -122,8 +115,6 @@ describe("deploy command (static site through the deployments API, env-gated)", expect(styles?.contentType).toBe("text/css; charset=utf-8"); expect(styles?.authorization).toBeUndefined(); - // Finalize: exactly one file part — the index.html bytes. No payload, - // no modules. expect(t.api.finalizeRequests).toHaveLength(1); const fields = t.api.finalizeRequests[0]; expect(fields.map((f) => f.name)).toEqual(["index.html"]); @@ -232,8 +223,6 @@ describe("deploy command (static site through the deployments API, env-gated)", await t.givenLoggedInWithProject(fixture("fullstack-project")); t.givenEnv({ BASE44_STATIC_DEPLOYMENTS: "1" }); mockResourcePushes(); - // Nothing owed on the cf arm: asset_uploads is null either way, and the - // request carrying a worker config is what selects the arm. t.api.mockDeploymentCreate({ deployment_id: DEPLOYMENT_ID, asset_uploads: null, @@ -244,7 +233,6 @@ describe("deploy command (static site through the deployments API, env-gated)", t.expectResult(result).toSucceed(); t.expectResult(result).toContain("Full-stack app deployed"); - // The framework-emitted wrangler config wins detection and is sent. const body = t.api.deploymentCreateRequests[0] as CreateBody; expect(body.config?.main).toBe("index.js"); expect(body.config?.compatibility_flags).toEqual(["nodejs_compat"]); diff --git a/packages/cli/tests/cli/testkit/TestAPIServer.ts b/packages/cli/tests/cli/testkit/TestAPIServer.ts index d921d91fc..dadfa8645 100644 --- a/packages/cli/tests/cli/testkit/TestAPIServer.ts +++ b/packages/cli/tests/cli/testkit/TestAPIServer.ts @@ -202,7 +202,7 @@ interface CreateAppResponse { name: string; } -// ─── DEPLOYMENTS (FULL-STACK) TYPES ───────────────────────── +// ─── DEPLOYMENTS TYPES ────────────────────────────────────── interface DeploymentCreateResponse { deployment_id: string; @@ -279,7 +279,6 @@ function parseMultipart( return fields; } -/** A captured asset bucket upload request. */ interface CapturedAssetUpload { authorization?: string; base64Query?: string; @@ -661,7 +660,7 @@ export class TestAPIServer { ); } - // ─── DEPLOYMENT (FULL-STACK) ENDPOINTS ─────────────────── + // ─── DEPLOYMENT ENDPOINTS ───────────────────────────────── /** Captured JSON bodies of POST deployments requests. */ readonly deploymentCreateRequests: unknown[] = []; @@ -691,10 +690,8 @@ export class TestAPIServer { /** * Register a Cloudflare-style assets upload endpoint: serves * POST /cf-assets/upload — point the cf arm's `url` at - * `${baseUrl}/cf-assets/upload` — capturing each request's Authorization - * header, base64 query param, and multipart fields in - * `assetUploadRequests`, responding 201 with the completion JWT - * (Cloudflare's reply shape). + * `${baseUrl}/cf-assets/upload`. Captures each request in + * `assetUploadRequests`. */ mockAssetUpload(completionJwt: string): this { return this.mockAssetUploadAfterFailures(0, completionJwt); @@ -702,9 +699,8 @@ export class TestAPIServer { /** * Same target as {@link mockAssetUpload}, but the first `failures` requests - * answer 503 before it starts succeeding — every attempt is still recorded in - * `assetUploadRequests`, so a spec can assert the upload was retried and that - * the retried request carried the same body. + * answer 503. Every attempt is still recorded, so a spec can assert what the + * retried request carried. */ mockAssetUploadAfterFailures(failures: number, completionJwt: string): this { let seen = 0; @@ -761,8 +757,7 @@ export class TestAPIServer { /** * Mock POST /api/apps/{appId}/deployments/{id}/finalize. Captures the - * multipart fields (payload JSON + one file field per module) in - * `finalizeRequests`. + * multipart fields in `finalizeRequests`. */ mockDeploymentFinalize(response: DeploymentFinalizeResponse): this { this.pendingRoutes.push({ diff --git a/packages/cli/tests/core/site-wrangler-config.spec.ts b/packages/cli/tests/core/site-wrangler-config.spec.ts index c7163b13e..22dce2d15 100644 --- a/packages/cli/tests/core/site-wrangler-config.spec.ts +++ b/packages/cli/tests/core/site-wrangler-config.spec.ts @@ -127,8 +127,7 @@ describe("wrangler config resolution", () => { }); it("does not treat a hand-authored root wrangler config as an artifact", async () => { - // Root configs target wrangler's own bundler; detecting one would hijack - // the deploy away from the static upload the project actually wants. + // Detecting one would hijack the deploy away from the static upload. await writeFile(join(root, "wrangler.jsonc"), JSON.stringify(BASE_CONFIG)); await writeFile(join(root, "wrangler.json"), JSON.stringify(BASE_CONFIG)); await writeFile(join(root, "wrangler.toml"), 'name = "test-worker"\n'); From d82ed4f3b77830a185e4eeb92fbb2e3b4025cfd5 Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Tue, 4 Aug 2026 21:50:29 +0300 Subject: [PATCH 6/7] refactor: keep the deployments lane in `site deploy` only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `base44 deploy` had grown the whole deployments lane: --git-hash, --concurrency, full-stack artifact detection, and a deployment summary. It doesn't need any of it. The fullstack flow is an addition to the existing *site* deploy flow, so that is where it lives. `base44 deploy` reverts to what it was: resources plus the legacy tar.gz site step through `deployAll()`. Reverted to origin/main verbatim — cli/commands/project/deploy.ts (drops the flags, detectAppDeployKind, `site: false`, printDeploymentSummary and the deployment JSON output) and core/project/deploy.ts (drops the `site` option and the buildCommand clause in hasResourcesToDeploy, which only existed to serve the removed flow). `base44 site deploy` keeps the lane and is now its only entry point. With one caller left, the shared addDeploymentOptions() helper was over-abstraction: the two option definitions and their parsers are inlined and cli/commands/site/deploy-options.ts is deleted. Specs for both arms move from the unified deploy to `site deploy`, which also drops their resource-push mocks — nothing pushes resources there. Added a test pinning the decision: `base44 deploy` rejects --git-hash and --concurrency as unknown options and shows neither in --help. typecheck, lint, and knip clean; full suite green (715 tests, 71 files). Co-Authored-By: Claude Opus 5 (1M context) --- docs/deployments.md | 6 +- docs/resources.md | 8 +- .../cli/src/cli/commands/project/deploy.ts | 74 +++---------------- .../src/cli/commands/site/deploy-options.ts | 50 ------------- packages/cli/src/cli/commands/site/deploy.ts | 48 ++++++++++-- packages/cli/src/core/project/deploy.ts | 14 +--- packages/cli/tests/cli/deploy.spec.ts | 18 +++++ .../cli/tests/cli/fullstack_deploy.spec.ts | 39 +++++----- .../tests/cli/static_site_deployments.spec.ts | 35 +++------ 9 files changed, 112 insertions(+), 180 deletions(-) delete mode 100644 packages/cli/src/cli/commands/site/deploy-options.ts diff --git a/docs/deployments.md b/docs/deployments.md index c44add732..a84325cb9 100644 --- a/docs/deployments.md +++ b/docs/deployments.md @@ -14,7 +14,7 @@ Two kinds go through the same protocol, and the create request decides which: a ## Artifact Detection -Both `base44 deploy` and `base44 site deploy` route through `deployAppSite()` in `core/site/` (see [resources.md](resources.md#site-module-not-a-resource)), which takes the full-stack path whenever an artifact is detected and falls back to the static-site transports otherwise. +`base44 site deploy` routes through `deployAppSite()` in `core/site/` (see [resources.md](resources.md#site-module-not-a-resource)), which takes the full-stack path whenever an artifact is detected and falls back to the static-site transports otherwise. **This lane is reachable only from `site deploy`** — `base44 deploy` ships the site through `deployAll()`'s legacy tar.gz step and has none of these flags. `detectFullStackArtifact(projectRoot)` looks for exactly one thing: `.wrangler/deploy/config.json`, the redirect file emitted by `@cloudflare/vite-plugin` builds. Its `configPath` points at the generated `wrangler.json`, **relative to the redirect file's directory**. @@ -53,9 +53,9 @@ Entry = `main` from the wrangler config. With `no_bundle: true`, every file unde ## Command UX -**`base44 deploy [--git-hash ] [--concurrency ] [--build|--no-build]`** — the optional build step is `maybeBuildBeforeDeploy` (`--build` forces it, `--no-build` skips it, otherwise an interactive ask). Then, if a full-stack artifact is detected it replaces the static site upload; otherwise the site ships over the static transport. Progress: "Found N static assets (M new)" → "Uploaded X of Y assets" → "Deploying worker (K modules)…" → summary row `Deployment: (commit )`. Under `--json`, stdout is a single `{deploymentId, gitHash}` document. +**`base44 site deploy [--git-hash ] [--concurrency ] [--build|--no-build]`** — the optional build step is `maybeBuildBeforeDeploy` (`--build` forces it, `--no-build` skips it, otherwise an interactive ask). Then, if a full-stack artifact is detected it ships as a Workers deployment; otherwise the site output ships over whichever static transport applies. Progress: "Found N static assets (M new)" → "Uploaded X of Y assets" → "Deploying worker (K modules)…" → outro `Deployment (commit )`. Under `--json`, stdout is a single `{deploymentId, gitHash}` document. -`base44 site deploy` takes the same deployment flags and ships only the site. Both get them from `addDeploymentOptions()` in `src/cli/commands/site/deploy-options.ts` — one definition, so the commit address and the upload concurrency mean the same thing on each command. +`base44 deploy` is deliberately untouched by this: it deploys the project's resources and ships the site through `deployAll()`'s legacy tar.gz step, exactly as before, and neither `--git-hash` nor `--concurrency` exists on it. Adopting the lane there is a separate decision — it would need a commit address the unified deploy has no way to take. The primary automated consumer is the platform's build/deploy sandbox, which runs this command with a scoped `apps:deploy` workspace key and the checkout's commit — so the sandbox and a human at a terminal go through the exact same door. diff --git a/docs/resources.md b/docs/resources.md index 9a1261fdb..1cc5d569e 100644 --- a/docs/resources.md +++ b/docs/resources.md @@ -78,7 +78,7 @@ Agent skills are app-scoped instruction snippets shared across the app's agents. The site module at `packages/cli/src/core/site/` handles deploying an app's built output. It follows a different pattern than resources — there is no item list, so no `readAll`/`push`. -It owns **which transport ships the build**. `deployAppSite()` in `deploy-app.ts` is the single entry point both `base44 deploy` and `base44 site deploy` call: +It owns **which transport ships the build**. `deployAppSite()` in `deploy-app.ts` is the entry point `base44 site deploy` calls: - A full-stack (Workers) artifact wins when one is present — see [deployments.md](deployments.md). It carries the server too, so shipping the static output directory instead would silently drop the worker. - Otherwise `site.outputDirectory` ships as a static site: through the deployments API when the env-gated lane is enabled, else the legacy path — tar.gz the built files and upload via `POST /api/apps/{app_id}/deploy-dist`. @@ -92,7 +92,9 @@ const result = await deployAppSite(project, { gitHash }); // | { kind: "static", appUrl } | { kind: "none" } ``` -`detectAppDeployKind()` answers what would ship right now — used for the deploy summary and spinner labels. It answers for the current state of the tree; the full-stack artifact is itself a build output, so a build step invalidates it. +`detectAppDeployKind()` answers what would ship right now — used for the confirmation prompt and spinner labels. It answers for the current state of the tree; the full-stack artifact is itself a build output, so a build step invalidates it. + +`base44 deploy` does **not** go through this. It ships the site through `deployAll()`'s legacy tar.gz step, so the full-stack and deployments-API transports are reachable only from `base44 site deploy` — they need a commit address the unified deploy has no way to take. One flow per file: `full-stack.ts` (Workers), `static-site.ts` (deployments-API static), `deploy.ts` (legacy tar.gz). The first two share `manifest.ts`, `upload.ts`, `git-hash.ts`, and the module's `api.ts` / `schema.ts`; see [deployments.md](deployments.md). @@ -124,7 +126,7 @@ What it deploys (in order): 3. Agent skills (via `agentSkillResource.push()`) 4. Agents (via `agentResource.push()`) 5. Connectors (via `pushConnectors()`) -- may return OAuth redirect URLs -6. Site — via `deployAppSite()`, which picks the transport (see [Site Module](#site-module-not-a-resource)). The deploy command passes `site: false` to `deployAll()` and handles this step itself, after the optional build step has produced whatever the site ships. +6. Site (if `site.outputDirectory` is configured) — the legacy tar.gz upload. The full-stack and deployments-API transports are not reachable from here; see [deployments.md](deployments.md). ```bash base44 deploy # With confirmation prompt diff --git a/packages/cli/src/cli/commands/project/deploy.ts b/packages/cli/src/cli/commands/project/deploy.ts index 7c773fc7d..986c01ffc 100644 --- a/packages/cli/src/cli/commands/project/deploy.ts +++ b/packages/cli/src/cli/commands/project/deploy.ts @@ -7,8 +7,6 @@ import { } from "@/cli/commands/connectors/oauth-prompt.js"; import { formatDeployResult } from "@/cli/commands/functions/formatDeployResult.js"; import { maybeBuildBeforeDeploy } from "@/cli/commands/project/site-build.js"; -import { addDeploymentOptions } from "@/cli/commands/site/deploy-options.js"; -import { runAppSiteDeploy } from "@/cli/commands/site/run-app-deploy.js"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { Base44Command, @@ -26,14 +24,11 @@ import type { ConnectorSyncResult, StripeSyncResult, } from "@/core/resources/connector/index.js"; -import { detectAppDeployKind } from "@/core/site/index.js"; interface DeployOptions { yes?: boolean; build?: boolean; projectRoot?: string; - gitHash?: string; - concurrency?: number; } export async function deployAction( @@ -46,20 +41,16 @@ export async function deployAction( } const projectData = await readProjectConfig(options.projectRoot); - const { project, entities, functions, agents, connectors, authConfig } = - projectData; - // Pre-build look at what the site step would ship, for the summary and the - // no-resources check. The build below can change the answer, so the deploy - // decides again for itself. - const plannedSite = await detectAppDeployKind(project); - - if (!hasResourcesToDeploy(projectData) && plannedSite === "none") { + if (!hasResourcesToDeploy(projectData)) { return { outroMessage: "No resources found to deploy", }; } + const { project, entities, functions, agents, connectors, authConfig } = + projectData; + // Build summary of what will be deployed const summaryLines: string[] = []; if (entities.length > 0) { @@ -88,9 +79,7 @@ export async function deployAction( if (project.visibility) { summaryLines.push(` - Visibility: ${project.visibility}`); } - if (plannedSite === "full-stack") { - summaryLines.push(" - Full-stack app"); - } else if (project.site?.outputDirectory) { + if (project.site?.outputDirectory) { summaryLines.push(` - Site from ${project.site.outputDirectory}`); } @@ -113,13 +102,11 @@ export async function deployAction( await maybeBuildBeforeDeploy(ctx, project, options.build); - // Deploy resources with per-function progress; the site ships below, from - // whatever the build produced. + // Deploy resources with per-function progress let functionCompleted = 0; const functionTotal = functions.length; const result = await deployAll(projectData, { - site: false, onVisibilitySet: (level) => { log.success(`App visibility set to ${level}`); }, @@ -137,11 +124,6 @@ export async function deployAction( }, }); - const siteResult = await runAppSiteDeploy(ctx, project, { - gitHash: options.gitHash, - concurrency: options.concurrency, - }); - // Handle connector-specific post-deploy flows const connectorResults = result.connectorResults ?? []; await handleOAuthConnectors(connectorResults, isNonInteractive, options, log); @@ -153,56 +135,24 @@ export async function deployAction( log.message( `${theme.styles.header("Dashboard")}: ${theme.colors.links(getDashboardUrl())}`, ); - if (siteResult.kind === "static") { + if (result.appUrl) { log.message( - `${theme.styles.header("App URL")}: ${theme.colors.links(siteResult.appUrl)}`, + `${theme.styles.header("App URL")}: ${theme.colors.links(result.appUrl)}`, ); } - const deployment = - siteResult.kind === "full-stack" || siteResult.kind === "static-deployment" - ? siteResult - : undefined; - if (deployment) { - printDeploymentSummary(deployment, log); - } - return { - outroMessage: "App deployed successfully", - stdout: - ctx.jsonMode && deployment - ? `${JSON.stringify( - { - deploymentId: deployment.deploymentId, - gitHash: deployment.gitHash, - }, - null, - 2, - )}\n` - : undefined, - }; -} - -function printDeploymentSummary( - deployment: { deploymentId: string; gitHash: string }, - log: Logger, -): void { - // No URL: what production serves is decided when the app is published from - // the builder, not by this deploy. - log.message( - `${theme.styles.header("Deployment")}: ${deployment.deploymentId} ${theme.styles.dim(`(commit ${deployment.gitHash.slice(0, 12)})`)}`, - ); + return { outroMessage: "App deployed successfully" }; } export function getDeployCommand(): Command { - const command = new Base44Command("deploy") + return new Base44Command("deploy") .description( "Deploy all project resources (entities, functions, agents, connectors, and site)", ) .option("-y, --yes", "Skip confirmation prompt") .option("--build", "Build the site before deploying (skips the prompt)") - .option("--no-build", "Deploy without building (skips the prompt)"); - - return addDeploymentOptions(command).action(deployAction); + .option("--no-build", "Deploy without building (skips the prompt)") + .action(deployAction); } async function handleOAuthConnectors( diff --git a/packages/cli/src/cli/commands/site/deploy-options.ts b/packages/cli/src/cli/commands/site/deploy-options.ts deleted file mode 100644 index 4daa3ba42..000000000 --- a/packages/cli/src/cli/commands/site/deploy-options.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { InvalidArgumentError, Option } from "commander"; -import { - DEFAULT_UPLOAD_CONCURRENCY, - MAX_UPLOAD_CONCURRENCY, -} from "@/core/site/index.js"; -import { isGitCommitHash } from "@/core/utils/git.js"; - -/** - * Shared by `base44 deploy` and `base44 site deploy`: both ship the built output - * through the same path, so these have to mean the same thing on each. - */ -export function addDeploymentOptions< - T extends { addOption: (option: Option) => T }, ->(command: T): T { - return command - .addOption( - new Option( - "--git-hash ", - "Commit the build came from (defaults to the checkout's HEAD)", - ).argParser(parseGitHash), - ) - .addOption( - new Option("--concurrency ", "Parallel asset uploads") - .default(DEFAULT_UPLOAD_CONCURRENCY) - .argParser(parseConcurrency), - ); -} - -function parseGitHash(value: string): string { - if (!isGitCommitHash(value)) { - throw new InvalidArgumentError( - "Expected a git commit hash (7-64 hex chars).", - ); - } - return value; -} - -function parseConcurrency(value: string): number { - const parsed = Number(value); - if ( - !Number.isInteger(parsed) || - parsed < 1 || - parsed > MAX_UPLOAD_CONCURRENCY - ) { - throw new InvalidArgumentError( - `Expected a whole number between 1 and ${MAX_UPLOAD_CONCURRENCY}.`, - ); - } - return parsed; -} diff --git a/packages/cli/src/cli/commands/site/deploy.ts b/packages/cli/src/cli/commands/site/deploy.ts index 125419097..fbe3d6215 100644 --- a/packages/cli/src/cli/commands/site/deploy.ts +++ b/packages/cli/src/cli/commands/site/deploy.ts @@ -1,12 +1,17 @@ import { confirm, isCancel } from "@clack/prompts"; import type { Command } from "commander"; +import { InvalidArgumentError, Option } from "commander"; import { maybeBuildBeforeDeploy } from "@/cli/commands/project/site-build.js"; -import { addDeploymentOptions } from "@/cli/commands/site/deploy-options.js"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { Base44Command } from "@/cli/utils/index.js"; import { ConfigNotFoundError, InvalidInputError } from "@/core/errors.js"; import { readProjectConfig } from "@/core/project/index.js"; -import { detectAppDeployKind } from "@/core/site/index.js"; +import { + DEFAULT_UPLOAD_CONCURRENCY, + detectAppDeployKind, + MAX_UPLOAD_CONCURRENCY, +} from "@/core/site/index.js"; +import { isGitCommitHash } from "@/core/utils/git.js"; import { runAppSiteDeploy } from "./run-app-deploy.js"; interface DeployOptions { @@ -87,13 +92,46 @@ async function deployAction( } export function getSiteDeployCommand(): Command { - const command = new Base44Command("deploy") + return new Base44Command("deploy") .description( "Deploy the built site to Base44 hosting (full-stack apps deploy their Workers build)", ) .option("-y, --yes", "Skip confirmation prompt") .option("--build", "Build the site before deploying (skips the prompt)") - .option("--no-build", "Deploy without building (skips the prompt)"); + .option("--no-build", "Deploy without building (skips the prompt)") + .addOption( + new Option( + "--git-hash ", + "Commit the build came from (defaults to the checkout's HEAD)", + ).argParser(parseGitHash), + ) + .addOption( + new Option("--concurrency ", "Parallel asset uploads") + .default(DEFAULT_UPLOAD_CONCURRENCY) + .argParser(parseConcurrency), + ) + .action(deployAction); +} - return addDeploymentOptions(command).action(deployAction); +function parseGitHash(value: string): string { + if (!isGitCommitHash(value)) { + throw new InvalidArgumentError( + "Expected a git commit hash (7-64 hex chars).", + ); + } + return value; +} + +function parseConcurrency(value: string): number { + const parsed = Number(value); + if ( + !Number.isInteger(parsed) || + parsed < 1 || + parsed > MAX_UPLOAD_CONCURRENCY + ) { + throw new InvalidArgumentError( + `Expected a whole number between 1 and ${MAX_UPLOAD_CONCURRENCY}.`, + ); + } + return parsed; } diff --git a/packages/cli/src/core/project/deploy.ts b/packages/cli/src/core/project/deploy.ts index 6dd659c1c..99ff0ef95 100644 --- a/packages/cli/src/core/project/deploy.ts +++ b/packages/cli/src/core/project/deploy.ts @@ -33,11 +33,7 @@ export function hasResourcesToDeploy(projectData: ProjectData): boolean { connectors, authConfig, } = projectData; - // A build command counts: a full-stack project may configure nothing else, - // and its artifact is not on disk until the build has run. - const hasSite = Boolean( - project.site?.outputDirectory || project.site?.buildCommand, - ); + const hasSite = Boolean(project.site?.outputDirectory); const hasEntities = entities.length > 0; const hasFunctions = functions.length > 0; const hasAgents = agents.length > 0; @@ -75,12 +71,6 @@ interface DeployAllResult { interface DeployAllOptions { onFunctionStart?: (names: string[]) => void; onFunctionResult?: (result: SingleFunctionDeployResult) => void; - /** - * Deploy the legacy static site (tar.gz upload) when configured. The unified - * deploy command passes false and handles the site itself. - * @default true - */ - site?: boolean; onVisibilitySet?: (visibility: Visibility) => void; } @@ -126,7 +116,7 @@ export async function deployAll( ? [] : (await pushConnectors(connectors)).results; - if ((options?.site ?? true) && project.site?.outputDirectory) { + if (project.site?.outputDirectory) { const outputDir = resolve(project.root, project.site.outputDirectory); const { appUrl } = await deploySite(outputDir); return { appUrl, connectorResults }; diff --git a/packages/cli/tests/cli/deploy.spec.ts b/packages/cli/tests/cli/deploy.spec.ts index c32ed161f..1c4208bb1 100644 --- a/packages/cli/tests/cli/deploy.spec.ts +++ b/packages/cli/tests/cli/deploy.spec.ts @@ -52,6 +52,24 @@ describe("deploy command (unified)", () => { ); }); + // The deployments lane belongs to `site deploy`. This command ships the site + // through the legacy tar.gz step, so it has no commit to address and none of + // the lane's flags. + it("does not take the deployments-lane flags", async () => { + await t.givenLoggedInWithProject(fixture("with-site")); + + const gitHash = await t.run("deploy", "-y", "--git-hash", "a1b2c3d4e5f6"); + const concurrency = await t.run("deploy", "-y", "--concurrency", "5"); + const help = await t.run("deploy", "--help"); + + t.expectResult(gitHash).toFail(); + t.expectResult(gitHash).toContain("unknown option"); + t.expectResult(concurrency).toFail(); + t.expectResult(concurrency).toContain("unknown option"); + t.expectResult(help).toNotContain("--git-hash"); + t.expectResult(help).toNotContain("--concurrency"); + }); + it("reports no resources when project is empty", async () => { await t.givenLoggedInWithProject(fixture("basic")); diff --git a/packages/cli/tests/cli/fullstack_deploy.spec.ts b/packages/cli/tests/cli/fullstack_deploy.spec.ts index 7abc5318b..586dea203 100644 --- a/packages/cli/tests/cli/fullstack_deploy.spec.ts +++ b/packages/cli/tests/cli/fullstack_deploy.spec.ts @@ -30,17 +30,10 @@ interface CreateBody { asset_manifest: Record; } -describe("deploy command (full-stack)", () => { +describe("site deploy command (full-stack)", () => { const t = setupCLITests(); - function mockResourcePushes() { - t.api.mockAgentsPush({ created: [], updated: [], deleted: [] }); - t.api.mockConnectorsList({ integrations: [] }); - t.api.mockStripeStatus({ stripe_mode: null }); - } - function mockHappyPath(options?: { buckets?: string[][] }) { - mockResourcePushes(); const htmlHash = assetHash(t.api.appId, INDEX_HTML); const jsHash = assetHash(t.api.appId, APP_JS); t.api.mockDeploymentCreate({ @@ -61,12 +54,12 @@ describe("deploy command (full-stack)", () => { await t.givenLoggedInWithProject(fixture("fullstack-project")); const { htmlHash, jsHash } = mockHappyPath(); - const result = await t.run("deploy", "-y", "--git-hash", GIT_HASH); + const result = await t.run("site", "deploy", "-y", "--git-hash", GIT_HASH); t.expectResult(result).toSucceed(); t.expectResult(result).toContain("Found 2 static assets (2 new)"); t.expectResult(result).toContain("Full-stack app deployed"); - t.expectResult(result).toContain(`Deployment: ${DEPLOYMENT_ID}`); + t.expectResult(result).toContain(`Deployment ${DEPLOYMENT_ID}`); expect(t.api.deploymentCreateRequests).toHaveLength(1); const body = t.api.deploymentCreateRequests[0] as CreateBody; @@ -130,14 +123,13 @@ describe("deploy command (full-stack)", () => { it("finalizes with a null completion token when every asset is already stored", async () => { await t.givenLoggedInWithProject(fixture("fullstack-project")); - mockResourcePushes(); t.api.mockDeploymentCreate({ deployment_id: DEPLOYMENT_ID, asset_uploads: null, }); t.api.mockDeploymentFinalize({ deployment_id: DEPLOYMENT_ID }); - const result = await t.run("deploy", "-y", "--git-hash", GIT_HASH); + const result = await t.run("site", "deploy", "-y", "--git-hash", GIT_HASH); t.expectResult(result).toSucceed(); expect(t.api.assetUploadRequests).toHaveLength(0); @@ -151,15 +143,20 @@ describe("deploy command (full-stack)", () => { it("normalizes and requires a commit hash", async () => { await t.givenLoggedInWithProject(fixture("fullstack-project")); - mockResourcePushes(); - const noHash = await t.run("deploy", "-y"); + const noHash = await t.run("site", "deploy", "-y"); t.expectResult(noHash).toFail(); t.expectResult(noHash).toContain("--git-hash"); // Rejected by the option's argParser, before the action (and any resource // push) runs. - const badHash = await t.run("deploy", "-y", "--git-hash", "not-a-hash"); + const badHash = await t.run( + "site", + "deploy", + "-y", + "--git-hash", + "not-a-hash", + ); t.expectResult(badHash).toFail(); t.expectResult(badHash).toContain("Expected a git commit hash"); expect(t.api.deploymentCreateRequests).toHaveLength(0); @@ -170,6 +167,7 @@ describe("deploy command (full-stack)", () => { mockHappyPath(); const result = await t.run( + "site", "deploy", "-y", "--json", @@ -199,7 +197,7 @@ describe("deploy command (full-stack)", () => { await writeFile(configPath, JSON.stringify(config)); mockHappyPath(); - const result = await t.run("deploy", "-y", "--git-hash", GIT_HASH); + const result = await t.run("site", "deploy", "-y", "--git-hash", GIT_HASH); t.expectResult(result).toSucceed(); t.expectResult(result).toContain("no 'nodejs_compat' compatibility flag"); @@ -209,7 +207,6 @@ describe("deploy command (full-stack)", () => { it("retries a bucket upload after a transient failure, resending the body", async () => { await t.givenLoggedInWithProject(fixture("fullstack-project")); - mockResourcePushes(); const htmlHash = assetHash(t.api.appId, INDEX_HTML); t.api.mockDeploymentCreate({ deployment_id: DEPLOYMENT_ID, @@ -223,7 +220,7 @@ describe("deploy command (full-stack)", () => { t.api.mockAssetUploadAfterFailures(2, "completion-jwt"); t.api.mockDeploymentFinalize({ deployment_id: DEPLOYMENT_ID }); - const result = await t.run("deploy", "-y", "--git-hash", GIT_HASH); + const result = await t.run("site", "deploy", "-y", "--git-hash", GIT_HASH); t.expectResult(result).toSucceed(); expect(t.api.assetUploadRequests).toHaveLength(3); @@ -244,7 +241,6 @@ describe("deploy command (full-stack)", () => { it("surfaces a session-expired error when Cloudflare rejects the session jwt", async () => { await t.givenLoggedInWithProject(fixture("fullstack-project")); - mockResourcePushes(); t.api.mockDeploymentCreate({ deployment_id: DEPLOYMENT_ID, asset_uploads: { @@ -256,7 +252,7 @@ describe("deploy command (full-stack)", () => { }); t.api.mockAssetUploadError({ status: 401, body: { error: "expired" } }); - const result = await t.run("deploy", "-y", "--git-hash", GIT_HASH); + const result = await t.run("site", "deploy", "-y", "--git-hash", GIT_HASH); t.expectResult(result).toFail(); t.expectResult(result).toContain("upload session has expired"); @@ -264,13 +260,12 @@ describe("deploy command (full-stack)", () => { it("fails when the deployment API rejects the create call", async () => { await t.givenLoggedInWithProject(fixture("fullstack-project")); - mockResourcePushes(); t.api.mockError("post", `/api/apps/${t.api.appId}/deployments`, { status: 422, body: { message: "unsupported artifact" }, }); - const result = await t.run("deploy", "-y", "--git-hash", GIT_HASH); + const result = await t.run("site", "deploy", "-y", "--git-hash", GIT_HASH); t.expectResult(result).toFail(); t.expectResult(result).toContain("unsupported artifact"); diff --git a/packages/cli/tests/cli/static_site_deployments.spec.ts b/packages/cli/tests/cli/static_site_deployments.spec.ts index bd8b7d35d..a28985137 100644 --- a/packages/cli/tests/cli/static_site_deployments.spec.ts +++ b/packages/cli/tests/cli/static_site_deployments.spec.ts @@ -30,15 +30,9 @@ interface CreateBody { asset_manifest: Record; } -describe("deploy command (static site through the deployments API, env-gated)", () => { +describe("site deploy command (static site through the deployments API, env-gated)", () => { const t = setupCLITests(); - function mockResourcePushes() { - t.api.mockAgentsPush({ created: [], updated: [], deleted: [] }); - t.api.mockConnectorsList({ integrations: [] }); - t.api.mockStripeStatus({ stripe_mode: null }); - } - /** The s3 create arm: presigned PUT targets for the requested paths. */ function mockStaticCreate(uploadPaths: string[]) { t.api.mockDeploymentCreate({ @@ -68,10 +62,9 @@ describe("deploy command (static site through the deployments API, env-gated)", it("keeps the legacy tar.gz site upload when the gate is off", async () => { await t.givenLoggedInWithProject(fixture("with-site")); - mockResourcePushes(); t.api.mockSiteDeploy({ app_url: "https://legacy.example.com" }); - const result = await t.run("deploy", "-y"); + const result = await t.run("site", "deploy", "-y"); t.expectResult(result).toSucceed(); t.expectResult(result).toContain("https://legacy.example.com"); @@ -81,16 +74,15 @@ describe("deploy command (static site through the deployments API, env-gated)", it("deploys the site output through the deployments API when gated on", async () => { await t.givenLoggedInWithProject(fixture("with-site")); t.givenEnv({ BASE44_STATIC_DEPLOYMENTS: "1" }); - mockResourcePushes(); mockStaticCreate(["/main.js", "/styles.css"]); t.api.mockDeploymentFinalize({ deployment_id: DEPLOYMENT_ID }); - const result = await t.run("deploy", "-y", "--git-hash", GIT_HASH); + const result = await t.run("site", "deploy", "-y", "--git-hash", GIT_HASH); t.expectResult(result).toSucceed(); t.expectResult(result).toContain("Found 3 static assets (2 new)"); t.expectResult(result).toContain("Site deployed"); - t.expectResult(result).toContain(`Deployment: ${DEPLOYMENT_ID}`); + t.expectResult(result).toContain(`Deployment ${DEPLOYMENT_ID}`); expect(t.api.deploymentCreateRequests).toHaveLength(1); const body = t.api.deploymentCreateRequests[0] as CreateBody; @@ -126,11 +118,10 @@ describe("deploy command (static site through the deployments API, env-gated)", it("sends no PUTs and still finalizes when every asset is already stored", async () => { await t.givenLoggedInWithProject(fixture("with-site")); t.givenEnv({ BASE44_STATIC_DEPLOYMENTS: "true" }); - mockResourcePushes(); mockStaticCreate([]); t.api.mockDeploymentFinalize({ deployment_id: DEPLOYMENT_ID }); - const result = await t.run("deploy", "-y", "--git-hash", GIT_HASH); + const result = await t.run("site", "deploy", "-y", "--git-hash", GIT_HASH); t.expectResult(result).toSucceed(); t.expectResult(result).toContain("Found 3 static assets (0 new)"); @@ -144,11 +135,11 @@ describe("deploy command (static site through the deployments API, env-gated)", it("emits a single JSON document with --json", async () => { await t.givenLoggedInWithProject(fixture("with-site")); t.givenEnv({ BASE44_STATIC_DEPLOYMENTS: "1" }); - mockResourcePushes(); mockStaticCreate(["/main.js", "/styles.css"]); t.api.mockDeploymentFinalize({ deployment_id: DEPLOYMENT_ID }); const result = await t.run( + "site", "deploy", "-y", "--git-hash", @@ -166,9 +157,8 @@ describe("deploy command (static site through the deployments API, env-gated)", it("requires a commit hash outside a git checkout", async () => { await t.givenLoggedInWithProject(fixture("with-site")); t.givenEnv({ BASE44_STATIC_DEPLOYMENTS: "1" }); - mockResourcePushes(); - const result = await t.run("deploy", "-y"); + const result = await t.run("site", "deploy", "-y"); t.expectResult(result).toFail(); t.expectResult(result).toContain("--git-hash"); @@ -179,7 +169,7 @@ describe("deploy command (static site through the deployments API, env-gated)", await t.givenLoggedInWithProject(fixture("with-site")); t.givenEnv({ BASE44_STATIC_DEPLOYMENTS: "1" }); - const result = await t.run("deploy", "-y", "--git-hash", "nope"); + const result = await t.run("site", "deploy", "-y", "--git-hash", "nope"); t.expectResult(result).toFail(); t.expectResult(result).toContain("Expected a git commit hash"); @@ -189,11 +179,11 @@ describe("deploy command (static site through the deployments API, env-gated)", it("uploads every asset under a --concurrency override", async () => { await t.givenLoggedInWithProject(fixture("with-site")); t.givenEnv({ BASE44_STATIC_DEPLOYMENTS: "1" }); - mockResourcePushes(); mockStaticCreate(["/main.js", "/styles.css"]); t.api.mockDeploymentFinalize({ deployment_id: DEPLOYMENT_ID }); const result = await t.run( + "site", "deploy", "-y", "--git-hash", @@ -210,8 +200,8 @@ describe("deploy command (static site through the deployments API, env-gated)", await t.givenLoggedInWithProject(fixture("with-site")); t.givenEnv({ BASE44_STATIC_DEPLOYMENTS: "1" }); - const zero = await t.run("deploy", "-y", "--concurrency", "0"); - const huge = await t.run("deploy", "-y", "--concurrency", "999"); + const zero = await t.run("site", "deploy", "-y", "--concurrency", "0"); + const huge = await t.run("site", "deploy", "-y", "--concurrency", "999"); t.expectResult(zero).toFail(); t.expectResult(zero).toContain("between 1 and 50"); @@ -222,14 +212,13 @@ describe("deploy command (static site through the deployments API, env-gated)", it("prefers a full-stack artifact over the static lane (cf arm)", async () => { await t.givenLoggedInWithProject(fixture("fullstack-project")); t.givenEnv({ BASE44_STATIC_DEPLOYMENTS: "1" }); - mockResourcePushes(); t.api.mockDeploymentCreate({ deployment_id: DEPLOYMENT_ID, asset_uploads: null, }); t.api.mockDeploymentFinalize({ deployment_id: DEPLOYMENT_ID }); - const result = await t.run("deploy", "-y", "--git-hash", GIT_HASH); + const result = await t.run("site", "deploy", "-y", "--git-hash", GIT_HASH); t.expectResult(result).toSucceed(); t.expectResult(result).toContain("Full-stack app deployed"); From 45a41710b1dffc48842b97f55e1ff3bc6adeb23b Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Tue, 4 Aug 2026 22:06:47 +0300 Subject: [PATCH 7/7] refactor(site deploy): shape results in helpers, as main already did MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #585 pulled the spinner wiring and result shaping out of `deployAction` into `deployToDeploymentsApi` / `deployTarball`. This branch had undone that: the action ended in an if/else chain over a result union, and the progress wiring had moved off to its own cli/commands/site/run-app-deploy.ts. Back to main's shape, with the full-stack flow folded in as a third helper. `deployAction` now reads as plan → confirm → build → dispatch, and each transport owns its own labels, progress and result: - deployFullStackApp — Workers, the cf arm - deployToDeploymentsApi — deployments-API static, the s3 arm - deployTarball — legacy tar.gz (unchanged from main) `runDeployTask` holds the spinner/progress wiring the two deployments-API helpers share, and `deploymentResult` the outro + --json document. run-app-deploy.ts is deleted. That let core shed an orchestrator it no longer needs: `deployAppSite()` and the AppDeployResult union are gone, and core/site/deploy-app.ts is just the planner now — `planAppDeploy()` returns the plan (with outputDir where there is one) and the command calls deployFullStack / deployStaticSite / deploySite itself. Core still decides which transport applies; the CLI no longer round-trips through a second dispatch to find out what it already asked for. The command plans twice, deliberately: once before the build for the prompt and the no-config error, once after for the transport it acts on, since a full-stack artifact is itself a build output. typecheck, lint, and knip clean; full suite green (715 tests, 71 files). Co-Authored-By: Claude Opus 5 (1M context) --- docs/deployments.md | 2 +- docs/resources.md | 20 +- packages/cli/src/cli/commands/site/deploy.ts | 185 +++++++++++++++--- .../src/cli/commands/site/run-app-deploy.ts | 77 -------- packages/cli/src/core/site/deploy-app.ts | 83 ++------ 5 files changed, 180 insertions(+), 187 deletions(-) delete mode 100644 packages/cli/src/cli/commands/site/run-app-deploy.ts diff --git a/docs/deployments.md b/docs/deployments.md index a84325cb9..b8ca6cc32 100644 --- a/docs/deployments.md +++ b/docs/deployments.md @@ -14,7 +14,7 @@ Two kinds go through the same protocol, and the create request decides which: a ## Artifact Detection -`base44 site deploy` routes through `deployAppSite()` in `core/site/` (see [resources.md](resources.md#site-module-not-a-resource)), which takes the full-stack path whenever an artifact is detected and falls back to the static-site transports otherwise. **This lane is reachable only from `site deploy`** — `base44 deploy` ships the site through `deployAll()`'s legacy tar.gz step and has none of these flags. +`base44 site deploy` picks its transport from `planAppDeploy()` in `core/site/` (see [resources.md](resources.md#site-module-not-a-resource)) — the full-stack path whenever an artifact is detected, the static-site transports otherwise — and calls that flow behind a spinner. **This lane is reachable only from `site deploy`** — `base44 deploy` ships the site through `deployAll()`'s legacy tar.gz step and has none of these flags. `detectFullStackArtifact(projectRoot)` looks for exactly one thing: `.wrangler/deploy/config.json`, the redirect file emitted by `@cloudflare/vite-plugin` builds. Its `configPath` points at the generated `wrangler.json`, **relative to the redirect file's directory**. diff --git a/docs/resources.md b/docs/resources.md index 1cc5d569e..d3c4f1f56 100644 --- a/docs/resources.md +++ b/docs/resources.md @@ -78,21 +78,21 @@ Agent skills are app-scoped instruction snippets shared across the app's agents. The site module at `packages/cli/src/core/site/` handles deploying an app's built output. It follows a different pattern than resources — there is no item list, so no `readAll`/`push`. -It owns **which transport ships the build**. `deployAppSite()` in `deploy-app.ts` is the entry point `base44 site deploy` calls: - -- A full-stack (Workers) artifact wins when one is present — see [deployments.md](deployments.md). It carries the server too, so shipping the static output directory instead would silently drop the worker. -- Otherwise `site.outputDirectory` ships as a static site: through the deployments API when the env-gated lane is enabled, else the legacy path — tar.gz the built files and upload via `POST /api/apps/{app_id}/deploy-dist`. -- Neither applies → `{ kind: "none" }`. +It owns **which transport ships the build**, but not the shipping itself: `planAppDeploy()` in `deploy-app.ts` only decides, and `base44 site deploy` calls the chosen flow. ```typescript -import { deployAppSite } from "@/core/site/index.js"; +import { planAppDeploy } from "@/core/site/index.js"; -const result = await deployAppSite(project, { gitHash }); -// { kind: "full-stack" | "static-deployment", deploymentId, gitHash } -// | { kind: "static", appUrl } | { kind: "none" } +const plan = await planAppDeploy(project); +// { kind: "full-stack" } | { kind: "static-deployment", outputDir } +// | { kind: "static", outputDir } | { kind: "none" } ``` -`detectAppDeployKind()` answers what would ship right now — used for the confirmation prompt and spinner labels. It answers for the current state of the tree; the full-stack artifact is itself a build output, so a build step invalidates it. +- A full-stack (Workers) artifact wins when one is present — see [deployments.md](deployments.md). It carries the server too, so shipping the static output directory instead would silently drop the worker. +- Otherwise `site.outputDirectory` ships as a static site: through the deployments API when the env-gated lane is enabled, else the legacy path — tar.gz the built files and upload via `POST /api/apps/{app_id}/deploy-dist`. +- Neither applies → `{ kind: "none" }`. + +The plan answers for the current state of the tree, and the full-stack artifact is itself a build output — so the command plans once before the build (for the prompt and the no-config error) and again after it, which is the answer it acts on. `base44 deploy` does **not** go through this. It ships the site through `deployAll()`'s legacy tar.gz step, so the full-stack and deployments-API transports are reachable only from `base44 site deploy` — they need a commit address the unified deploy has no way to take. diff --git a/packages/cli/src/cli/commands/site/deploy.ts b/packages/cli/src/cli/commands/site/deploy.ts index fbe3d6215..060499dde 100644 --- a/packages/cli/src/cli/commands/site/deploy.ts +++ b/packages/cli/src/cli/commands/site/deploy.ts @@ -3,16 +3,20 @@ import type { Command } from "commander"; import { InvalidArgumentError, Option } from "commander"; import { maybeBuildBeforeDeploy } from "@/cli/commands/project/site-build.js"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; -import { Base44Command } from "@/cli/utils/index.js"; +import { Base44Command, theme } from "@/cli/utils/index.js"; import { ConfigNotFoundError, InvalidInputError } from "@/core/errors.js"; import { readProjectConfig } from "@/core/project/index.js"; +import type { DeploymentProgress } from "@/core/site/index.js"; import { DEFAULT_UPLOAD_CONCURRENCY, - detectAppDeployKind, + deployFullStack, + deploySite, + deployStaticSite, MAX_UPLOAD_CONCURRENCY, + planAppDeploy, + resolveGitHash, } from "@/core/site/index.js"; import { isGitCommitHash } from "@/core/utils/git.js"; -import { runAppSiteDeploy } from "./run-app-deploy.js"; interface DeployOptions { yes?: boolean; @@ -31,10 +35,9 @@ async function deployAction( } const { project } = await readProjectConfig(); + const planned = await planAppDeploy(project); - const kind = await detectAppDeployKind(project); - - if (kind === "none") { + if (planned.kind === "none") { throw new ConfigNotFoundError("No site configuration found.", { hints: [ { @@ -52,7 +55,7 @@ async function deployAction( if (!options.yes) { const shouldDeploy = await confirm({ message: - kind === "full-stack" + planned.kind === "full-stack" ? "Deploy full-stack app?" : `Deploy site from ${project.site?.outputDirectory}?`, }); @@ -64,31 +67,157 @@ async function deployAction( await maybeBuildBeforeDeploy(ctx, project, options.build); - const result = await runAppSiteDeploy(ctx, project, { - gitHash: options.gitHash, - concurrency: options.concurrency, - }); - - if (result.kind === "full-stack" || result.kind === "static-deployment") { - // No URL: what production serves is decided when the app is published from - // the builder, not by this deploy. - return { - outroMessage: `Deployment ${result.deploymentId} (commit ${result.gitHash.slice(0, 12)})`, - stdout: ctx.jsonMode - ? `${JSON.stringify( - { deploymentId: result.deploymentId, gitHash: result.gitHash }, - null, - 2, - )}\n` - : undefined, - }; + // Planned again: the build may have produced the full-stack artifact that + // decides which transport applies. + const plan = await planAppDeploy(project); + + switch (plan.kind) { + case "full-stack": + return await deployFullStackApp(ctx, project.root, options); + case "static-deployment": + return await deployToDeploymentsApi( + ctx, + project.root, + plan.outputDir, + options, + ); + case "static": + return await deployTarball(ctx, plan.outputDir); + case "none": + return { outroMessage: "Nothing to deploy" }; } +} + +async function deployFullStackApp( + ctx: CLIContext, + projectRoot: string, + options: DeployOptions, +): Promise { + const gitHash = await resolveGitHash(projectRoot, options.gitHash); + + const { deploymentId } = await runDeployTask( + ctx, + { + start: "Deploying full-stack app...", + success: theme.colors.base44Orange("Full-stack app deployed"), + error: "Full-stack deploy failed", + }, + async (progress) => + await deployFullStack({ + projectRoot, + gitHash, + concurrency: options.concurrency, + progress, + }), + ); + + return deploymentResult(ctx, deploymentId, gitHash); +} + +async function deployToDeploymentsApi( + ctx: CLIContext, + projectRoot: string, + outputDir: string, + options: DeployOptions, +): Promise { + const gitHash = await resolveGitHash(projectRoot, options.gitHash); + + const { deploymentId } = await runDeployTask( + ctx, + { + start: "Deploying site...", + success: "Site deployed", + error: "Site deploy failed", + }, + async (progress) => + await deployStaticSite({ + outputDir, + gitHash, + concurrency: options.concurrency, + progress, + }), + ); - if (result.kind === "static") { - return { outroMessage: `Visit your site at: ${result.appUrl}` }; + return deploymentResult(ctx, deploymentId, gitHash); +} + +async function deployTarball( + { runTask }: CLIContext, + outputDir: string, +): Promise { + const { appUrl } = await runTask( + "Creating archive and deploying site...", + async () => await deploySite(outputDir), + { + successMessage: "Site deployed successfully", + errorMessage: "Deployment failed", + }, + ); + + return { outroMessage: `Visit your site at: ${appUrl}` }; +} + +/** + * Run a deployments-API deploy behind a spinner, streaming its stages into the + * spinner message. Asset counts are also kept for a summary line, since the + * spinner only ever shows the latest one, and warnings are held back so they + * land after the task instead of being overwritten by it. + */ +async function runDeployTask( + { runTask, log }: CLIContext, + labels: { start: string; success: string; error: string }, + deploy: ( + progress: DeploymentProgress, + ) => Promise<{ deploymentId: string; gitHash: string }>, +): Promise<{ deploymentId: string }> { + const progressLines: string[] = []; + const warnings: string[] = []; + + const result = await runTask( + labels.start, + async (updateMessage) => + await deploy({ + onWarning: (message) => { + warnings.push(message); + }, + onAssets: ({ totalAssets, newAssets }) => { + const line = `Found ${totalAssets} static assets (${newAssets} new)`; + progressLines.push(line); + updateMessage(line); + }, + onAssetUpload: ({ uploadedFiles, totalFiles }) => { + updateMessage(`Uploaded ${uploadedFiles} of ${totalFiles} assets`); + }, + onWorker: ({ moduleCount }) => { + updateMessage(`Deploying worker (${moduleCount} modules)…`); + }, + }), + { successMessage: labels.success, errorMessage: labels.error }, + ); + + for (const line of progressLines) { + log.message(theme.styles.dim(line)); } + for (const warning of warnings) { + log.warn(warning); + } + + return result; +} - return { outroMessage: "Nothing to deploy" }; +function deploymentResult( + { jsonMode }: CLIContext, + deploymentId: string, + gitHash: string, +): RunCommandResult { + // No URL: what production serves is decided when the app is published from + // the builder, not by this deploy. + return { + outroMessage: `Deployment ${deploymentId} (commit ${gitHash.slice(0, 12)})`, + stdout: jsonMode + ? `${JSON.stringify({ deploymentId, gitHash }, null, 2)}\n` + : undefined, + }; } export function getSiteDeployCommand(): Command { diff --git a/packages/cli/src/cli/commands/site/run-app-deploy.ts b/packages/cli/src/cli/commands/site/run-app-deploy.ts deleted file mode 100644 index 2cd553aff..000000000 --- a/packages/cli/src/cli/commands/site/run-app-deploy.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { CLIContext } from "@/cli/types.js"; -import { theme } from "@/cli/utils/index.js"; -import type { AppDeployResult, AppSiteTarget } from "@/core/site/index.js"; -import { deployAppSite, detectAppDeployKind } from "@/core/site/index.js"; - -const TASK_LABELS = { - "full-stack": { - start: "Deploying full-stack app...", - success: theme.colors.base44Orange("Full-stack app deployed"), - error: "Full-stack deploy failed", - }, - "static-deployment": { - start: "Deploying site...", - success: "Site deployed", - error: "Site deploy failed", - }, - static: { - start: "Creating archive and deploying site...", - success: "Site deployed successfully", - error: "Deployment failed", - }, -} as const; - -/** - * Run the project's site deploy behind a spinner, adapting the labels and the - * progress stream to whichever transport applies. - */ -export async function runAppSiteDeploy( - { runTask, log }: CLIContext, - target: AppSiteTarget, - options: { gitHash?: string; concurrency?: number } = {}, -): Promise { - const kind = await detectAppDeployKind(target); - if (kind === "none") return { kind: "none" }; - - const labels = TASK_LABELS[kind]; - const progressLines: string[] = []; - const warnings: string[] = []; - - const result = await runTask( - labels.start, - async (updateMessage) => - await deployAppSite(target, { - gitHash: options.gitHash, - concurrency: options.concurrency, - progress: { - onWarning: (message) => { - warnings.push(message); - }, - onAssets: ({ totalAssets, newAssets }) => { - const line = `Found ${totalAssets} static assets (${newAssets} new)`; - progressLines.push(line); - updateMessage(line); - }, - onAssetUpload: ({ uploadedFiles, totalFiles }) => { - updateMessage(`Uploaded ${uploadedFiles} of ${totalFiles} assets`); - }, - onWorker: ({ moduleCount }) => { - updateMessage(`Deploying worker (${moduleCount} modules)…`); - }, - }, - }), - { - successMessage: labels.success, - errorMessage: labels.error, - }, - ); - - for (const line of progressLines) { - log.message(theme.styles.dim(line)); - } - for (const warning of warnings) { - log.warn(warning); - } - - return result; -} diff --git a/packages/cli/src/core/site/deploy-app.ts b/packages/cli/src/core/site/deploy-app.ts index 3925a2f19..3ea5f7d25 100644 --- a/packages/cli/src/core/site/deploy-app.ts +++ b/packages/cli/src/core/site/deploy-app.ts @@ -1,24 +1,13 @@ import { resolve } from "node:path"; -import { deploySite } from "@/core/site/deploy.js"; -import { deployFullStack } from "./full-stack.js"; -import { resolveGitHash } from "./git-hash.js"; -import type { DeploymentProgress } from "./schema.js"; -import { deployStaticSite, staticDeploymentsEnabled } from "./static-site.js"; +import { staticDeploymentsEnabled } from "./static-site.js"; import { detectFullStackArtifact } from "./wrangler-config.js"; -export interface AppSiteTarget { +interface AppSiteTarget { root: string; site?: { outputDirectory?: string }; } -type AppDeployKind = "full-stack" | "static-deployment" | "static" | "none"; - -export type AppDeployResult = - | { kind: "full-stack"; deploymentId: string; gitHash: string } - | { kind: "static-deployment"; deploymentId: string; gitHash: string } - | { kind: "static"; appUrl: string } - | { kind: "none" }; - +/** Which transport ships this project's built output. */ type AppDeployPlan = | { kind: "full-stack" } | { kind: "static-deployment"; outputDir: string } @@ -26,11 +15,16 @@ type AppDeployPlan = | { kind: "none" }; /** - * A full-stack artifact wins over the static output directory: it carries the - * server too, so shipping the static output instead would silently drop the - * worker. + * How the built output would ship right now. A full-stack artifact wins over the + * static output directory: it carries the server too, so shipping the static + * output instead would silently drop the worker. + * + * The artifact is itself a build output, so a build step invalidates the answer + * — plan again after one runs. */ -async function planAppDeploy(target: AppSiteTarget): Promise { +export async function planAppDeploy( + target: AppSiteTarget, +): Promise { if (await detectFullStackArtifact(target.root)) { return { kind: "full-stack" }; } @@ -43,56 +37,3 @@ async function planAppDeploy(target: AppSiteTarget): Promise { ? { kind: "static-deployment", outputDir } : { kind: "static", outputDir }; } - -/** How the built output would ship right now — a build step invalidates it. */ -export async function detectAppDeployKind( - target: AppSiteTarget, -): Promise { - return (await planAppDeploy(target)).kind; -} - -/** - * Deploy the project's built output over whichever transport applies — a - * Workers deployment addressed by commit for full-stack builds, a - * deployments-API static deployment when the lane is enabled, the legacy tar.gz - * upload otherwise. - */ -export async function deployAppSite( - target: AppSiteTarget, - options: { - gitHash?: string; - concurrency?: number; - progress?: DeploymentProgress; - } = {}, -): Promise { - const plan = await planAppDeploy(target); - - switch (plan.kind) { - case "full-stack": { - const gitHash = await resolveGitHash(target.root, options.gitHash); - const { deploymentId } = await deployFullStack({ - projectRoot: target.root, - gitHash, - concurrency: options.concurrency, - progress: options.progress, - }); - return { kind: "full-stack", deploymentId, gitHash }; - } - case "static-deployment": { - const gitHash = await resolveGitHash(target.root, options.gitHash); - const { deploymentId } = await deployStaticSite({ - outputDir: plan.outputDir, - gitHash, - concurrency: options.concurrency, - progress: options.progress, - }); - return { kind: "static-deployment", deploymentId, gitHash }; - } - case "static": { - const { appUrl } = await deploySite(plan.outputDir); - return { kind: "static", appUrl }; - } - case "none": - return { kind: "none" }; - } -}