diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 453cff780..9a7a79550 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 +- **[Deployments](deployments.md)** - Deploys addressed by commit, wrangler config, asset manifest hashing, direct asset uploads (Workers) and presigned uploads (static) - **[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 d31969689..b8ca6cc32 100644 --- a/docs/deployments.md +++ b/docs/deployments.md @@ -1,49 +1,82 @@ -# Deployments API (Static Sites) +# 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, 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. This is a transport of the site module, not a module of its own, so it lives directly in `src/core/site/`: `manifest.ts` (asset walk + hashing), `static-site.ts` (the flow), `upload.ts` (presigned PUTs), with the requests and responses in the shared `api.ts` / `schema.ts` next to the legacy tar.gz upload. 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. +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. **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. -## The Commit Address +## 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) fails fast with guidance. A non-hex value is rejected by the option's `argParser` before the action even runs — `isGitCommitHash()` in `src/core/utils/git.ts`, pattern `^[a-fA-F0-9]{7,64}$`, the same validation as the server. + +## Artifact Detection + +`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. -`--git-hash` carries it, and there is no fallback: the flag is what selects this lane, so a deploy without one is the legacy tar.gz upload. A non-hex value is rejected by the option's `argParser` before the action runs — `isGitCommitHash()` in `src/core/utils/git.ts`, pattern `^[a-fA-F0-9]{7,64}$`, the same validation as the server. +`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 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 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. 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 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. + + 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/site/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 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. +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. + +Content types are computed per file (`getAssetContentType()`) **only for the cf arm**, where each multipart part declares its own MIME type. The s3 arm never uses them: 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. + +## 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 + +**`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. -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` 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 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. -Two switches, in order. **`BASE44_STATIC_DEPLOYMENTS=1`** (or `true`) is the release gate, read by `staticDeploymentsEnabled()` and consulted in exactly one place — `getSiteDeployCommand()`, which registers `--git-hash` only when it is set. **`--git-hash `** is then the runtime switch: passing it deploys through the deployments API, omitting it takes the legacy tar.gz upload. So with the gate off the flag does not exist and the lane is unreachable; with it on, the flow is chosen per invocation. +## Static Sites through the Deployments API (experimental, env-gated) -On the lane, 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. `--json` emits `{deploymentId, gitHash}`. +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. -**The lane is reachable only from `base44 site deploy`** — the command the build sandbox drives, since it ships the site rather than the whole project. The fork is a plain `if` in that command's action: `options.gitHash` present → `deployStaticSite()`, absent → `deploySite()` (the tar.gz upload). There is no transport-abstraction layer between the command and the two flows; each branch owns its spinner labels and its result shape. +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`). -`base44 deploy` is untouched by this lane: it keeps shipping the site through `deployAll()`'s legacy tar.gz step exactly as before, gate on or off. The sandbox runs `base44 site deploy -y --json --git-hash ` with a scoped `apps:deploy` workspace key. When the unified deploy should adopt the lane too, that is a deliberate follow-up. +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` 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); specs pass `--git-hash` to select the lane. Manifest and ignore-pattern unit tests live in `tests/core/site-manifest.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) - **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 +- **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 the gate is off — the tar.gz site path must not change, and nothing about the lane (flags, help text, output) may surface +- **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/docs/resources.md b/docs/resources.md index 0932813bf..d3c4f1f56 100644 --- a/docs/resources.md +++ b/docs/resources.md @@ -78,19 +78,25 @@ 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 exposes **two ways to ship `site.outputDirectory`**, and the caller picks: +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 { deploySite, deployStaticSite } from "@/core/site/index.js"; +import { planAppDeploy } from "@/core/site/index.js"; -// Legacy: tar.gz the built files, POST /api/apps/{app_id}/deploy-dist -const { appUrl } = await deploySite(outputDir); - -// Deployments API (env-gated lane, see deployments.md) -const { deploymentId } = await deployStaticSite({ outputDir, gitHash }); +const plan = await planAppDeploy(project); +// { kind: "full-stack" } | { kind: "static-deployment", outputDir } +// | { kind: "static", outputDir } | { kind: "none" } ``` -`base44 site deploy` chooses between them on whether `--git-hash` was passed; `base44 deploy` always uses `deploySite()` via `deployAll()`. The lane's own files are `gate.ts`, `manifest.ts`, `static-site.ts`, and `upload.ts`; both transports share the module's `api.ts` and `schema.ts`. +- 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. + +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 @@ -120,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 (if `site.outputDirectory` is configured) — the legacy tar.gz upload. The env-gated deployments-API lane is reachable only from `base44 site deploy`, not from here (see [deployments.md](deployments.md)). +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/docs/testing.md b/docs/testing.md index f34052dc6..9f06316d5 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 @@ -300,15 +301,18 @@ t.api.mockFunctionLogsError("my-function", { status: 500, body: { error: "Server ### 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). +See [deployments.md](deployments.md) for the API contract. Requests are captured for assertions: `t.api.deploymentCreateRequests` (JSON bodies), `t.api.assetUploadRequests` (cf arm — Authorization header, `base64` query, multipart fields), `t.api.presignedUploadRequests` (s3 arm — 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 }] }, + // cf arm shown; also {type: "s3", uploads: [{path, content_type, content_length, url}]} + // 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/site/deploy.ts b/packages/cli/src/cli/commands/site/deploy.ts index eecdda568..060499dde 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 { InvalidArgumentError, Option } from "commander"; @@ -7,11 +6,15 @@ import type { CLIContext, RunCommandResult } from "@/cli/types.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, + deployFullStack, deploySite, deployStaticSite, MAX_UPLOAD_CONCURRENCY, + planAppDeploy, + resolveGitHash, } from "@/core/site/index.js"; import { isGitCommitHash } from "@/core/utils/git.js"; @@ -32,23 +35,29 @@ async function deployAction( } const { project } = await readProjectConfig(); + const planned = await planAppDeploy(project); - const outputDirectory = project.site?.outputDirectory; - - if (!outputDirectory) { + if (planned.kind === "none") { throw new ConfigNotFoundError("No site configuration found.", { hints: [ { 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 ${outputDirectory}?`, + message: + planned.kind === "full-stack" + ? "Deploy full-stack app?" + : `Deploy site from ${project.site?.outputDirectory}?`, }); if (isCancel(shouldDeploy) || !shouldDeploy) { @@ -58,58 +67,78 @@ async function deployAction( await maybeBuildBeforeDeploy(ctx, project, options.build); - const outputDir = resolve(project.root, outputDirectory); + // 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); - // A commit means a deployments-API deploy: a deployment is addressed by the - // commit that produced the build. Without one, ship the legacy tar.gz upload. - const { gitHash, concurrency } = options; + 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 gitHash - ? await deployToDeploymentsApi(ctx, outputDir, gitHash, concurrency) - : await deployTarball(ctx, outputDir); + return deploymentResult(ctx, deploymentId, gitHash); } async function deployToDeploymentsApi( - { runTask, log, jsonMode }: CLIContext, + ctx: CLIContext, + projectRoot: string, outputDir: string, - gitHash: string, - concurrency?: number, + options: DeployOptions, ): Promise { - const progressLines: string[] = []; + const gitHash = await resolveGitHash(projectRoot, options.gitHash); - const { deploymentId } = await runTask( - "Deploying site...", - async (updateMessage) => + const { deploymentId } = await runDeployTask( + ctx, + { + start: "Deploying site...", + success: "Site deployed", + error: "Site deploy failed", + }, + async (progress) => await deployStaticSite({ outputDir, gitHash, - concurrency, - progress: { - 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`); - }, - }, + concurrency: options.concurrency, + progress, }), - { successMessage: "Site deployed", errorMessage: "Site deploy failed" }, ); - for (const line of progressLines) { - log.message(theme.styles.dim(line)); - } - - // 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 ${deploymentId} (commit ${gitHash.slice(0, 12)})`, - stdout: jsonMode - ? `${JSON.stringify({ deploymentId, gitHash }, null, 2)}\n` - : undefined, - }; + return deploymentResult(ctx, deploymentId, gitHash); } async function deployTarball( @@ -128,37 +157,98 @@ async function deployTarball( 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; +} + +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 { - const command = new Base44Command("deploy") - .description("Deploy built site files to Base44 hosting") + 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)"); - - // Only registered on the enabled lane, so with the gate off the flag is - // absent from --help and rejected as an unknown option. - if (staticDeploymentsEnabled()) { - command.addOption( + .option("--no-build", "Deploy without building (skips the prompt)") + .addOption( new Option( "--git-hash ", - "Commit the build came from — deploys through the deployments API", - ).argParser((value) => { - if (!isGitCommitHash(value)) { - throw new InvalidArgumentError( - "Expected a git commit hash (7-64 hex chars).", - ); - } - return value; - }), - ); - command.addOption( + "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); +} + +function parseGitHash(value: string): string { + if (!isGitCommitHash(value)) { + throw new InvalidArgumentError( + "Expected a git commit hash (7-64 hex chars).", ); } - - return command.action(deployAction); + return value; } function parseConcurrency(value: string): number { @@ -174,10 +264,3 @@ function parseConcurrency(value: string): number { } return parsed; } - -function staticDeploymentsEnabled( - env: NodeJS.ProcessEnv = process.env, -): boolean { - const value = env.BASE44_STATIC_DEPLOYMENTS; - return value === "1" || value === "true"; -} diff --git a/packages/cli/src/core/site/api.ts b/packages/cli/src/core/site/api.ts index bdffd2291..7a396d806 100644 --- a/packages/cli/src/core/site/api.ts +++ b/packages/cli/src/core/site/api.ts @@ -6,6 +6,8 @@ import type { CreateDeploymentResponse, DeployResponse, FinalizeDeploymentResponse, + ModuleType, + WorkerModule, } from "@/core/site/schema.js"; import { CreateDeploymentResponseSchema, @@ -50,6 +52,14 @@ 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 { @@ -77,6 +87,27 @@ export async function createDeployment( return result.data; } +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); +} + /** * The form carries exactly one file part — `index.html`, the sentinel that * completes the deployment — and nothing else. 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..3ea5f7d25 --- /dev/null +++ b/packages/cli/src/core/site/deploy-app.ts @@ -0,0 +1,39 @@ +import { resolve } from "node:path"; +import { staticDeploymentsEnabled } from "./static-site.js"; +import { detectFullStackArtifact } from "./wrangler-config.js"; + +interface AppSiteTarget { + root: string; + site?: { outputDirectory?: string }; +} + +/** Which transport ships this project's built output. */ +type AppDeployPlan = + | { kind: "full-stack" } + | { kind: "static-deployment"; outputDir: string } + | { kind: "static"; outputDir: string } + | { kind: "none" }; + +/** + * 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. + */ +export 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" }; + } + const outputDir = resolve(target.root, outputDirectory); + return staticDeploymentsEnabled() + ? { kind: "static-deployment", outputDir } + : { kind: "static", outputDir }; +} diff --git a/packages/cli/src/core/site/full-stack.ts b/packages/cli/src/core/site/full-stack.ts new file mode 100644 index 000000000..98ae8056e --- /dev/null +++ b/packages/cli/src/core/site/full-stack.ts @@ -0,0 +1,143 @@ +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. + */ +export async function deployFullStack(options: { + projectRoot: string; + gitHash: string; + concurrency?: number; + progress?: DeploymentProgress; +}): Promise { + const { projectRoot, gitHash, concurrency, progress } = options; + + const config = await resolveWranglerConfig(projectRoot); + + // 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 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 }); + + // 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, + onProgress: 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. The + * unsupported fields would change runtime behavior if dropped silently, 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/site/git-hash.ts b/packages/cli/src/core/site/git-hash.ts new file mode 100644 index 000000000..0b858e235 --- /dev/null +++ b/packages/cli/src/core/site/git-hash.ts @@ -0,0 +1,38 @@ +import { execa } from "execa"; +import { InvalidInputError } from "@/core/errors.js"; +import { isGitCommitHash } from "@/core/utils/git.js"; + +/** An explicit hash (flag/automation) wins over the checkout's HEAD. */ +export async function resolveGitHash( + projectRoot: string, + explicit?: string, +): Promise { + const hash = explicit ?? (await gitHead(projectRoot)); + if (!hash || !isGitCommitHash(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/site/index.ts b/packages/cli/src/core/site/index.ts index 523f1faf8..e57020862 100644 --- a/packages/cli/src/core/site/index.ts +++ b/packages/cli/src/core/site/index.ts @@ -1,7 +1,12 @@ 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/site/manifest.ts b/packages/cli/src/core/site/manifest.ts index 4df018841..b5cfebdb3 100644 --- a/packages/cli/src/core/site/manifest.ts +++ b/packages/cli/src/core/site/manifest.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; import { readFile, stat } from "node:fs/promises"; -import { basename, join } from "node:path"; +import { basename, extname, join } from "node:path"; import { globby } from "globby"; import { InvalidInputError } from "@/core/errors.js"; import type { @@ -21,6 +21,44 @@ const ALWAYS_IGNORED = 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", +}; + +/** 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" + ); +} + /** * 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 @@ -37,7 +75,8 @@ export function hashAsset(appId: string, content: Buffer): string { /** * Walk the assets directory and build the deployment asset manifest. Honors * `.assetsignore` at the assets root with full gitignore semantics, negation - * included. + * included. Rejects files larger than 25 MiB and caps the total file count at + * 100,000. */ export async function buildAssetManifest( assetsDir: string, @@ -82,7 +121,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/site/modules.ts b/packages/cli/src/core/site/modules.ts new file mode 100644 index 000000000..d55d1eb54 --- /dev/null +++ b/packages/cli/src/core/site/modules.ts @@ -0,0 +1,135 @@ +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, 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, +): 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, + }); + } + } + } + + 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/site/schema.ts b/packages/cli/src/core/site/schema.ts index 9897fa2e9..56ec976e4 100644 --- a/packages/cli/src/core/site/schema.ts +++ b/packages/cli/src/core/site/schema.ts @@ -17,6 +17,16 @@ export const PublishedUrlResponseSchema = z.object({ url: z.string(), }); +export type ModuleType = "esm" | "sourcemap" | "wasm" | "text" | "data"; + +export interface WorkerModule { + /** Path relative to the wrangler config dir, forward slashes. */ + name: string; + absolutePath: string; + size: number; + type: ModuleType; +} + /** Manifest entry keyed by URL-ish path ("/index.html"). */ export interface AssetManifestEntry { hash: string; @@ -27,6 +37,7 @@ export interface AssetFile { absolutePath: string; hash: string; size: number; + contentType: string; } export interface AssetManifestResult { @@ -40,17 +51,29 @@ export interface AssetUploadProgress { } export interface DeploymentProgress { + onWarning?: (message: string) => void; onAssets?: (info: { totalAssets: number; newAssets: number }) => void; onAssetUpload?: (progress: AssetUploadProgress) => void; + onWorker?: (info: { moduleCount: number }) => void; } /** - * A request without a worker config is a static-site deployment, which the - * server answers with the `s3` arm of the create response. The deployment id - * is derived from `git_hash`, so re-deploying a commit is idempotent. + * 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, no `config` field at all 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; } @@ -63,6 +86,24 @@ export interface PresignedAssetUpload { url: string; } +/** + * 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"; + 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[]; @@ -70,24 +111,32 @@ interface S3AssetUploads { /** * `asset_uploads` says where the assets still owed should go, discriminated on - * `type`. The `s3` arm always excludes `/index.html` (finalize carries it), and - * the whole field is null when nothing is owed. + * `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({ 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(), }) @@ -96,21 +145,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, + })), + }, }), ); @@ -118,6 +169,17 @@ export type CreateDeploymentResponse = z.infer< typeof CreateDeploymentResponseSchema >; +/** + * 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 + .looseObject({ jwt: z.string().nullable().optional() }) + .nullable() + .optional(), +}); + export const FinalizeDeploymentResponseSchema = z .object({ deployment_id: z.string(), diff --git a/packages/cli/src/core/site/static-site.ts b/packages/cli/src/core/site/static-site.ts index 0ab1f3067..4ef808fa0 100644 --- a/packages/cli/src/core/site/static-site.ts +++ b/packages/cli/src/core/site/static-site.ts @@ -1,12 +1,26 @@ 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"; 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: with it off, a static output keeps taking the legacy tar.gz + * 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, create the deployment at the commit's @@ -18,7 +32,7 @@ export async function deployStaticSite(options: { gitHash: string; concurrency?: number; progress?: DeploymentProgress; -}): Promise<{ deploymentId: string }> { +}): Promise<{ deploymentId: string; gitHash: string }> { const { outputDir, gitHash, concurrency, progress } = options; const assets = await buildAssetManifest(outputDir, getAppContext().id); @@ -34,6 +48,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.`, + ); + } + progress?.onAssets?.({ totalAssets: Object.keys(assets.manifest).length, newAssets: created.assetUploads?.uploads.length ?? 0, @@ -52,5 +72,5 @@ export async function deployStaticSite(options: { new Uint8Array(indexHtml), ); - return { deploymentId: finalized.deploymentId }; + return { deploymentId: finalized.deploymentId, gitHash }; } diff --git a/packages/cli/src/core/site/upload.ts b/packages/cli/src/core/site/upload.ts index e9ccbaa25..6c14d6971 100644 --- a/packages/cli/src/core/site/upload.ts +++ b/packages/cli/src/core/site/upload.ts @@ -1,12 +1,16 @@ import { readFile } from "node:fs/promises"; -import ky from "ky"; +import type { KyResponse } from "ky"; +import ky, { HTTPError } from "ky"; import pMap from "p-map"; import { ApiError, InternalError } from "@/core/errors.js"; import type { + AssetFile, AssetManifestResult, AssetUploadProgress, + CfAssetUploads, PresignedAssetUpload, } from "./schema.js"; +import { AssetUploadResponseSchema } from "./schema.js"; export const DEFAULT_UPLOAD_CONCURRENCY = 3; @@ -16,6 +20,117 @@ export const MAX_UPLOAD_CONCURRENCY = 50; const MAX_UPLOAD_ATTEMPTS = 3; const RETRY_BASE_DELAY_MS = 500; +/** + * 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, + 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, and return the completion JWT finalize needs. + */ +export async function uploadAssetBuckets( + target: CfAssetUploads, + filesByHash: Map, + options: { + concurrency?: number; + onProgress?: (progress: AssetUploadProgress) => void; + } = {}, +): Promise { + const { concurrency = DEFAULT_UPLOAD_CONCURRENCY, onProgress } = options; + const { buckets } = target; + const totalFiles = buckets.reduce((sum, bucket) => sum + bucket.length, 0); + let uploadedFiles = 0; + let completionJwt: string | null = null; + + await pMap( + buckets, + async (bucket) => { + const jwt = await uploadAssetBucket(target, bucket, filesByHash); + if (jwt) { + completionJwt = jwt; + } + uploadedFiles += bucket.length; + onProgress?.({ uploadedFiles, totalFiles }); + }, + { concurrency }, + ); + + if (!completionJwt) { + throw new ApiError( + "Asset upload finished but the server did not return a completion token.", + ); + } + + return completionJwt; +} + +async function uploadAssetBucket( + target: CfAssetUploads, + bucket: string[], + filesByHash: Map, +): Promise { + const formData = await buildBucketForm(bucket, filesByHash); + + let response: KyResponse; + try { + // 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 naming it is what + // makes these uploads 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"); + } + + const parsed = AssetUploadResponseSchema.safeParse(await response.json()); + const jwt = parsed.success ? parsed.data.result?.jwt : null; + return jwt || null; +} + +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. A presigned URL * carries its own authorization in the query string, so each request is a plain @@ -63,12 +178,8 @@ 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"); diff --git a/packages/cli/src/core/site/wrangler-config.ts b/packages/cli/src/core/site/wrangler-config.ts new file mode 100644 index 000000000..dda3ae70f --- /dev/null +++ b/packages/cli/src/core/site/wrangler-config.ts @@ -0,0 +1,175 @@ +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"); + +const RedirectConfigSchema = z.looseObject({ + configPath: z.string().min(1), +}); + +// 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(), + 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 { + configPath: string; + /** Module paths — `main` and the rules globs — are relative to this. */ + configDir: string; + main: string; + 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: the redirect file + * emitted by @cloudflare/vite-plugin builds. + * + * A hand-authored root wrangler config is deliberately not an artifact. Those + * 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, +): Promise { + const redirectPath = join(projectRoot, WRANGLER_REDIRECT_PATH); + return (await pathExists(redirectPath)) ? redirectPath : null; +} + +/** Throws when there is no artifact, or when the build still needs 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, + ); + } + + // 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( + `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/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 new file mode 100644 index 000000000..586dea203 --- /dev/null +++ b/packages/cli/tests/cli/fullstack_deploy.spec.ts @@ -0,0 +1,273 @@ +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"; + +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 fixture is not a git repo, so every deploy passes --git-hash. */ +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("site deploy command (full-stack)", () => { + const t = setupCLITests(); + + function mockHappyPath(options?: { buckets?: string[][] }) { + 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("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}`); + + 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"]); + 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 }, + }); + 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"); + + 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, + ); + + 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")); + 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(); + 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")); + + 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( + "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); + }); + + it("outputs a single JSON document with --json", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + mockHappyPath(); + + const result = await t.run( + "site", + "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")); + 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("site", "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("retries a bucket upload after a transient failure, resending the body", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + 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]], + }, + }); + t.api.mockAssetUploadAfterFailures(2, "completion-jwt"); + t.api.mockDeploymentFinalize({ deployment_id: DEPLOYMENT_ID }); + + const result = await t.run("site", "deploy", "-y", "--git-hash", GIT_HASH); + + t.expectResult(result).toSucceed(); + expect(t.api.assetUploadRequests).toHaveLength(3); + // 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); + expect( + Buffer.from(field?.data.toString() ?? "", "base64").toString(), + ).toBe(INDEX_HTML); + } + 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")); + 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("site", "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")); + t.api.mockError("post", `/api/apps/${t.api.appId}/deployments`, { + status: 422, + body: { message: "unsupported artifact" }, + }); + + 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/site_deploy.spec.ts b/packages/cli/tests/cli/site_deploy.spec.ts index aff60681f..b6f5a1e0f 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 fullstack fixture is not a git repo, so these deploys pass --git-hash. */ +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 4d892e089..a28985137 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; } @@ -59,33 +60,6 @@ describe("site deploy command (static site through the deployments API, env-gate return await readFile(join(fixture("with-site"), "site-output", name)); } - it("keeps --git-hash out of the help while the gate is off", async () => { - const siteDeployHelp = await t.run("site", "deploy", "--help"); - - t.expectResult(siteDeployHelp).toSucceed(); - t.expectResult(siteDeployHelp).toContain("--build"); - t.expectResult(siteDeployHelp).toNotContain("--git-hash"); - }); - - it("rejects --git-hash outright while the gate is off", async () => { - await t.givenLoggedInWithProject(fixture("with-site")); - - const result = await t.run("site", "deploy", "-y", "--git-hash", GIT_HASH); - - t.expectResult(result).toFail(); - t.expectResult(result).toContain("unknown option"); - expect(t.api.deploymentCreateRequests).toHaveLength(0); - }); - - it("shows --git-hash on site deploy once the gate is on", async () => { - t.givenEnv({ BASE44_STATIC_DEPLOYMENTS: "1" }); - - const result = await t.run("site", "deploy", "--help"); - - t.expectResult(result).toSucceed(); - t.expectResult(result).toContain("--git-hash"); - }); - it("keeps the legacy tar.gz site upload when the gate is off", async () => { await t.givenLoggedInWithProject(fixture("with-site")); t.api.mockSiteDeploy({ app_url: "https://legacy.example.com" }); @@ -108,7 +82,7 @@ describe("site deploy command (static site through the deployments API, env-gate 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_ID); + t.expectResult(result).toContain(`Deployment ${DEPLOYMENT_ID}`); expect(t.api.deploymentCreateRequests).toHaveLength(1); const body = t.api.deploymentCreateRequests[0] as CreateBody; @@ -180,15 +154,14 @@ describe("site deploy command (static site through the deployments API, env-gate }); }); - it("takes the legacy path when the gate is on but no commit is passed", async () => { + it("requires a commit hash outside a git checkout", async () => { await t.givenLoggedInWithProject(fixture("with-site")); t.givenEnv({ BASE44_STATIC_DEPLOYMENTS: "1" }); - t.api.mockSiteDeploy({ app_url: "https://legacy.example.com" }); const result = await t.run("site", "deploy", "-y"); - t.expectResult(result).toSucceed(); - t.expectResult(result).toContain("https://legacy.example.com"); + t.expectResult(result).toFail(); + t.expectResult(result).toContain("--git-hash"); expect(t.api.deploymentCreateRequests).toHaveLength(0); }); @@ -236,10 +209,22 @@ describe("site deploy command (static site through the deployments API, env-gate t.expectResult(huge).toContain("between 1 and 50"); }); - it("hides --concurrency while the gate is off", async () => { - const result = await t.run("site", "deploy", "--help"); + 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" }); + 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).toNotContain("--concurrency"); + t.expectResult(result).toContain("Full-stack app deployed"); + 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..dadfa8645 100644 --- a/packages/cli/tests/cli/testkit/TestAPIServer.ts +++ b/packages/cli/tests/cli/testkit/TestAPIServer.ts @@ -207,15 +207,18 @@ interface CreateAppResponse { 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,12 @@ function parseMultipart( return fields; } +interface CapturedAssetUpload { + authorization?: string; + base64Query?: string; + fields: MultipartField[]; +} + /** A captured presigned-style asset PUT. */ interface CapturedPresignedUpload { path: string; @@ -655,6 +664,8 @@ export class TestAPIServer { /** 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 +687,46 @@ 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`. Captures each request in + * `assetUploadRequests`. + */ + mockAssetUpload(completionJwt: string): this { + return this.mockAssetUploadAfterFailures(0, completionJwt); + } + + /** + * Same target as {@link mockAssetUpload}, but the first `failures` requests + * 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; + 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"] ?? "", + ), + }); + seen++; + if (seen <= failures) { + res.status(503).json({ error: "Service Unavailable" }); + return; + } + 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,6 +750,11 @@ 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`. diff --git a/packages/cli/tests/core/site-manifest.spec.ts b/packages/cli/tests/core/site-manifest.spec.ts index 5974ed2b5..063a3a141 100644 --- a/packages/cli/tests/core/site-manifest.spec.ts +++ b/packages/cli/tests/core/site-manifest.spec.ts @@ -59,6 +59,11 @@ describe("buildAssetManifest", () => { }); const entry = manifest["/assets/app.js"]; expect(filesByHash.get(entry.hash)?.size).toBe(entry.size); + // Only the cf arm reads contentType; the s3 arm echoes the signed value. + 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/site-modules.spec.ts b/packages/cli/tests/core/site-modules.spec.ts new file mode 100644 index 000000000..c8c9593be --- /dev/null +++ b/packages/cli/tests/core/site-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/site/modules.js"; +import type { ResolvedWranglerConfig } from "@/core/site/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/site-wrangler-config.spec.ts b/packages/cli/tests/core/site-wrangler-config.spec.ts new file mode 100644 index 000000000..22dce2d15 --- /dev/null +++ b/packages/cli/tests/core/site-wrangler-config.spec.ts @@ -0,0 +1,140 @@ +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/site/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 () => { + // 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'); + + 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": [] +}