Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion packages/cli/src/core/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined> {
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;
Expand Down Expand Up @@ -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(
Expand Down
19 changes: 10 additions & 9 deletions packages/cli/src/core/site/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -104,12 +105,12 @@ export async function finalizeDeployment(
): Promise<FinalizeDeploymentResponse> {
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 }),
Expand Down
52 changes: 36 additions & 16 deletions packages/cli/src/core/site/deployment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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 };
Expand Down Expand Up @@ -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<Uint8Array> {
): 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<FinalizePayload> {
if (indexHtmlStaged) return { kind: "static-staged" };
const dir = requireStaticEntryPoint(assetsDir, assets);
return {
kind: "static-inline",
indexHtml: new Uint8Array(await readFile(join(dir, "index.html"))),
};
}

/**
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/core/site/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
(
Expand All @@ -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
Expand Down
59 changes: 57 additions & 2 deletions packages/cli/tests/cli/static_site_deployments.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@ const SESSION_ID = "3f9a1c07b8e44d2f";
const SIGNED_CONTENT_TYPES: Record<string, string> = {
"/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<string, number> = 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,
Expand All @@ -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
Expand Down Expand Up @@ -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<Buffer> {
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 });
});
});
3 changes: 3 additions & 0 deletions packages/cli/tests/cli/testkit/TestAPIServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
73 changes: 73 additions & 0 deletions packages/cli/tests/core/errors.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
"<html><body>403 Forbidden — nginx</body></html>",
{
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 () => {
Expand Down
Loading