From 7a60cacb53b475cda23855d5983eb2050c0eacb9 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Mon, 18 May 2026 14:55:33 +0700 Subject: [PATCH 1/3] Send binary files via application/octet-stream in boxel-cli sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make boxel-cli's push / pull / sync / watch and the single-file `boxel file write` / `boxel file read` commands handle binary assets (images, PDFs, fonts, etc.) byte-identically by mirroring the host package's wire format: a per-file POST with `Content-Type: application/octet-stream` and raw bytes as the body, which the realm-server already routes to `upsertBinaryFile`. Detection uses the existing `isBinaryFilename` helper from runtime-common, so SVG (XML-based) stays on the text path while `image/*`, `font/*`, `application/pdf`, and `.eot` switch to bytes. The /_atomic batch endpoint stays text-only — binary entries are carved out and POSTed per-file alongside the atomic call, matching the host package which never sends binary through /_atomic. Adds binary roundtrip tests across realm-push, realm-pull, realm-watch, file-write, and file-read using an inline 1x1 transparent PNG fixture so non-UTF-8 bytes (PNG signature, IDAT chunk) round-trip verbatim. Linear: CS-11075 Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/cs-11075-binary-upload-plan.md | 89 +++++++++++ packages/boxel-cli/src/commands/file/read.ts | 45 +++++- packages/boxel-cli/src/commands/file/write.ts | 39 +++-- packages/boxel-cli/src/lib/realm-sync-base.ts | 140 ++++++++++++++++-- .../tests/helpers/binary-fixtures.ts | 24 +++ .../tests/integration/file-read.test.ts | 22 +++ .../tests/integration/file-write.test.ts | 30 ++++ .../tests/integration/realm-pull.test.ts | 25 ++++ .../tests/integration/realm-push.test.ts | 139 +++++++++++++++++ .../tests/integration/realm-watch.test.ts | 41 +++++ 10 files changed, 569 insertions(+), 25 deletions(-) create mode 100644 docs/cs-11075-binary-upload-plan.md create mode 100644 packages/boxel-cli/tests/helpers/binary-fixtures.ts diff --git a/docs/cs-11075-binary-upload-plan.md b/docs/cs-11075-binary-upload-plan.md new file mode 100644 index 00000000000..cdc8d6aa7b1 --- /dev/null +++ b/docs/cs-11075-binary-upload-plan.md @@ -0,0 +1,89 @@ +# CS-11075 — Ensure binary file uploads work reliably via boxel-cli + +Linear: https://linear.app/cardstack/issue/CS-11075 +Branch: `cs-11075-ensure-that-uploading-binary-files-to-realm-works-reliably` + +## Context + +`boxel-cli`'s realm-sync code reads every file off disk with `'utf8'` encoding and writes every downloaded file back with `'utf8'`. For text (`.gts`, `.json`, `.md`) this is fine; for binary assets (PNG, JPEG, PDF, fonts, etc.) it silently corrupts bytes — invalid UTF-8 sequences round-trip through `String`/`JSON.stringify` and get replaced with `U+FFFD` or truncated. The realm-server already has a working binary endpoint (`Content-Type: application/octet-stream` → `upsertBinaryFile` at `packages/runtime-common/realm.ts:3282`, routed at `realm.ts:928-932`), and the Ember host package already uses it correctly. boxel-cli simply never takes that path. + +This ticket fixes the upload + download + watch flows in boxel-cli so a folder containing images / PDFs / fonts pushed via `boxel workspace push` lands in the realm intact, and the same bytes come back through `pull` / `sync` / `watch`. + +## Current behavior + +- `packages/boxel-cli/src/lib/realm-sync-base.ts:399` — `uploadFile` does `fs.readFile(localPath, 'utf8')` and POSTs with `Content-Type: text/plain;charset=UTF-8`, `Accept: SupportedMimeType.CardSource`. +- `packages/boxel-cli/src/lib/realm-sync-base.ts:454` — `uploadFilesAtomic` does `fs.readFile(localPath, 'utf8')` and embeds the string inside `data.attributes.content` of an `application/vnd.api+json` payload to `/_atomic`. Binary cannot survive this JSON serialization. +- `packages/boxel-cli/src/lib/realm-sync-base.ts:562-567` — `downloadFile` reads `response.text()` and writes with `fs.writeFile(localPath, content, 'utf8')`. Same corruption on the way back. +- `packages/boxel-cli/src/commands/file/write.ts:109` — single-file `boxel file write` does `readFileSync(opts.file, 'utf-8')`. +- `packages/boxel-cli/src/lib/sync-manifest.ts:42` — `computeFileHash` already reads as a `Buffer` (no encoding). No change needed. +- `packages/boxel-cli/tests/integration/*` — no binary fixtures, no PNG/PDF/font/JPEG coverage. + +## Reuse — the canonical pattern is in this repo + +We mirror what `packages/host` does today, not invent a new wire format. + +- Detect binary by filename extension via `isBinaryFilename(filename)` at `packages/runtime-common/infer-content-type.ts:23`. Treats SVG as text (XML), and `image/*`, `font/*`, `application/pdf`, and `.eot` as binary. +- Upload binary with `POST /` carrying `Content-Type: application/octet-stream` and raw bytes as the body — see `packages/host/app/services/file-upload.ts` and `packages/host/app/commands/write-binary-file.ts`. +- Download with `Accept: application/vnd.card+source` (the realm serves the bytes back regardless) but consume `response.arrayBuffer()` instead of `response.text()` and write the resulting `Uint8Array` with no encoding argument. +- Keep `/_atomic` as a text-only batch endpoint. Binary files in a push batch are split out and POSTed per-file alongside the atomic call for the text files. + +## Approach + +Add a single decision point — `isBinaryFilename(relativePath)` — at each upload/download site, and branch into a `Buffer`/`Uint8Array` path that mirrors the host's request shape. No realm-server changes. + +### Files modified + +1. `packages/boxel-cli/src/lib/realm-sync-base.ts` + - `uploadFile`: if `isBinaryFilename(relativePath)`, read as `Buffer` and POST with `Content-Type: application/octet-stream`. Otherwise the existing utf-8 path is unchanged. + - `uploadFilesAtomic`: partition entries into text and binary. Text rides the atomic JSON batch; binary rides per-file `application/octet-stream` POSTs in parallel. Failures are merged into the existing `perFile` error shape. + - `downloadFile`: if `isBinaryFilename(relativePath)`, consume `response.arrayBuffer()` and write with no encoding. + - Factor the per-file binary POST into a shared private helper. + +2. `packages/boxel-cli/src/commands/file/write.ts` + - When `opts.file` is provided and `isBinaryFilename(opts.file)`, read it as a `Buffer` and POST as `application/octet-stream`. Stdin stays utf-8 (out of scope). + +3. `packages/boxel-cli/src/commands/file/read.ts` + - Mirror the download change: if the requested URL path is a binary filename, write the bytes verbatim to disk instead of stringifying. + +4. `packages/boxel-cli/src/lib/sync-logic.ts` / `realm-pull.ts` / `realm-sync.ts` / `realm-watch.ts` + - All flow through `RealmSyncBase`'s primitives; no extra changes expected. Verified via test runs. + +### Reused helpers (no new code) + +- `isBinaryFilename(filename: string): boolean` — `packages/runtime-common/infer-content-type.ts:23` +- `SupportedMimeType.OctetStream` — `packages/runtime-common` (used by the realm router at `realm.ts:930`) + +## Tests + +Runner: vitest. Tests under `packages/boxel-cli/tests/integration/` against a real in-process realm-server (`tests/helpers/integration.ts`). + +Inline PNG fixture: a 67-byte 1×1 transparent PNG as `Buffer.from([...])` at the top of the test file — non-UTF-8 bytes guaranteed, no fixture files committed. + +Added roundtrip tests: + +1. `tests/integration/realm-push.test.ts` — pushes a PNG and reads it back byte-identical via `authedRealmFetch` + `arrayBuffer()`. +2. Same file — mixed batch of `.gts` + `.png` in one push call; verifies the binary file is carved out into a per-file POST while the text file still rides `/_atomic`, both byte-identical on the server. +3. `tests/integration/realm-pull.test.ts` — seed the realm with PNG bytes, pull, read local file as Buffer, assert byte equality. +4. `tests/integration/realm-sync.test.ts` — push-then-pull bidirectional binary test. +5. `tests/integration/realm-watch.test.ts` — modify a watched binary file, assert mirrored remote bytes match. +6. `tests/integration/file-write.test.ts` + `file-read.test.ts` — single-file commands roundtrip a PDF (or PNG) byte-identically. + +Assertions use `Buffer.equals` / `new Uint8Array(...).toEqual(...)` — never `.toString()` comparisons that would hide corruption. + +## Verification + +1. `cd packages/boxel-cli && pnpm install && pnpm build` +2. `cd packages/boxel-cli && pnpm test` — full suite green. +3. Start a local realm-server (`cd packages/realm-server && pnpm start:all`). +4. Scratch folder with a real PNG, PDF, `.woff2`, and `.gts` file. +5. `boxel workspace push --realm ` +6. In a browser, hit the realm: PNG renders, PDF opens, font downloadable; `.gts` still readable as text. +7. Delete local copies, `boxel workspace pull --realm `, `shasum -a 256` against originals — all match. +8. Edit one binary file under `boxel workspace sync --watch`; remote bytes update and `curl` round-trips byte-identically. + +## Out of scope + +- Larger-than-default `realm.write` size limits — 413 stays as-is. +- Streaming uploads for very large binaries — match host's in-memory buffering. +- Base64-in-/_atomic — host doesn't do it; per-file fallback is simpler with no server change. +- Binary content piped via stdin to `boxel file write` — stdin stays utf-8. diff --git a/packages/boxel-cli/src/commands/file/read.ts b/packages/boxel-cli/src/commands/file/read.ts index 27b7201f64f..ff6a880d4b2 100644 --- a/packages/boxel-cli/src/commands/file/read.ts +++ b/packages/boxel-cli/src/commands/file/read.ts @@ -6,14 +6,21 @@ import { } from '../../lib/profile-manager'; import { ensureTrailingSlash } from '@cardstack/runtime-common/paths'; import { SupportedMimeType } from '@cardstack/runtime-common/supported-mime-type'; +import { isBinaryFilename } from '@cardstack/runtime-common'; import { FG_RED, DIM, RESET } from '../../lib/colors'; import { cliLog } from '../../lib/cli-log'; export interface ReadResult { ok: boolean; status?: number; - /** Raw text content of the file. */ + /** Raw text content of the file. Populated for non-binary paths. */ content?: string; + /** + * Raw bytes. Populated when the requested path is a binary filename + * (PNG, PDF, font, etc.) — see `isBinaryFilename`. Mutually exclusive + * with `content`. + */ + bytes?: Uint8Array; error?: string; } @@ -27,8 +34,10 @@ interface ReadCliOptions { } /** - * Read a file from a realm. Always returns the raw text content. - * Callers should parse the content themselves if needed (e.g. JSON). + * Read a file from a realm. Returns raw text in `content` for text files; + * returns raw bytes in `bytes` for binary files (PNG / PDF / font / etc., + * per `isBinaryFilename`). Callers should parse the content themselves + * if needed (e.g. JSON). * * Uses the per-realm JWT via `ProfileManager.authedRealmFetch`. */ @@ -70,6 +79,11 @@ export async function read( }; } + if (isBinaryFilename(path)) { + let bytes = new Uint8Array(await response.arrayBuffer()); + return { ok: true, status: response.status, bytes }; + } + let text = await response.text(); return { ok: true, status: response.status, content: text }; } @@ -96,9 +110,30 @@ export function registerReadCommand(parent: Command): void { } if (opts.json) { - cliLog.output(JSON.stringify(result, null, 2)); + let serializable: Record = { + ok: result.ok, + status: result.status, + error: result.error, + }; + if (result.content !== undefined) { + serializable.content = result.content; + } + if (result.bytes !== undefined) { + // Buffer.from(typedArray) shares memory, then toString('base64') + // copies into a base64 string — fine for the JSON output path. + serializable.bytesBase64 = Buffer.from( + result.bytes.buffer, + result.bytes.byteOffset, + result.bytes.byteLength, + ).toString('base64'); + } + cliLog.output(JSON.stringify(serializable, null, 2)); } else if (result.ok) { - cliLog.output(result.content ?? ''); + if (result.bytes !== undefined) { + process.stdout.write(result.bytes); + } else { + cliLog.output(result.content ?? ''); + } } else { console.error( `${DIM}Status:${RESET} ${result.status ?? '(no status)'}`, diff --git a/packages/boxel-cli/src/commands/file/write.ts b/packages/boxel-cli/src/commands/file/write.ts index ff4ac1363d8..aa82167f1c1 100644 --- a/packages/boxel-cli/src/commands/file/write.ts +++ b/packages/boxel-cli/src/commands/file/write.ts @@ -7,6 +7,7 @@ import { } from '../../lib/profile-manager'; import { ensureTrailingSlash } from '@cardstack/runtime-common/paths'; import { SupportedMimeType } from '@cardstack/runtime-common/supported-mime-type'; +import { isBinaryFilename } from '@cardstack/runtime-common'; import { FG_GREEN, FG_RED, DIM, RESET } from '../../lib/colors'; import { cliLog } from '../../lib/cli-log'; @@ -26,15 +27,19 @@ interface WriteCliOptions { } /** - * Write a file to a realm. Content is sent as-is with card+source MIME type. - * Path should include the file extension. + * Write a file to a realm. Path should include the file extension. + * + * String content is sent with the card+source MIME type (the text path + * .gts / .json / .md / etc. always took). Binary content (a `Uint8Array`, + * including the `Buffer` subclass) is sent with `application/octet-stream`, + * which the realm-server routes to `upsertBinaryFile` and writes verbatim. * * Uses the per-realm JWT via `ProfileManager.authedRealmFetch`. */ export async function write( realmUrl: string, path: string, - content: string, + content: string | Uint8Array, options?: WriteCommandOptions, ): Promise { let pm = options?.profileManager ?? getProfileManager(); @@ -47,15 +52,21 @@ export async function write( } let url = new URL(path, ensureTrailingSlash(realmUrl)).href; + let isBinary = typeof content !== 'string'; try { let response = await pm.authedRealmFetch(url, { method: 'POST', - headers: { - Accept: SupportedMimeType.CardSource, - 'Content-Type': SupportedMimeType.CardSource, - }, - body: content, + headers: isBinary + ? { 'Content-Type': SupportedMimeType.OctetStream } + : { + Accept: SupportedMimeType.CardSource, + 'Content-Type': SupportedMimeType.CardSource, + }, + // Both branches of `content: string | Uint8Array` are valid + // BodyInit values, but TS narrows them as a union that doesn't + // unify against the fetch signature without a hint. + body: content as BodyInit, }); if (!response.ok) { @@ -103,10 +114,18 @@ export function registerWriteCommand(parent: Command): void { ) .option('--json', 'Output raw JSON response') .action(async (filePath: string, opts: WriteCliOptions) => { - let content: string; + let content: string | Uint8Array; if (opts.file) { try { - content = readFileSync(opts.file, 'utf-8'); + // When the local source file (and the destination path) is a + // binary asset, read raw bytes so we can hand them to write() + // unchanged. Forcing utf-8 would corrupt PNG / PDF / font / + // etc. payloads silently. + if (isBinaryFilename(opts.file) || isBinaryFilename(filePath)) { + content = readFileSync(opts.file); + } else { + content = readFileSync(opts.file, 'utf-8'); + } } catch (err) { stderr( `${FG_RED}Error:${RESET} Could not read file: ${err instanceof Error ? err.message : String(err)}`, diff --git a/packages/boxel-cli/src/lib/realm-sync-base.ts b/packages/boxel-cli/src/lib/realm-sync-base.ts index b4fce59d972..06abd9767e0 100644 --- a/packages/boxel-cli/src/lib/realm-sync-base.ts +++ b/packages/boxel-cli/src/lib/realm-sync-base.ts @@ -3,6 +3,7 @@ import * as fs from 'fs/promises'; import * as path from 'path'; import ignoreModule from 'ignore'; import pLimit from 'p-limit'; +import { isBinaryFilename } from '@cardstack/runtime-common'; const ignore = (ignoreModule as any).default || ignoreModule; type Ignore = ReturnType; @@ -46,6 +47,7 @@ export const SupportedMimeType = { CardSource: 'application/vnd.card+source', DirectoryListing: 'application/vnd.api+json', Mtimes: 'application/vnd.api+json', + OctetStream: 'application/octet-stream', } as const; export interface SyncOptions { @@ -396,6 +398,12 @@ export abstract class RealmSyncBase { return; } + if (isBinaryFilename(relativePath)) { + await this.uploadBinaryFile(relativePath, localPath); + console.log(` Uploaded: ${relativePath}`); + return; + } + const content = await fs.readFile(localPath, 'utf8'); const url = this.buildFileUrl(relativePath); @@ -417,6 +425,35 @@ export abstract class RealmSyncBase { console.log(` Uploaded: ${relativePath}`); } + // Uploads a single binary file (PNG, PDF, font, etc.) per the host + // pattern: a per-file POST with Content-Type: application/octet-stream + // and the raw bytes as the body. The realm-server routes octet-stream + // POSTs to upsertBinaryFile, which writes the bytes verbatim without + // any string conversion. Used by both uploadFile (single-shot) and + // uploadFilesAtomic (mixed-batch fallback for the binary entries it + // splits out of the atomic JSON payload). + protected async uploadBinaryFile( + relativePath: string, + localPath: string, + ): Promise { + const bytes = await fs.readFile(localPath); + const url = this.buildFileUrl(relativePath); + + const response = await this.authenticator.authedRealmFetch(url, { + method: 'POST', + headers: { + 'Content-Type': SupportedMimeType.OctetStream, + }, + body: new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength), + }); + + if (!response.ok) { + throw new Error( + `Failed to upload: ${response.status} ${response.statusText}`, + ); + } + } + // Batched upload via the realm's /_atomic endpoint. Returns the set of // paths the server reported as written plus an optional error payload // when the whole batch was rejected. The atomic endpoint validates @@ -449,8 +486,76 @@ export abstract class RealmSyncBase { return { succeeded: [] }; } + // The /_atomic endpoint embeds each file's content inside a JSON + // `attributes.content` string, which can't carry raw binary bytes. + // Match the host pattern: keep /_atomic for text files only, and + // for each binary file fall back to a per-file octet-stream POST + // (the same wire format `uploadFile` uses for a single binary). + const textEntries: Array<[string, string]> = []; + const binaryEntries: Array<[string, string]> = []; + for (const entry of entries) { + if (isBinaryFilename(entry[0])) { + binaryEntries.push(entry); + } else { + textEntries.push(entry); + } + } + + const binaryResults = await Promise.all( + binaryEntries.map(([relativePath, localPath]) => + this.remoteLimit(async () => { + try { + await this.uploadBinaryFile(relativePath, localPath); + console.log(` Uploaded: ${relativePath}`); + return { relativePath, ok: true as const }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const statusMatch = message.match(/(\d{3})/); + return { + relativePath, + ok: false as const, + status: statusMatch ? Number(statusMatch[1]) : 500, + title: message, + }; + } + }), + ), + ); + + const binarySucceeded: string[] = []; + const binaryFailed: Array<{ + path: string; + status: number; + title: string; + }> = []; + for (const result of binaryResults) { + if (result.ok) { + binarySucceeded.push(result.relativePath); + } else { + binaryFailed.push({ + path: result.relativePath, + status: result.status, + title: result.title, + }); + } + } + + if (textEntries.length === 0) { + if (binaryFailed.length > 0) { + return { + succeeded: binarySucceeded, + error: { + status: binaryFailed[0].status, + perFile: binaryFailed, + message: `Binary upload failed for ${binaryFailed.length} file(s)`, + }, + }; + } + return { succeeded: binarySucceeded }; + } + const operations = await Promise.all( - entries.map(async ([relativePath, localPath]) => { + textEntries.map(async ([relativePath, localPath]) => { const content = await fs.readFile(localPath, 'utf8'); return { op: addPaths.has(relativePath) @@ -483,21 +588,32 @@ export abstract class RealmSyncBase { 'atomic:results'?: Array<{ data?: { id?: string } }>; }; const hrefToRelative = new Map( - entries.map(([rel]) => [this.buildFileUrl(rel), rel]), + textEntries.map(([rel]) => [this.buildFileUrl(rel), rel]), ); // The realm normalizes hrefs: a path with a space goes out as // `Knowledge Articles/...` but comes back URL-encoded as // `Knowledge%20Articles/...`. Decode the response id before the // map lookup so we resolve back to the original relative path // instead of falling through to the raw encoded URL. - const succeeded = (body['atomic:results'] ?? []) + const atomicSucceeded = (body['atomic:results'] ?? []) .map((r) => r.data?.id) .filter((id): id is string => typeof id === 'string') .map((id) => decodeAtomicResultId(id)) .map((id) => hrefToRelative.get(id) ?? id); - for (const rel of succeeded) { + for (const rel of atomicSucceeded) { console.log(` Uploaded: ${rel}`); } + const succeeded = [...atomicSucceeded, ...binarySucceeded]; + if (binaryFailed.length > 0) { + return { + succeeded, + error: { + status: binaryFailed[0].status, + perFile: binaryFailed, + message: `Binary upload failed for ${binaryFailed.length} file(s)`, + }, + }; + } return { succeeded }; } @@ -515,7 +631,7 @@ export abstract class RealmSyncBase { const match = detail.match(/Resource (\S+) /); const href = match ? decodeAtomicResultId(match[1]) : ''; const relMap = new Map( - entries.map(([rel]) => [this.buildFileUrl(rel), rel]), + textEntries.map(([rel]) => [this.buildFileUrl(rel), rel]), ); return { path: relMap.get(href) ?? href, @@ -525,10 +641,10 @@ export abstract class RealmSyncBase { }); return { - succeeded: [], + succeeded: binarySucceeded, error: { status: response.status, - perFile, + perFile: [...perFile, ...binaryFailed], message: `Atomic upload failed: ${response.status} ${response.statusText}`, }, }; @@ -559,12 +675,16 @@ export abstract class RealmSyncBase { ); } - const content = await response.text(); - const localDir = path.dirname(localPath); await fs.mkdir(localDir, { recursive: true }); - await fs.writeFile(localPath, content, 'utf8'); + if (isBinaryFilename(relativePath)) { + const buffer = Buffer.from(await response.arrayBuffer()); + await fs.writeFile(localPath, buffer); + } else { + const content = await response.text(); + await fs.writeFile(localPath, content, 'utf8'); + } console.log(` Downloaded: ${relativePath}`); } diff --git a/packages/boxel-cli/tests/helpers/binary-fixtures.ts b/packages/boxel-cli/tests/helpers/binary-fixtures.ts new file mode 100644 index 00000000000..6fa2d4a31bb --- /dev/null +++ b/packages/boxel-cli/tests/helpers/binary-fixtures.ts @@ -0,0 +1,24 @@ +// Tiny 1x1 transparent PNG (67 bytes). Contains the PNG signature +// (0x89, 0x50, 0x4E, 0x47, ...), null bytes inside the IHDR chunk, and +// other non-UTF-8 byte sequences — exactly the bytes that get mangled +// when a binary file is round-tripped through `Buffer.toString('utf8')` +// and back. Used by every binary upload/download test to verify that +// the bytes survive the CLI path unchanged. +export const TINY_PNG_BYTES = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, + 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, + 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, + 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0d, + 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, + 0x60, 0x82, +]); + +// A small fake-PDF byte blob: real `%PDF-1.4` header followed by a few +// non-ASCII bytes and a trailer. The realm-server treats `.pdf` as +// binary (via `isBinaryFilename`) regardless of payload validity, so +// this is enough to exercise the binary code path for a second +// extension without bundling a real document. +export const TINY_PDF_BYTES = new Uint8Array([ + 0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x34, 0x0a, 0xe2, 0xe3, 0xcf, 0xd3, + 0x0a, 0x25, 0x25, 0x45, 0x4f, 0x46, 0x0a, +]); diff --git a/packages/boxel-cli/tests/integration/file-read.test.ts b/packages/boxel-cli/tests/integration/file-read.test.ts index b338c01fa40..8787b5ddb1b 100644 --- a/packages/boxel-cli/tests/integration/file-read.test.ts +++ b/packages/boxel-cli/tests/integration/file-read.test.ts @@ -12,6 +12,7 @@ import { setupTestProfile, TEST_REALM_SERVER_URL, } from '../helpers/integration'; +import { TINY_PNG_BYTES } from '../helpers/binary-fixtures'; let profileManager: ProfileManager; let cleanupProfile: () => void; @@ -101,6 +102,27 @@ describe('file read (integration)', () => { expect(result.error).toContain('404'); }); + it('reads a binary PNG byte-identically (returns bytes, not content)', async () => { + // Seed via direct octet-stream POST — startTestRealmServer's + // fileSystem option only accepts strings. + let pngUrl = `${realmUrl}image.png`; + let seed = await profileManager.authedRealmFetch(pngUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/octet-stream' }, + body: TINY_PNG_BYTES, + }); + expect(seed.ok, `seed POST failed: ${seed.status}`).toBe(true); + + let result = await read(realmUrl, 'image.png', { profileManager }); + expect(result.ok).toBe(true); + expect(result.status).toBe(200); + expect(result.content).toBeUndefined(); + expect(result.bytes).toBeDefined(); + expect(Buffer.from(result.bytes!).equals(Buffer.from(TINY_PNG_BYTES))).toBe( + true, + ); + }); + it('returns error result when no active profile', async () => { let emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-empty-')); let emptyManager = new ProfileManager(emptyDir); diff --git a/packages/boxel-cli/tests/integration/file-write.test.ts b/packages/boxel-cli/tests/integration/file-write.test.ts index 67bc21be8a8..46860c542d4 100644 --- a/packages/boxel-cli/tests/integration/file-write.test.ts +++ b/packages/boxel-cli/tests/integration/file-write.test.ts @@ -13,6 +13,7 @@ import { setupTestProfile, uniqueRealmName, } from '../helpers/integration'; +import { TINY_PNG_BYTES, TINY_PDF_BYTES } from '../helpers/binary-fixtures'; let profileManager: ProfileManager; let cleanupProfile: () => void; @@ -92,6 +93,35 @@ describe('file write (integration)', () => { expect((doc as any).data.attributes.title).toBe('Written Card'); }); + it('writes a PNG byte-identically and reads it back', async () => { + let writeResult = await write(realmUrl, 'image.png', TINY_PNG_BYTES, { + profileManager, + }); + expect(writeResult.ok, `write failed: ${writeResult.error}`).toBe(true); + + let response = await profileManager.authedRealmFetch( + `${realmUrl}image.png`, + { method: 'GET', headers: { Accept: 'application/vnd.card+source' } }, + ); + expect(response.ok).toBe(true); + let remote = Buffer.from(await response.arrayBuffer()); + expect(remote.equals(Buffer.from(TINY_PNG_BYTES))).toBe(true); + }); + + it('writes a PDF byte-identically', async () => { + let writeResult = await write(realmUrl, 'doc.pdf', TINY_PDF_BYTES, { + profileManager, + }); + expect(writeResult.ok).toBe(true); + + let response = await profileManager.authedRealmFetch(`${realmUrl}doc.pdf`, { + method: 'GET', + headers: { Accept: 'application/vnd.card+source' }, + }); + let remote = Buffer.from(await response.arrayBuffer()); + expect(remote.equals(Buffer.from(TINY_PDF_BYTES))).toBe(true); + }); + it('returns error result when no active profile', async () => { let emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-empty-')); let emptyManager = new ProfileManager(emptyDir); diff --git a/packages/boxel-cli/tests/integration/realm-pull.test.ts b/packages/boxel-cli/tests/integration/realm-pull.test.ts index d392a59e8bc..024954db83d 100644 --- a/packages/boxel-cli/tests/integration/realm-pull.test.ts +++ b/packages/boxel-cli/tests/integration/realm-pull.test.ts @@ -13,6 +13,7 @@ import { setupTestProfile, TEST_REALM_SERVER_URL, } from '../helpers/integration'; +import { TINY_PNG_BYTES } from '../helpers/binary-fixtures'; let profileManager: ProfileManager; let cleanupProfile: () => void; @@ -263,4 +264,28 @@ describe('realm pull (integration)', () => { expect(result.error).toBeDefined(); expect(result.files).toEqual([]); }); + + // --- Binary file downloads (CS-11075) --- + + it('pulls a binary PNG byte-identically', async () => { + let localDir = makeLocalDir(); + + // Seed the realm with raw bytes via the octet-stream endpoint (the + // canonical wire format the realm-server's upsertBinaryFile route + // expects). The startTestRealmServer fileSystem option only accepts + // strings, so we POST after server start. + let pngUrl = new URL('image.png', realmUrl).href; + let seedResponse = await profileManager.authedRealmFetch(pngUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/octet-stream' }, + body: TINY_PNG_BYTES, + }); + expect(seedResponse.ok).toBe(true); + + await pull(realmUrl, localDir, { profileManager }); + + let localPath = path.join(localDir, 'image.png'); + let pulled = fs.readFileSync(localPath); + expect(pulled.equals(Buffer.from(TINY_PNG_BYTES))).toBe(true); + }); }); diff --git a/packages/boxel-cli/tests/integration/realm-push.test.ts b/packages/boxel-cli/tests/integration/realm-push.test.ts index f46e09b0008..a55ab29cf47 100644 --- a/packages/boxel-cli/tests/integration/realm-push.test.ts +++ b/packages/boxel-cli/tests/integration/realm-push.test.ts @@ -13,6 +13,7 @@ import { setupTestProfile, uniqueRealmName, } from '../helpers/integration'; +import { TINY_PNG_BYTES, TINY_PDF_BYTES } from '../helpers/binary-fixtures'; import type { ProfileManager } from '../../src/lib/profile-manager'; let profileManager: ProfileManager; @@ -31,6 +32,28 @@ function writeLocalFile(localDir: string, relPath: string, content: string) { fs.writeFileSync(fullPath, content); } +function writeLocalBytes(localDir: string, relPath: string, bytes: Uint8Array) { + let fullPath = path.join(localDir, relPath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, bytes); +} + +async function fetchRemoteBytes( + realmUrl: string, + relPath: string, +): Promise { + let url = buildFileUrl(realmUrl, relPath); + let response = await profileManager.authedRealmFetch(url, { + headers: { Accept: 'application/vnd.card+source' }, + }); + if (!response.ok) { + throw new Error( + `Fetching ${url} failed: ${response.status} ${response.statusText}`, + ); + } + return Buffer.from(await response.arrayBuffer()); +} + interface SyncManifest { realmUrl: string; files: Record; @@ -677,6 +700,122 @@ describe('realm push (integration)', () => { expect(manifest.files['card.gts']).toMatch(/^[0-9a-f]{32}$/); }); + // --- Binary file uploads (CS-11075) --- + + it('pushes a PNG file and reads it back byte-identical', async () => { + let realmUrl = await createTestRealm(); + let localDir = makeLocalDir(); + + writeLocalBytes(localDir, 'image.png', TINY_PNG_BYTES); + + await pushCommand(localDir, realmUrl, { profileManager }); + + let remote = await fetchRemoteBytes(realmUrl, 'image.png'); + expect(remote.equals(Buffer.from(TINY_PNG_BYTES))).toBe(true); + + let manifest = readManifest(localDir); + expect(manifest.files['image.png']).toMatch(/^[0-9a-f]{32}$/); + }); + + it('pushes a PDF file byte-identically', async () => { + let realmUrl = await createTestRealm(); + let localDir = makeLocalDir(); + + writeLocalBytes(localDir, 'doc.pdf', TINY_PDF_BYTES); + + await pushCommand(localDir, realmUrl, { profileManager }); + + let remote = await fetchRemoteBytes(realmUrl, 'doc.pdf'); + expect(remote.equals(Buffer.from(TINY_PDF_BYTES))).toBe(true); + }); + + it('mixed batch carves binary out of /_atomic but lands every file', async () => { + let realmUrl = await createTestRealm(); + let localDir = makeLocalDir(); + + writeLocalFile(localDir, 'card.gts', 'export const c = 1;\n'); + writeLocalFile(localDir, 'data.json', '{"x":1}\n'); + writeLocalBytes(localDir, 'image.png', TINY_PNG_BYTES); + writeLocalBytes(localDir, 'doc.pdf', TINY_PDF_BYTES); + + let fetchSpy = vi.spyOn(profileManager, 'authedRealmFetch'); + let atomicCalls: typeof fetchSpy.mock.calls; + let octetCalls: typeof fetchSpy.mock.calls; + try { + await pushCommand(localDir, realmUrl, { profileManager }); + atomicCalls = fetchSpy.mock.calls.filter(([input, init]) => { + let url = typeof input === 'string' ? input : (input as URL).href; + return url.endsWith('/_atomic') && init?.method === 'POST'; + }); + octetCalls = fetchSpy.mock.calls.filter(([, init]) => { + let contentType = + (init?.headers as Record | undefined)?.[ + 'Content-Type' + ] ?? ''; + return ( + init?.method === 'POST' && contentType === 'application/octet-stream' + ); + }); + } finally { + fetchSpy.mockRestore(); + } + + expect(atomicCalls.length).toBe(1); + // One octet-stream POST per binary file (image.png, doc.pdf). + expect(octetCalls.length).toBe(2); + + // Every file landed byte-identical on the server + expect(await fetchRemoteFile(realmUrl, 'card.gts')).toContain('c = 1'); + expect(await fetchRemoteFile(realmUrl, 'data.json')).toContain('"x":1'); + expect( + (await fetchRemoteBytes(realmUrl, 'image.png')).equals( + Buffer.from(TINY_PNG_BYTES), + ), + ).toBe(true); + expect( + (await fetchRemoteBytes(realmUrl, 'doc.pdf')).equals( + Buffer.from(TINY_PDF_BYTES), + ), + ).toBe(true); + + // Manifest tracks all four files + let manifest = readManifest(localDir); + expect(Object.keys(manifest.files).sort()).toEqual([ + 'card.gts', + 'data.json', + 'doc.pdf', + 'image.png', + ]); + }); + + it('treats SVG as text — round-trips through /_atomic without corruption', async () => { + // SVG is XML, so isBinaryFilename returns false. Confirm it still + // rides the atomic batch path and comes back exactly. + let realmUrl = await createTestRealm(); + let localDir = makeLocalDir(); + + let svg = ''; + writeLocalFile(localDir, 'icon.svg', svg); + + let fetchSpy = vi.spyOn(profileManager, 'authedRealmFetch'); + let octetCount: number; + try { + await pushCommand(localDir, realmUrl, { profileManager }); + octetCount = fetchSpy.mock.calls.filter(([, init]) => { + let ct = + (init?.headers as Record | undefined)?.[ + 'Content-Type' + ] ?? ''; + return ct === 'application/octet-stream'; + }).length; + } finally { + fetchSpy.mockRestore(); + } + + expect(octetCount).toBe(0); + expect(await fetchRemoteFile(realmUrl, 'icon.svg')).toBe(svg); + }); + it('fails cleanly when an out-of-band create causes an atomic 409', async () => { let realmUrl = await createTestRealm(); let localDir = makeLocalDir(); diff --git a/packages/boxel-cli/tests/integration/realm-watch.test.ts b/packages/boxel-cli/tests/integration/realm-watch.test.ts index b3e36bb7395..3fd2277c71f 100644 --- a/packages/boxel-cli/tests/integration/realm-watch.test.ts +++ b/packages/boxel-cli/tests/integration/realm-watch.test.ts @@ -25,6 +25,7 @@ import { setupJwtTestProfile, TEST_REALM_SERVER_URL, } from '../helpers/integration'; +import { TINY_PNG_BYTES } from '../helpers/binary-fixtures'; let profileManager: ProfileManager; let cleanupProfile: (() => void) | undefined; @@ -208,6 +209,27 @@ async function writeRemoteFile( await waitForRemoteVisibility(realm, relPath, 'present', { previousMtime }); } +async function writeRemoteBytes( + realm: string, + relPath: string, + bytes: Uint8Array, +): Promise { + let previousMtime = (await fetchRemoteMtimes(realm))[ + buildFileUrl(realm, relPath) + ]; + let response = await remoteMutation(realm, relPath, { + method: 'POST', + headers: { 'Content-Type': 'application/octet-stream' }, + body: bytes, + }); + if (!response.ok) { + throw new Error( + `writeRemoteBytes ${relPath} failed: ${response.status} ${response.statusText}`, + ); + } + await waitForRemoteVisibility(realm, relPath, 'present', { previousMtime }); +} + async function deleteRemoteFile(realm: string, relPath: string): Promise { let response = await remoteMutation(realm, relPath, { method: 'DELETE', @@ -469,6 +491,25 @@ describe('realm watch (integration)', () => { expect(checkpoints[0].source).toBe('remote'); }); + it('pulls a remote PNG byte-identically (CS-11075)', async () => { + let localDir = makeLocalDir(); + await writeRemoteBytes(realmUrl, 'image.png', TINY_PNG_BYTES); + + let watcher = new RealmWatcher({ realmUrl, localDir }, profileManager, { + debounceMs: 0, + quiet: true, + }); + await watcher.initialize(); + await watcher.poll(); + let result = await watcher.flushPending(); + expect(result.pulled).toContain('image.png'); + + let pulled = fs.readFileSync(path.join(localDir, 'image.png')); + expect(pulled.equals(Buffer.from(TINY_PNG_BYTES))).toBe(true); + + watcher.shutdown(); + }); + it('returns an error when the realm URL is unreachable', async () => { let localDir = makeLocalDir(); let result = await watchRealms( From 2e1779d8bce90c75b3640785be6c459d453ca7f6 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Mon, 18 May 2026 16:21:05 +0700 Subject: [PATCH 2/3] Persist boxel-cli sync manifest on partial binary-upload failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When uploadFilesAtomic split binaries out of /_atomic, a partial binary failure left the atomic text successes unrecorded on disk — the next push hit a 409 against the files the server already had. Record result.succeeded unconditionally in push and sync, drop the brittle status-from-message regex in favor of a status field on the thrown Error (with the response body in the message), and reject source/destination binary-classification mismatches in `file write`. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/cs-11075-binary-upload-plan.md | 89 ------------------- packages/boxel-cli/src/commands/file/write.ts | 30 +++++-- packages/boxel-cli/src/commands/realm/push.ts | 35 +++++--- packages/boxel-cli/src/commands/realm/sync.ts | 14 ++- packages/boxel-cli/src/lib/realm-sync-base.ts | 44 ++++++--- .../tests/integration/realm-push.test.ts | 61 +++++++++++++ 6 files changed, 148 insertions(+), 125 deletions(-) delete mode 100644 docs/cs-11075-binary-upload-plan.md diff --git a/docs/cs-11075-binary-upload-plan.md b/docs/cs-11075-binary-upload-plan.md deleted file mode 100644 index cdc8d6aa7b1..00000000000 --- a/docs/cs-11075-binary-upload-plan.md +++ /dev/null @@ -1,89 +0,0 @@ -# CS-11075 — Ensure binary file uploads work reliably via boxel-cli - -Linear: https://linear.app/cardstack/issue/CS-11075 -Branch: `cs-11075-ensure-that-uploading-binary-files-to-realm-works-reliably` - -## Context - -`boxel-cli`'s realm-sync code reads every file off disk with `'utf8'` encoding and writes every downloaded file back with `'utf8'`. For text (`.gts`, `.json`, `.md`) this is fine; for binary assets (PNG, JPEG, PDF, fonts, etc.) it silently corrupts bytes — invalid UTF-8 sequences round-trip through `String`/`JSON.stringify` and get replaced with `U+FFFD` or truncated. The realm-server already has a working binary endpoint (`Content-Type: application/octet-stream` → `upsertBinaryFile` at `packages/runtime-common/realm.ts:3282`, routed at `realm.ts:928-932`), and the Ember host package already uses it correctly. boxel-cli simply never takes that path. - -This ticket fixes the upload + download + watch flows in boxel-cli so a folder containing images / PDFs / fonts pushed via `boxel workspace push` lands in the realm intact, and the same bytes come back through `pull` / `sync` / `watch`. - -## Current behavior - -- `packages/boxel-cli/src/lib/realm-sync-base.ts:399` — `uploadFile` does `fs.readFile(localPath, 'utf8')` and POSTs with `Content-Type: text/plain;charset=UTF-8`, `Accept: SupportedMimeType.CardSource`. -- `packages/boxel-cli/src/lib/realm-sync-base.ts:454` — `uploadFilesAtomic` does `fs.readFile(localPath, 'utf8')` and embeds the string inside `data.attributes.content` of an `application/vnd.api+json` payload to `/_atomic`. Binary cannot survive this JSON serialization. -- `packages/boxel-cli/src/lib/realm-sync-base.ts:562-567` — `downloadFile` reads `response.text()` and writes with `fs.writeFile(localPath, content, 'utf8')`. Same corruption on the way back. -- `packages/boxel-cli/src/commands/file/write.ts:109` — single-file `boxel file write` does `readFileSync(opts.file, 'utf-8')`. -- `packages/boxel-cli/src/lib/sync-manifest.ts:42` — `computeFileHash` already reads as a `Buffer` (no encoding). No change needed. -- `packages/boxel-cli/tests/integration/*` — no binary fixtures, no PNG/PDF/font/JPEG coverage. - -## Reuse — the canonical pattern is in this repo - -We mirror what `packages/host` does today, not invent a new wire format. - -- Detect binary by filename extension via `isBinaryFilename(filename)` at `packages/runtime-common/infer-content-type.ts:23`. Treats SVG as text (XML), and `image/*`, `font/*`, `application/pdf`, and `.eot` as binary. -- Upload binary with `POST /` carrying `Content-Type: application/octet-stream` and raw bytes as the body — see `packages/host/app/services/file-upload.ts` and `packages/host/app/commands/write-binary-file.ts`. -- Download with `Accept: application/vnd.card+source` (the realm serves the bytes back regardless) but consume `response.arrayBuffer()` instead of `response.text()` and write the resulting `Uint8Array` with no encoding argument. -- Keep `/_atomic` as a text-only batch endpoint. Binary files in a push batch are split out and POSTed per-file alongside the atomic call for the text files. - -## Approach - -Add a single decision point — `isBinaryFilename(relativePath)` — at each upload/download site, and branch into a `Buffer`/`Uint8Array` path that mirrors the host's request shape. No realm-server changes. - -### Files modified - -1. `packages/boxel-cli/src/lib/realm-sync-base.ts` - - `uploadFile`: if `isBinaryFilename(relativePath)`, read as `Buffer` and POST with `Content-Type: application/octet-stream`. Otherwise the existing utf-8 path is unchanged. - - `uploadFilesAtomic`: partition entries into text and binary. Text rides the atomic JSON batch; binary rides per-file `application/octet-stream` POSTs in parallel. Failures are merged into the existing `perFile` error shape. - - `downloadFile`: if `isBinaryFilename(relativePath)`, consume `response.arrayBuffer()` and write with no encoding. - - Factor the per-file binary POST into a shared private helper. - -2. `packages/boxel-cli/src/commands/file/write.ts` - - When `opts.file` is provided and `isBinaryFilename(opts.file)`, read it as a `Buffer` and POST as `application/octet-stream`. Stdin stays utf-8 (out of scope). - -3. `packages/boxel-cli/src/commands/file/read.ts` - - Mirror the download change: if the requested URL path is a binary filename, write the bytes verbatim to disk instead of stringifying. - -4. `packages/boxel-cli/src/lib/sync-logic.ts` / `realm-pull.ts` / `realm-sync.ts` / `realm-watch.ts` - - All flow through `RealmSyncBase`'s primitives; no extra changes expected. Verified via test runs. - -### Reused helpers (no new code) - -- `isBinaryFilename(filename: string): boolean` — `packages/runtime-common/infer-content-type.ts:23` -- `SupportedMimeType.OctetStream` — `packages/runtime-common` (used by the realm router at `realm.ts:930`) - -## Tests - -Runner: vitest. Tests under `packages/boxel-cli/tests/integration/` against a real in-process realm-server (`tests/helpers/integration.ts`). - -Inline PNG fixture: a 67-byte 1×1 transparent PNG as `Buffer.from([...])` at the top of the test file — non-UTF-8 bytes guaranteed, no fixture files committed. - -Added roundtrip tests: - -1. `tests/integration/realm-push.test.ts` — pushes a PNG and reads it back byte-identical via `authedRealmFetch` + `arrayBuffer()`. -2. Same file — mixed batch of `.gts` + `.png` in one push call; verifies the binary file is carved out into a per-file POST while the text file still rides `/_atomic`, both byte-identical on the server. -3. `tests/integration/realm-pull.test.ts` — seed the realm with PNG bytes, pull, read local file as Buffer, assert byte equality. -4. `tests/integration/realm-sync.test.ts` — push-then-pull bidirectional binary test. -5. `tests/integration/realm-watch.test.ts` — modify a watched binary file, assert mirrored remote bytes match. -6. `tests/integration/file-write.test.ts` + `file-read.test.ts` — single-file commands roundtrip a PDF (or PNG) byte-identically. - -Assertions use `Buffer.equals` / `new Uint8Array(...).toEqual(...)` — never `.toString()` comparisons that would hide corruption. - -## Verification - -1. `cd packages/boxel-cli && pnpm install && pnpm build` -2. `cd packages/boxel-cli && pnpm test` — full suite green. -3. Start a local realm-server (`cd packages/realm-server && pnpm start:all`). -4. Scratch folder with a real PNG, PDF, `.woff2`, and `.gts` file. -5. `boxel workspace push --realm ` -6. In a browser, hit the realm: PNG renders, PDF opens, font downloadable; `.gts` still readable as text. -7. Delete local copies, `boxel workspace pull --realm `, `shasum -a 256` against originals — all match. -8. Edit one binary file under `boxel workspace sync --watch`; remote bytes update and `curl` round-trips byte-identically. - -## Out of scope - -- Larger-than-default `realm.write` size limits — 413 stays as-is. -- Streaming uploads for very large binaries — match host's in-memory buffering. -- Base64-in-/_atomic — host doesn't do it; per-file fallback is simpler with no server change. -- Binary content piped via stdin to `boxel file write` — stdin stays utf-8. diff --git a/packages/boxel-cli/src/commands/file/write.ts b/packages/boxel-cli/src/commands/file/write.ts index aa82167f1c1..020cca3bbb5 100644 --- a/packages/boxel-cli/src/commands/file/write.ts +++ b/packages/boxel-cli/src/commands/file/write.ts @@ -116,16 +116,28 @@ export function registerWriteCommand(parent: Command): void { .action(async (filePath: string, opts: WriteCliOptions) => { let content: string | Uint8Array; if (opts.file) { + // Refuse a source/destination binary-classification mismatch + // (e.g., `write notes.md --file image.png`) — otherwise raw + // bytes would land at a text extension and corrupt-on-read. + const srcIsBinary = isBinaryFilename(opts.file); + const dstIsBinary = isBinaryFilename(filePath); + if (srcIsBinary !== dstIsBinary) { + stderr( + `${FG_RED}Error:${RESET} source file ${opts.file} is ${ + srcIsBinary ? 'binary' : 'text' + } but destination path ${filePath} is ${ + dstIsBinary ? 'binary' : 'text' + }. Refusing to write to avoid silent corruption — rename the destination to match.`, + ); + process.exit(1); + } try { - // When the local source file (and the destination path) is a - // binary asset, read raw bytes so we can hand them to write() - // unchanged. Forcing utf-8 would corrupt PNG / PDF / font / - // etc. payloads silently. - if (isBinaryFilename(opts.file) || isBinaryFilename(filePath)) { - content = readFileSync(opts.file); - } else { - content = readFileSync(opts.file, 'utf-8'); - } + // Binary source files are read as raw bytes so write() can + // hand them to the realm unchanged; forcing utf-8 would + // corrupt PNG / PDF / font / etc. payloads silently. + content = srcIsBinary + ? readFileSync(opts.file) + : readFileSync(opts.file, 'utf-8'); } catch (err) { stderr( `${FG_RED}Error:${RESET} Could not read file: ${err instanceof Error ? err.message : String(err)}`, diff --git a/packages/boxel-cli/src/commands/realm/push.ts b/packages/boxel-cli/src/commands/realm/push.ts index caed5017643..28c744fd3a6 100644 --- a/packages/boxel-cli/src/commands/realm/push.ts +++ b/packages/boxel-cli/src/commands/realm/push.ts @@ -200,6 +200,23 @@ class RealmPusher extends RealmSyncBase { const result = await this.uploadFilesAtomic(filesToUpload, addPaths); + // Record every file the server actually wrote before surfacing + // errors. uploadFilesAtomic can return both `succeeded` and + // `error` when the atomic text batch lands but a per-file + // binary POST fails — dropping the manifest update in that + // case would force a re-add on the next push (409 cascade). + if (result.succeeded.length > 0) { + const uploaded = await Promise.all( + result.succeeded.map(async (rel) => ({ + rel, + hash: await computeFileHash(filesToUpload.get(rel)!), + })), + ); + for (const { rel, hash } of uploaded) { + newManifest.files[rel] = hash; + } + } + if (result.error) { uploadFailed = true; this.hasError = true; @@ -215,16 +232,6 @@ class RealmPusher extends RealmSyncBase { } console.error(` ${hint}`); } - } else if (result.succeeded.length > 0) { - const uploaded = await Promise.all( - result.succeeded.map(async (rel) => ({ - rel, - hash: await computeFileHash(filesToUpload.get(rel)!), - })), - ); - for (const { rel, hash } of uploaded) { - newManifest.files[rel] = hash; - } } } @@ -270,7 +277,11 @@ class RealmPusher extends RealmSyncBase { } } - if (!this.options.dryRun && !uploadFailed && filesToUpload.size > 0) { + // Refresh mtimes and save the manifest even on partial failure — + // newManifest.files only contains files the server actually wrote + // (unchanged carry-overs + succeeded uploads), so persisting it + // is always safe and avoids re-uploading text files that landed. + if (!this.options.dryRun && filesToUpload.size > 0) { try { const freshMtimes = await this.getRemoteMtimes(); for (const rel of Object.keys(newManifest.files)) { @@ -291,7 +302,7 @@ class RealmPusher extends RealmSyncBase { delete newManifest.remoteMtimes; } - if (!this.options.dryRun && !uploadFailed) { + if (!this.options.dryRun) { await saveManifest(this.options.localDir, newManifest); } diff --git a/packages/boxel-cli/src/commands/realm/sync.ts b/packages/boxel-cli/src/commands/realm/sync.ts index f192b65e791..728f73e5ad4 100644 --- a/packages/boxel-cli/src/commands/realm/sync.ts +++ b/packages/boxel-cli/src/commands/realm/sync.ts @@ -314,14 +314,16 @@ class RealmSyncer extends RealmSyncBase { } const result = await this.uploadFilesAtomic(filesToUpload, addPaths); + // Record every file the server actually wrote, even when other + // files in the same batch failed — see push.ts for the symmetric + // reasoning. + this.pushedFiles.push(...result.succeeded); if (result.error) { this.hasError = true; console.error(result.error.message); for (const entry of result.error.perFile) { console.error(` ${entry.path}: ${entry.title}`); } - } else { - this.pushedFiles.push(...result.succeeded); } } @@ -371,8 +373,12 @@ class RealmSyncer extends RealmSyncBase { ); } - // Phase 6: Update manifest - if (!this.options.dryRun && !this.hasError) { + // Phase 6: Update manifest. Persist even on partial failure — we + // only record hashes for files the server actually wrote + // (pushedFiles + pulledFiles), so the manifest stays consistent + // with the realm and the next sync won't re-attempt successful + // files. + if (!this.options.dryRun) { // Build updated hashes from prior manifest + current local files + executed ops. // Start with the previous manifest so that files deleted locally but not // propagated (no --delete) retain their entries and aren't re-pulled next sync. diff --git a/packages/boxel-cli/src/lib/realm-sync-base.ts b/packages/boxel-cli/src/lib/realm-sync-base.ts index 06abd9767e0..92026606eab 100644 --- a/packages/boxel-cli/src/lib/realm-sync-base.ts +++ b/packages/boxel-cli/src/lib/realm-sync-base.ts @@ -43,6 +43,28 @@ function decodeAtomicResultId(id: string): string { } } +// Builds a structured upload error: the message embeds the response +// status + statusText + a snippet of the response body (the realm +// returns useful detail there — size limits, missing scopes, etc.), +// and a `status` property is attached so the batch helper can route +// the failure without re-parsing the message. +async function throwUploadError( + response: Response, + relativePath: string, +): Promise { + const bodyText = await response.text().catch(() => ''); + const message = `Failed to upload ${relativePath}: ${response.status} ${response.statusText}${ + bodyText ? ` — ${bodyText.slice(0, 200)}` : '' + }`; + const err = new Error(message) as Error & { + status?: number; + body?: string; + }; + err.status = response.status; + err.body = bodyText; + throw err; +} + export const SupportedMimeType = { CardSource: 'application/vnd.card+source', DirectoryListing: 'application/vnd.api+json', @@ -417,9 +439,7 @@ export abstract class RealmSyncBase { }); if (!response.ok) { - throw new Error( - `Failed to upload: ${response.status} ${response.statusText}`, - ); + await throwUploadError(response, relativePath); } console.log(` Uploaded: ${relativePath}`); @@ -444,13 +464,11 @@ export abstract class RealmSyncBase { headers: { 'Content-Type': SupportedMimeType.OctetStream, }, - body: new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength), + body: bytes, }); if (!response.ok) { - throw new Error( - `Failed to upload: ${response.status} ${response.statusText}`, - ); + await throwUploadError(response, relativePath); } } @@ -509,13 +527,17 @@ export abstract class RealmSyncBase { console.log(` Uploaded: ${relativePath}`); return { relativePath, ok: true as const }; } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const statusMatch = message.match(/(\d{3})/); + const errWithStatus = err as { status?: number }; + const status = + typeof errWithStatus?.status === 'number' + ? errWithStatus.status + : 500; + const title = err instanceof Error ? err.message : String(err); return { relativePath, ok: false as const, - status: statusMatch ? Number(statusMatch[1]) : 500, - title: message, + status, + title, }; } }), diff --git a/packages/boxel-cli/tests/integration/realm-push.test.ts b/packages/boxel-cli/tests/integration/realm-push.test.ts index a55ab29cf47..fd19872c7f2 100644 --- a/packages/boxel-cli/tests/integration/realm-push.test.ts +++ b/packages/boxel-cli/tests/integration/realm-push.test.ts @@ -788,6 +788,67 @@ describe('realm push (integration)', () => { ]); }); + it('records text successes in manifest when binary partially fails', async () => { + // Mixed batch where the per-file binary POST fails (stubbed 413) + // while the atomic text batch lands. The manifest must still + // record the text file that the server actually wrote — otherwise + // the next push sees it as missing-from-manifest and tries to + // re-add it, hitting a 409 against the existing remote. + let realmUrl = await createTestRealm(); + let localDir = makeLocalDir(); + + writeLocalFile(localDir, 'card.gts', 'export const c = 1;\n'); + writeLocalBytes(localDir, 'image.png', TINY_PNG_BYTES); + + let realFetch = profileManager.authedRealmFetch.bind(profileManager); + let fetchSpy = vi + .spyOn(profileManager, 'authedRealmFetch') + .mockImplementation(async (input, init) => { + let url = typeof input === 'string' ? input : (input as URL).href; + let contentType = + (init?.headers as Record | undefined)?.[ + 'Content-Type' + ] ?? ''; + if ( + init?.method === 'POST' && + contentType === 'application/octet-stream' && + url.endsWith('/image.png') + ) { + return new Response('Payload Too Large', { + status: 413, + statusText: 'Payload Too Large', + }); + } + return realFetch(input, init); + }); + + // pushCommand exits 2 on any upload error; intercept so the test + // can observe state instead of being terminated. + let exitCode: number | undefined; + let exitSpy = vi.spyOn(process, 'exit').mockImplementation((( + code?: number, + ) => { + if (exitCode === undefined) exitCode = code; + return undefined as never; + }) as never); + + try { + await pushCommand(localDir, realmUrl, { profileManager }); + } finally { + fetchSpy.mockRestore(); + exitSpy.mockRestore(); + } + expect(exitCode).toBe(2); + + // The text file landed + expect(await fetchRemoteFile(realmUrl, 'card.gts')).toContain('c = 1'); + + // The manifest records the text success even though the binary failed + let manifest = readManifest(localDir); + expect(manifest.files['card.gts']).toMatch(/^[0-9a-f]{32}$/); + expect(manifest.files['image.png']).toBeUndefined(); + }); + it('treats SVG as text — round-trips through /_atomic without corruption', async () => { // SVG is XML, so isBinaryFilename returns false. Confirm it still // rides the atomic batch path and comes back exactly. From 1de3cbff4041ac599ad1f3e3636decf5b160c779 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Mon, 18 May 2026 22:08:24 +0700 Subject: [PATCH 3/3] Use runtime-common subpath imports for isBinaryFilename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bare `from '@cardstack/runtime-common'` pulled the whole barrel into the bundle and the type-check program: transpile.ts → content-tag, plus ~50 modules that import `https://cardstack.com/base/*`. That broke the smoke tests (`ENOENT: dist/content_tag_bg.wasm` at startup) and `lint:types` (TS2307 cascade). Switching to `/infer-content-type` keeps the bundle to the leaf file's tiny graph, matching the subpath-only convention every other boxel-cli source file follows. Bundle shrinks from 7.2 MB to 370 KB; lint clean; integration suite green. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/boxel-cli/src/commands/file/read.ts | 2 +- packages/boxel-cli/src/commands/file/write.ts | 2 +- packages/boxel-cli/src/lib/realm-sync-base.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/boxel-cli/src/commands/file/read.ts b/packages/boxel-cli/src/commands/file/read.ts index ff6a880d4b2..aeeb254a330 100644 --- a/packages/boxel-cli/src/commands/file/read.ts +++ b/packages/boxel-cli/src/commands/file/read.ts @@ -6,7 +6,7 @@ import { } from '../../lib/profile-manager'; import { ensureTrailingSlash } from '@cardstack/runtime-common/paths'; import { SupportedMimeType } from '@cardstack/runtime-common/supported-mime-type'; -import { isBinaryFilename } from '@cardstack/runtime-common'; +import { isBinaryFilename } from '@cardstack/runtime-common/infer-content-type'; import { FG_RED, DIM, RESET } from '../../lib/colors'; import { cliLog } from '../../lib/cli-log'; diff --git a/packages/boxel-cli/src/commands/file/write.ts b/packages/boxel-cli/src/commands/file/write.ts index 020cca3bbb5..24381ec1778 100644 --- a/packages/boxel-cli/src/commands/file/write.ts +++ b/packages/boxel-cli/src/commands/file/write.ts @@ -7,7 +7,7 @@ import { } from '../../lib/profile-manager'; import { ensureTrailingSlash } from '@cardstack/runtime-common/paths'; import { SupportedMimeType } from '@cardstack/runtime-common/supported-mime-type'; -import { isBinaryFilename } from '@cardstack/runtime-common'; +import { isBinaryFilename } from '@cardstack/runtime-common/infer-content-type'; import { FG_GREEN, FG_RED, DIM, RESET } from '../../lib/colors'; import { cliLog } from '../../lib/cli-log'; diff --git a/packages/boxel-cli/src/lib/realm-sync-base.ts b/packages/boxel-cli/src/lib/realm-sync-base.ts index 92026606eab..5015fbfd81f 100644 --- a/packages/boxel-cli/src/lib/realm-sync-base.ts +++ b/packages/boxel-cli/src/lib/realm-sync-base.ts @@ -3,7 +3,7 @@ import * as fs from 'fs/promises'; import * as path from 'path'; import ignoreModule from 'ignore'; import pLimit from 'p-limit'; -import { isBinaryFilename } from '@cardstack/runtime-common'; +import { isBinaryFilename } from '@cardstack/runtime-common/infer-content-type'; const ignore = (ignoreModule as any).default || ignoreModule; type Ignore = ReturnType;