diff --git a/packages/cli/src/core/errors.ts b/packages/cli/src/core/errors.ts index a93d1bf9..b57ab370 100644 --- a/packages/cli/src/core/errors.ts +++ b/packages/cli/src/core/errors.ts @@ -288,6 +288,28 @@ export class DependencyNotFoundError extends UserError { // System Errors // ============================================================================ +/** Keeps an unparseable error body loggable without dumping a whole HTML page. */ +const MAX_RESPONSE_TEXT_CHARS = 500; + +/** + * The response body as text, for a response whose body would not parse as JSON. + * Reads a clone so the caller's own body stays unconsumed, and never throws — + * this runs on a path that is already reporting a failure. + */ +async function readResponseText( + response: Response, +): Promise { + try { + const text = (await response.clone().text()).trim(); + if (!text) return undefined; + return text.length > MAX_RESPONSE_TEXT_CHARS + ? `${text.slice(0, MAX_RESPONSE_TEXT_CHARS)}…` + : text; + } catch { + return undefined; + } +} + interface ApiErrorOptions extends CLIErrorOptions { statusCode?: number; requestUrl?: string; @@ -357,7 +379,18 @@ export class ApiError extends SystemError { details = parseErrorDetails(parsedData.extra_data); } } catch { - message = error.message; + // A non-JSON body is the one case where the server's own words are + // lost: ky's message names only the status and URL, so an opaque 403 + // or 502 reads identically whoever emitted it. Keep the bytes (and, + // when there are none, the content type) — they identify the layer + // that answered, which JSON from our API would have named outright. + const body = await readResponseText(error.response); + responseBody = body; + message = body + ? `${error.message} — ${body}` + : `${error.message} — empty body, content-type ${ + error.response.headers.get("content-type") ?? "absent" + }`; } const statusCode = ApiError.normalizeStatusCode( diff --git a/packages/cli/src/core/site/api.ts b/packages/cli/src/core/site/api.ts index ab004967..4071d9c1 100644 --- a/packages/cli/src/core/site/api.ts +++ b/packages/cli/src/core/site/api.ts @@ -88,14 +88,15 @@ export async function createDeployment( } /** - * What completes a deployment — the one thing the two kinds of build send - * differently. A build that produced a worker completes with its modules and - * the asset completion token; a plain static build completes with the - * `index.html` sentinel, which is the whole form and carries no payload part. + * What completes a deployment. A worker build sends its modules and the asset + * completion token. A static build sends nothing at all against a current + * server — its entry point went up through the presigned PUTs and finalize + * copies it into place — and the `index.html` bytes against an older one. */ -type FinalizePayload = - | { modules: WorkerModule[]; completionJwt: string | null } - | { indexHtml: Uint8Array }; +export type FinalizePayload = + | { kind: "worker"; modules: WorkerModule[]; completionJwt: string | null } + | { kind: "static-inline"; indexHtml: Uint8Array } + | { kind: "static-staged" }; export async function finalizeDeployment( deploymentId: string, @@ -104,12 +105,12 @@ export async function finalizeDeployment( ): Promise { const formData = new FormData(); - if ("indexHtml" in payload) { + if (payload.kind === "static-inline") { formData.append( "index.html", new File([payload.indexHtml], "index.html", { type: "text/html" }), ); - } else { + } else if (payload.kind === "worker") { formData.append( "payload", JSON.stringify({ completion_jwt: payload.completionJwt }), diff --git a/packages/cli/src/core/site/deployment.ts b/packages/cli/src/core/site/deployment.ts index a73e3104..d7f35461 100644 --- a/packages/cli/src/core/site/deployment.ts +++ b/packages/cli/src/core/site/deployment.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { InvalidInputError } from "@/core/errors.js"; import { getAppContext } from "@/core/project/app-config.js"; import { pathExists } from "@/core/utils/fs.js"; +import type { FinalizePayload } from "./api.js"; import { createDeployment, finalizeDeployment } from "./api.js"; import { buildAssetManifest } from "./manifest.js"; import { collectModules } from "./modules.js"; @@ -28,9 +29,6 @@ interface WorkerBuild { assetsDir: string | null; } -/** What completes the deployment at finalize. */ -type Completion = { modules: WorkerModule[] } | { indexHtml: Uint8Array }; - const NO_ASSETS: AssetManifestResult = { manifest: {}, filesByHash: new Map() }; const DEPLOYMENTS_API_ENV = "BASE44_DEPLOYMENTS_API"; @@ -81,11 +79,11 @@ export async function deployToDeployments(options: { ? await buildAssetManifest(assetsDir, getAppContext().id) : NO_ASSETS; - // Resolved before the create call so a build that cannot be completed fails - // before any upload work. - const completion: Completion = worker - ? { modules: worker.modules } - : { indexHtml: await readIndexHtml(assetsDir, assets) }; + // Checked before the create call so a build that cannot be completed fails + // before any upload work — the bytes are only read if finalize carries them. + if (!worker) { + requireStaticEntryPoint(assetsDir, assets); + } const created = await createDeployment({ git_hash: gitHash, @@ -99,13 +97,17 @@ export async function deployToDeployments(options: { { concurrency, progress }, ); - if ("modules" in completion) { + const completion: FinalizePayload = worker + ? { kind: "worker", modules: worker.modules, completionJwt } + : await resolveStaticCompletion(assetsDir, assets, created.indexHtmlStaged); + + if (completion.kind === "worker") { progress?.onWorker?.({ moduleCount: completion.modules.length }); } const finalized = await finalizeDeployment( created.deploymentId, created.sessionId, - "modules" in completion ? { ...completion, completionJwt } : completion, + completion, ); return { deploymentId: finalized.deploymentId, gitHash }; @@ -140,20 +142,38 @@ async function resolveWorkerBuild( } /** - * Finalize carries these bytes by contract when no worker completes the - * deployment, so a build with no index.html at its root is broken — or the - * configured outputDirectory points at the wrong place. + * A static build is addressed by its entry point, so one without an index.html + * at its root is broken — or the configured outputDirectory points at the wrong + * place. Checked against the manifest, which is already built. */ -async function readIndexHtml( +function requireStaticEntryPoint( assetsDir: string | null, assets: AssetManifestResult, -): Promise { +): string { if (!assetsDir || !assets.manifest["/index.html"]) { throw new InvalidInputError( `No index.html found in "${assetsDir ?? "the site output directory"}" — a static site needs one at the output directory root.`, ); } - return new Uint8Array(await readFile(join(assetsDir, "index.html"))); + return assetsDir; +} + +/** + * How a static build completes. A current server stages the entry point with + * the other presigned uploads and copies it in, so finalize sends nothing; + * older ones still expect the bytes in the request body. + */ +async function resolveStaticCompletion( + assetsDir: string | null, + assets: AssetManifestResult, + indexHtmlStaged: boolean, +): Promise { + if (indexHtmlStaged) return { kind: "static-staged" }; + const dir = requireStaticEntryPoint(assetsDir, assets); + return { + kind: "static-inline", + indexHtml: new Uint8Array(await readFile(join(dir, "index.html"))), + }; } /** diff --git a/packages/cli/src/core/site/schema.ts b/packages/cli/src/core/site/schema.ts index 97fd7d4d..f0883f75 100644 --- a/packages/cli/src/core/site/schema.ts +++ b/packages/cli/src/core/site/schema.ts @@ -140,6 +140,9 @@ export const CreateDeploymentResponseSchema = z ]) .nullable() .optional(), + // Absent on a server predating staged entry points, where finalize is still + // the only thing that writes index.html. + index_html_staged: z.boolean().optional().default(false), }) .transform( ( @@ -148,9 +151,11 @@ export const CreateDeploymentResponseSchema = z deploymentId: string; sessionId: string; assetUploads: CfAssetUploads | S3AssetUploads | null; + indexHtmlStaged: boolean; } => ({ deploymentId: data.deployment_id, sessionId: data.session_id, + indexHtmlStaged: data.index_html_staged, assetUploads: data.asset_uploads == null ? null diff --git a/packages/cli/tests/cli/static_site_deployments.spec.ts b/packages/cli/tests/cli/static_site_deployments.spec.ts index 9d66d2fc..46f81e35 100644 --- a/packages/cli/tests/cli/static_site_deployments.spec.ts +++ b/packages/cli/tests/cli/static_site_deployments.spec.ts @@ -15,11 +15,12 @@ const SESSION_ID = "3f9a1c07b8e44d2f"; const SIGNED_CONTENT_TYPES: Record = { "/main.js": "application/javascript", "/styles.css": "text/css", + "/index.html": "text/html", }; /** Byte counts the server signs into the URLs (from the real fixture files). */ const FIXTURE_SIZES: Record = Object.fromEntries( - ["/main.js", "/styles.css"].map((path) => [ + ["/main.js", "/styles.css", "/index.html"].map((path) => [ path, readFileSync(join(fixture("with-site"), "site-output", path.slice(1))) .length, @@ -36,10 +37,11 @@ describe("site deploy command (static site through the deployments API, env-gate const t = setupCLITests(); /** The s3 create arm: presigned PUT targets for the requested paths. */ - function mockStaticCreate(uploadPaths: string[]) { + function mockStaticCreate(uploadPaths: string[], indexHtmlStaged = false) { t.api.mockDeploymentCreate({ deployment_id: DEPLOYMENT_ID, session_id: SESSION_ID, + ...(indexHtmlStaged ? { index_html_staged: true } : {}), asset_uploads: uploadPaths.length === 0 ? null @@ -301,3 +303,56 @@ describe("site deploy command (static site through the deployments API, env-gate expect(t.api.presignedUploadRequests).toHaveLength(0); }); }); + +describe("site deploy — index.html staged through the presigned PUTs", () => { + const t = setupCLITests(); + + async function readSiteFile(name: string): Promise { + return await readFile(join(fixture("with-site"), "site-output", name)); + } + + function mockStagedCreate(uploadPaths: string[]) { + t.api.mockDeploymentCreate({ + deployment_id: DEPLOYMENT_ID, + session_id: SESSION_ID, + index_html_staged: true, + asset_uploads: { + type: "s3" as const, + uploads: uploadPaths.map((path) => ({ + path, + content_type: `${SIGNED_CONTENT_TYPES[path]}; charset=utf-8`, + content_length: FIXTURE_SIZES[path], + url: `${t.api.baseUrl}/presigned${path}`, + })), + }, + }); + for (const path of uploadPaths) { + t.api.mockPresignedUpload(path); + } + } + + it("PUTs index.html with the other assets and finalizes with an empty form", async () => { + await t.givenLoggedInWithProject(fixture("with-site")); + t.givenEnv({ BASE44_DEPLOYMENTS_API: "1" }); + mockStagedCreate(["/index.html", "/main.js", "/styles.css"]); + t.api.mockDeploymentFinalize({ deployment_id: DEPLOYMENT_ID }); + + const result = await t.run("site", "deploy", "-y", "--git-hash", GIT_HASH); + + t.expectResult(result).toSucceed(); + + // The entry point goes up the same way as every other asset — the server + // signed it to a staging key, so nothing here has to know that. + const byPath = new Map( + t.api.presignedUploadRequests.map((r) => [r.path, r]), + ); + const index = byPath.get("/index.html"); + expect(index?.data.equals(await readSiteFile("index.html"))).toBe(true); + expect(index?.authorization).toBeUndefined(); + + // The point of the change: no user-authored HTML in a request body of ours. + expect(t.api.finalizeRequests).toHaveLength(1); + expect(t.api.finalizeRequests[0]).toEqual([]); + expect(t.api.finalizeQueries[0]).toEqual({ session_id: SESSION_ID }); + }); +}); diff --git a/packages/cli/tests/cli/testkit/TestAPIServer.ts b/packages/cli/tests/cli/testkit/TestAPIServer.ts index 0fc704cc..83123a30 100644 --- a/packages/cli/tests/cli/testkit/TestAPIServer.ts +++ b/packages/cli/tests/cli/testkit/TestAPIServer.ts @@ -252,6 +252,9 @@ interface DeploymentCreateResponse { }>; } | null; + /** True once the server stages index.html with the presigned PUTs, so + * finalize carries no file parts at all for a static build. */ + index_html_staged?: boolean; } interface DeploymentFinalizeResponse { diff --git a/packages/cli/tests/core/errors.spec.ts b/packages/cli/tests/core/errors.spec.ts index d14924f4..737348ae 100644 --- a/packages/cli/tests/core/errors.spec.ts +++ b/packages/cli/tests/core/errors.spec.ts @@ -255,7 +255,80 @@ describe("SystemError subclasses", () => { expect(apiError.statusCode).toBe(500); expect(apiError.requestUrl).toBe("https://api.base44.com/v1/deploy"); expect(apiError.requestMethod).toBe("POST"); + expect(apiError.responseBody).toBe("Internal Server Error"); + expect(apiError.message).toContain("Internal Server Error"); + }); + + it("ApiError.fromHttpError keeps a non-JSON body that names the emitting layer", async () => { + const { HTTPError } = await import("ky"); + const response = new Response( + "403 Forbidden — nginx", + { + status: 403, + statusText: "Forbidden", + headers: { "content-type": "text/html" }, + }, + ); + const request = new Request( + "https://app.base44.com/api/apps/a1/deployments/d1/finalize", + { + method: "POST", + }, + ); + + const apiError = await ApiError.fromHttpError( + new HTTPError(response, request, {} as never), + "finalizing deployment", + ); + + expect(apiError.statusCode).toBe(403); + expect(apiError.message).toContain("403 Forbidden — nginx"); + }); + + it("ApiError.fromHttpError reports the content type when the body is empty", async () => { + const { HTTPError } = await import("ky"); + const response = new Response("", { + status: 403, + statusText: "Forbidden", + headers: { "content-type": "text/plain" }, + }); + const request = new Request( + "https://app.base44.com/api/apps/a1/deployments", + { + method: "POST", + }, + ); + + const apiError = await ApiError.fromHttpError( + new HTTPError(response, request, {} as never), + "creating deployment", + ); + expect(apiError.responseBody).toBeUndefined(); + expect(apiError.message).toContain("empty body"); + expect(apiError.message).toContain("text/plain"); + }); + + it("ApiError.fromHttpError truncates a long non-JSON body", async () => { + const { HTTPError } = await import("ky"); + const response = new Response("x".repeat(2000), { + status: 502, + statusText: "Bad Gateway", + }); + const request = new Request( + "https://app.base44.com/api/apps/a1/deployments", + { + method: "POST", + }, + ); + + const apiError = await ApiError.fromHttpError( + new HTTPError(response, request, {} as never), + "creating deployment", + ); + + expect(apiError.message).toContain("…"); + expect(apiError.message.length).toBeLessThan(800); }); it("ApiError.fromHttpError handles plain Error", async () => {