feat: full-stack deploy — build a commit's worker from the CLI (cf arm) - #584
feat: full-stack deploy — build a commit's worker from the CLI (cf arm)#584netanelgilad wants to merge 14 commits into
Conversation
🚀 Package Preview Available!Install this PR's preview build with npm: npm i @base44-preview/cli@0.1.12-pr.584.92706a2Prefer not to change any import paths? Install using npm alias so your code still imports npm i "base44@npm:@base44-preview/cli@0.1.12-pr.584.92706a2"Or add it to your {
"dependencies": {
"base44": "npm:@base44-preview/cli@0.1.12-pr.584.92706a2"
}
}
Preview published to npm registry — try new features instantly! |
…ted)
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DvhQfqxACcq25XAQRpoSh9
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DvhQfqxACcq25XAQRpoSh9
5c32b34 to
a588d65
Compare
Main landed PR #585 (static-site deploys) in a reworked form after review: the code moved to `core/site/`, the `.assetsignore` walk switched to globby, uploads switched to p-map + ky retry, and `--concurrency` was added. This branch had built the full-stack (cf) arm on top of #585's original layout in `core/deployments/`. Resolution: keep this branch's structure and CLI contract, adopt main's review improvements into it. Structure — kept `core/deployments/`; the cf arm deploys Workers, which is not site-specific. Reverted main's duplicate `core/site/{manifest,static-site, upload}.ts` and the deployment additions to `core/site/{api,schema}.ts`; `core/site/deploy-app.ts` remains the bridge from the site module. Adopted from main: - globby-based `.assetsignore` via `ignoreFiles`, replacing the hand-rolled matcher — gains real gitignore semantics including negation. Kept the MIME table and `AssetFile.contentType`, which the cf arm's multipart parts need. - p-map for upload concurrency (both arms) and ky's built-in retry for presigned PUTs, so a 403 from an expired URL fails fast. - `--concurrency <n>` (default 3, max 50) and `isGitCommitHash()` in `core/utils/git.ts`; `GIT_HASH_PATTERN` dropped from the deployments schema. - Main's manifest suite (negation, brace/extglob, anchoring, dotfiles). - v0.1.8, globby ^16.2.2, p-map ^7.0.6. Contract kept from this branch — main gated `--git-hash`/`--concurrency` registration on BASE44_STATIC_DEPLOYMENTS. That cannot hold here: full-stack deploys are ungated and need `--git-hash`, which now defaults to the checkout's HEAD. Both flags are therefore always registered, on `deploy` and `site deploy` alike, from a shared `addDeploymentOptions()`. The env gate now decides only whether a static output takes the deployments API or the legacy tar.gz path. Main's flag-hiding tests were dropped as no longer applicable; its `--git-hash`/`--concurrency` validation tests were ported. One behavior change: a malformed `--git-hash` is now rejected by the option's argParser ("Expected a git commit hash") before the action runs, rather than later by resolveGitHash. typecheck, lint, and knip clean; 713 tests pass. The suite has a pre-existing "Body is unusable" flake under parallel load that reproduces on pristine main. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…omment
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) <noreply@anthropic.com>
`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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
# Conflicts: # docs/deployments.md # packages/cli/src/cli/commands/site/deploy.ts # packages/cli/tests/cli/static_site_deployments.spec.ts
Integrates the session id main's #602 added to the deployments protocol: create returns `session_id` and finalize now addresses this attempt's uploads through it, so the cf arm threads `created.sessionId` into `finalizeDeployment` alongside the static arm. Conflicts resolved in docs/deployments.md (both arms plus session_id) and core/site/schema.ts (kept sessionId alongside the cf/s3 upload union).
| }, | ||
| { | ||
| message: | ||
| "Full-stack apps ship from their build artifact — run your framework's build first", |
There was a problem hiding this comment.
there's no need to have full-stack apps in user facing copy.
the copy that is user facing always says "site" as an encompassiong term for what we deploy, whetehr it has the backend worker or not, so there shoudn't be copy changes ideally
There was a problem hiding this comment.
Reverted to main's copy — the second hint is gone, so the error is No site configuration found. with the site.outputDirectory hint exactly as before this branch.
Same for the rest of the command's copy: description back to "Deploy built site files to Base44 hosting", spinner/success/error back to "Deploying site..." / "Site deployed" / "Site deploy failed" (no orange highlight for one of them either). I also dropped "full-stack" from the errors the wrangler and module paths raise — "No Workers build artifact found", "Base44 only deploys pre-bundled Workers output", "exceeds the 40 MB limit for a Base44 deploy" — since those reach the user too.
The one place a word had to be chosen rather than reverted: the spinner's Deploying worker (N modules)… stage, which only appears when there is a worker. Left as "worker" — it names the thing being uploaded rather than a kind of app, and no other stage line changes with the build. Say so if you'd rather it just stayed "Deploying site...".
Recorded the rule in docs/deployments.md so it doesn't drift back.
Generated by Claude Code
| const shouldDeploy = await confirm({ | ||
| message: `Deploy site from ${outputDirectory}?`, | ||
| message: | ||
| planned.kind === "full-stack" |
There was a problem hiding this comment.
Done — the prompt is Deploy site from ${outputDirectory}? again, with no branch on the kind of build.
It does still need a fallback: a project whose build produced a worker has no site.outputDirectory to name (the worker's own assets.directory supersedes it — the fullstack-project fixture has no site config at all), so with nothing to interpolate the prompt is just Deploy site?. That's a missing directory name, not a second kind of deploy.
Generated by Claude Code
| const plan = await planAppDeploy(project); | ||
|
|
||
| switch (plan.kind) { | ||
| case "full-stack": |
There was a problem hiding this comment.
I dont think we need to code flow to start separating here based on fullstack / static.
inside deploytoDEplymentsApi we should check if there is a user worker to also deploy and decide internally what to add to the createDeployment call, but the bottom line is that we sdhould have a single call to createDeployment in the code, it's jsut the paramters to it that changes based on what we have to deploy
There was a problem hiding this comment.
Agreed, and done — there is now one flow and one createDeployment() call site.
core/site/full-stack.ts and core/site/static-site.ts are gone, replaced by core/site/deployment.ts with a single deployToDeployments({projectRoot, outputDir, gitHash, ...}). It checks for a worker itself, and that only changes what it sends:
const worker = await resolveWorkerBuild(projectRoot, progress); // null = no worker
const assetsDir = worker ? worker.assetsDir : outputDir;
const assets = assetsDir ? await buildAssetManifest(assetsDir, appId) : NO_ASSETS;
const created = await createDeployment({
git_hash: gitHash,
config: worker?.config, // presence is what selects the storage target
asset_manifest: assets.manifest,
});Two more forks collapsed on the way, both for the same reason — they were us deciding something the server had already decided:
- Upload:
uploadDeploymentAssets(created.assetUploads, assets, …)dispatches on theasset_uploadsdiscriminant and returns the completion token when that arm has one. It also owns the "Found N static assets (M new)" progress, since counting what's owed is per-arm. The twoassetUploads.type !== "cf"/"s3"guards each flow used to raise are deleted: with one dispatch there is no wrong arm to reject. - Finalize: one
finalizeDeployment(deploymentId, sessionId, payload), where payload is{modules, completionJwt}or{indexHtml}. The remainingifis insideapi.tsbuilding the multipart form — that difference is the wire protocol (a worker completes with its modules, a static build with the index.html sentinel), not a flow choice, and the bytes on both arms are unchanged.
The command is down to one call: planAppDeploy() now returns deployment | tarball | none (the two deployments kinds merged), so site deploy has a 3-arm switch with a single deployToDeploymentsApi(), and its runDeployTask wrapper is inlined now that there's one caller. A worker build stays ungated and needs no site.outputDirectory.
Net −23 lines. static_site_deployments.spec.ts already asserted both parameter shapes of that one call — expect(body).not.toHaveProperty("config") on the static arm, body.config.main on the worker arm — so the contract you described is locked in by tests. Full suite: 732 passed, all 296 deploy + core tests green, typecheck/lint/knip clean.
Generated by Claude Code
Review feedback on #584. A worker is not a different kind of deploy, so the code no longer forks on one. `deployToDeployments()` checks for a worker itself and that changes only what it sends: the worker's config on the single create call (which is what makes the server answer with the cf arm) and its modules at finalize. The parallel full-stack.ts / static-site.ts flows are gone, and with them the two create call sites, the two finalize entry points and the arm-mismatch guards each flow needed to reject the other's answer. Downstream now follows the server's answer rather than a decision of ours: uploadDeploymentAssets() dispatches on the asset_uploads discriminant and owns the asset progress; finalizeDeployment() takes whichever payload completes the deployment. The only place the two shapes are still spelled out is the finalize form in api.ts, where the protocol itself differs. User-facing copy says "site" again, as it did before this branch: a site is whatever we deploy, worker or no worker, so the prompt, spinner, success line and errors no longer say "full-stack app". The command description, the no-config hints and the deploy labels are back to main's wording; the confirm prompt drops the output directory only when there is none to name. Errors from the wrangler and module paths lost "full-stack" too. Transport plan is now deployment | tarball | none, since the deployments arms merged. A worker build stays ungated and needs no site.outputDirectory.
Note
Description
Adds the worker (
cf) arm of the deployments API so a full-stack framework build (React Router 7, TanStack Start, Astro 6, vinext — anything built through@cloudflare/vite-plugin) can be deployed from the CLI as a Workers deployment addressed by the commit that produced it. The CLI detects the.wrangler/deploy/config.jsonredirect artifact, resolves and validates the generated wrangler config, collects the unbundled worker modules plus the static assets, POSTs asset buckets straight to Cloudflare under the upload-session JWT, then finalizes with the worker modules.The worker and static arms are one flow with one
createDeployment()call, not two lanes:deployToDeployments()checks for a worker itself, and that changes only what it sends (the worker'sconfigon create, its modules at finalize). Everything downstream follows the server's answer —uploadDeploymentAssets()dispatches on theasset_uploadsdiscriminant,finalizeDeployment()takes whichever payload completes the deployment. Deploy still only builds; nothing here publishes, and re-deploying a commit stays idempotent.Related Issue
None (pairs with the
cfarm of the backend's discriminated create response)Type of Change
Changes Made
core/site/deployment.ts, replacingstatic-site.ts):deployToDeployments()— resolve the worker if there is one → manifest → onecreateDeployment()→ upload → finalize.config: worker?.configis the only difference on create, and its presence is what selects the storage target server-side.usesDeploymentsApi()): a detected worker artifact always takes the deployments API (a tar.gz cannot ship a worker) and needs nosite.outputDirectory, since the worker brings its own assets directory; a plain static build takes it only behindBASE44_STATIC_DEPLOYMENTS, else the legacy tar.gz upload. Asked after the build, because the artifact is itself a build output.core/site/wrangler-config.ts): the only trigger is.wrangler/deploy/config.json, withconfigPathresolved relative to the redirect file's own directory; hand-authored root configs are deliberately not artifacts. The resolved config must beno_bundle: true, and only fields the deploy acts on are declared, so bindings and the workernameride along ignored.core/site/modules.ts): entry module plus therulesglobs, relative paths as module names,wrangler.json/.dev.vars/ the assets dir excluded, sourcemaps beside modules (all of them underupload_source_maps), 40 MB total cap under the server's 50 MB.core/site/git-hash.ts):resolveGitHash()— explicit--git-hashwins, otherwisegit rev-parse HEAD; neither available fails fast with guidance. A malformed value is still rejected by the option'sargParserbefore the action runs.cfcreate arm (core/site/schema.ts):asset_uploadsbecomes a discriminated union (cfalongsides3), the request carries an optional workerconfig, andAssetUploadResponseSchemaparses Cloudflare's bucket reply.core/site/upload.ts): buckets POST straight to Cloudflare with?base64=trueand a bearer session JWT — never the app client, which would leak app auth. 401/403 maps to "upload session expired — rerun deploy". The completion token is read from whichever bucket reply carries one, since the server decides completeness by manifest membership and buckets upload concurrently. Both arms now share oneUPLOAD_RETRYon ky (the cf arm must namemethods: ["post"]or bucket uploads would never retry).core/site/api.ts):finalizeDeployment(deploymentId, sessionId, payload)where payload is{modules, completionJwt}or{indexHtml}; theiflives inside the multipart form builder, where the protocol itself differs.nodejs_compat, wranglervars,_headers/_redirectsandrun_worker_firstroute arrays are surfaced after the task rather than erroring or quietly changing behavior — the config is framework-generated, so the fix belongs in the adapter's settings.core/site/manifest.ts): per-file MIME lookup for the cf arm's multipart parts; the s3 arm still echoes the server-signedContent-Typeverbatim.cli/commands/site/deploy.ts):--git-hashand--concurrencyare registered unconditionally (a worker build is ungated), the flow keeps saying "site" to the user whether or not there is a worker, and the tar.gz path is now the one that raises "No site configuration found."docs/deployments.mdrewritten to cover the single flow and both arms;docs/resources.md,docs/testing.md,docs/AGENTS.mdupdated.Testing
npm test)New:
tests/cli/fullstack_deploy.spec.ts(manifest/bucket/finalize round trip, null completion token when every asset is already stored, git-hash requirement andargParserrejection,--json,nodejs_compatwarning, bucket retry resending the body, session-expired mapping, create failure),tests/core/site-modules.spec.ts,tests/core/site-wrangler-config.spec.ts, transport-precedence cases insite_deploy.spec.ts, abase44 deploycase asserting the lane's flags stay off the unified command, and a retargetedstatic_site_deployments.spec.ts. Testkit gainsmockAssetUpload/mockAssetUploadAfterFailures/mockAssetUploadErrorandassetUploadRequests, plus atests/fixtures/fullstack-project/fixture.Checklist
docs/(AGENTS.md) if I made architectural changesAdditional Notes
main. (1)mainselected the static lane by the presence of--git-hash; here the env gate picks the transport and the commit defaults toHEAD, so with the gate on and no commit available the deploy fails asking for--git-hashinstead of silently falling back to tar.gz — a build with no address could never be published. (2)--git-hash/--concurrencynow appear onbase44 site deploy --helpeven with the static gate off, because a worker build is ungated.BASE44_STATIC_DEPLOYMENTSunset keeps the legacy tar.gz upload unchanged, andbase44 deploy(unified) is untouched — it still ships the site throughdeployAll()'s tar.gz step and has neither flag. Adopting the lane there needs a commit address the unified deploy has no way to take.retry.methods, and ky clones a pristine request so a FormData body survives being resent.no_bundle !== true) is rejected; wranglervarsare ignored in favor of app secrets;_headers/_redirectsandrun_worker_firstroute patterns are not forwarded yet.🤖 Generated by Claude | 2026-08-31 10:33 UTC | 92706a2