Skip to content

Commit d884c0f

Browse files
authored
fix: Send binary files via application/octet-stream in boxel-cli sync (CS-11075) (#4852)
1 parent ea5033b commit d884c0f

11 files changed

Lines changed: 856 additions & 203 deletions

File tree

packages/boxel-cli/src/commands/file/read.ts

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,21 @@ import {
66
} from '../../lib/profile-manager';
77
import { ensureTrailingSlash } from '@cardstack/runtime-common/paths';
88
import { SupportedMimeType } from '@cardstack/runtime-common/supported-mime-type';
9+
import { isBinaryFilename } from '@cardstack/runtime-common/infer-content-type';
910
import { FG_RED, DIM, RESET } from '../../lib/colors';
1011
import { cliLog } from '../../lib/cli-log';
1112

1213
export interface ReadResult {
1314
ok: boolean;
1415
status?: number;
15-
/** Raw text content of the file. */
16+
/** Raw text content of the file. Populated for non-binary paths. */
1617
content?: string;
18+
/**
19+
* Raw bytes. Populated when the requested path is a binary filename
20+
* (PNG, PDF, font, etc.) — see `isBinaryFilename`. Mutually exclusive
21+
* with `content`.
22+
*/
23+
bytes?: Uint8Array;
1724
error?: string;
1825
}
1926

@@ -27,8 +34,10 @@ interface ReadCliOptions {
2734
}
2835

2936
/**
30-
* Read a file from a realm. Always returns the raw text content.
31-
* Callers should parse the content themselves if needed (e.g. JSON).
37+
* Read a file from a realm. Returns raw text in `content` for text files;
38+
* returns raw bytes in `bytes` for binary files (PNG / PDF / font / etc.,
39+
* per `isBinaryFilename`). Callers should parse the content themselves
40+
* if needed (e.g. JSON).
3241
*
3342
* Uses the per-realm JWT via `ProfileManager.authedRealmFetch`.
3443
*/
@@ -70,6 +79,11 @@ export async function read(
7079
};
7180
}
7281

82+
if (isBinaryFilename(path)) {
83+
let bytes = new Uint8Array(await response.arrayBuffer());
84+
return { ok: true, status: response.status, bytes };
85+
}
86+
7387
let text = await response.text();
7488
return { ok: true, status: response.status, content: text };
7589
}
@@ -96,9 +110,30 @@ export function registerReadCommand(parent: Command): void {
96110
}
97111

98112
if (opts.json) {
99-
cliLog.output(JSON.stringify(result, null, 2));
113+
let serializable: Record<string, unknown> = {
114+
ok: result.ok,
115+
status: result.status,
116+
error: result.error,
117+
};
118+
if (result.content !== undefined) {
119+
serializable.content = result.content;
120+
}
121+
if (result.bytes !== undefined) {
122+
// Buffer.from(typedArray) shares memory, then toString('base64')
123+
// copies into a base64 string — fine for the JSON output path.
124+
serializable.bytesBase64 = Buffer.from(
125+
result.bytes.buffer,
126+
result.bytes.byteOffset,
127+
result.bytes.byteLength,
128+
).toString('base64');
129+
}
130+
cliLog.output(JSON.stringify(serializable, null, 2));
100131
} else if (result.ok) {
101-
cliLog.output(result.content ?? '');
132+
if (result.bytes !== undefined) {
133+
process.stdout.write(result.bytes);
134+
} else {
135+
cliLog.output(result.content ?? '');
136+
}
102137
} else {
103138
console.error(
104139
`${DIM}Status:${RESET} ${result.status ?? '(no status)'}`,

packages/boxel-cli/src/commands/file/write.ts

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
} from '../../lib/profile-manager';
88
import { ensureTrailingSlash } from '@cardstack/runtime-common/paths';
99
import { SupportedMimeType } from '@cardstack/runtime-common/supported-mime-type';
10+
import { isBinaryFilename } from '@cardstack/runtime-common/infer-content-type';
1011
import { FG_GREEN, FG_RED, DIM, RESET } from '../../lib/colors';
1112
import { cliLog } from '../../lib/cli-log';
1213

@@ -26,15 +27,19 @@ interface WriteCliOptions {
2627
}
2728

2829
/**
29-
* Write a file to a realm. Content is sent as-is with card+source MIME type.
30-
* Path should include the file extension.
30+
* Write a file to a realm. Path should include the file extension.
31+
*
32+
* String content is sent with the card+source MIME type (the text path
33+
* .gts / .json / .md / etc. always took). Binary content (a `Uint8Array`,
34+
* including the `Buffer` subclass) is sent with `application/octet-stream`,
35+
* which the realm-server routes to `upsertBinaryFile` and writes verbatim.
3136
*
3237
* Uses the per-realm JWT via `ProfileManager.authedRealmFetch`.
3338
*/
3439
export async function write(
3540
realmUrl: string,
3641
path: string,
37-
content: string,
42+
content: string | Uint8Array,
3843
options?: WriteCommandOptions,
3944
): Promise<WriteResult> {
4045
let pm = options?.profileManager ?? getProfileManager();
@@ -47,15 +52,38 @@ export async function write(
4752
}
4853

4954
let url = new URL(path, ensureTrailingSlash(realmUrl)).href;
55+
let isBinary = typeof content !== 'string';
56+
57+
// Defense-in-depth for programmatic callers (BoxelClient.write, tests).
58+
// The CLI wrapper has an earlier guard against `--file image.png` →
59+
// `notes.md` style misuse, but the library function is also reachable
60+
// without going through that branch. Reject the mismatch here so raw
61+
// bytes never land at a text extension (corrupt-on-read) and a UTF-8
62+
// string never lands at a binary extension (corrupt-on-write).
63+
let pathIsBinary = isBinaryFilename(path);
64+
if (pathIsBinary !== isBinary) {
65+
return {
66+
ok: false,
67+
error:
68+
`Path ${path} is ${pathIsBinary ? 'binary' : 'text'} by extension ` +
69+
`but content is ${isBinary ? 'bytes' : 'a string'}. ` +
70+
`Refusing to write to avoid silent corruption.`,
71+
};
72+
}
5073

5174
try {
5275
let response = await pm.authedRealmFetch(url, {
5376
method: 'POST',
54-
headers: {
55-
Accept: SupportedMimeType.CardSource,
56-
'Content-Type': SupportedMimeType.CardSource,
57-
},
58-
body: content,
77+
headers: isBinary
78+
? { 'Content-Type': SupportedMimeType.OctetStream }
79+
: {
80+
Accept: SupportedMimeType.CardSource,
81+
'Content-Type': SupportedMimeType.CardSource,
82+
},
83+
// Both branches of `content: string | Uint8Array` are valid
84+
// BodyInit values, but TS narrows them as a union that doesn't
85+
// unify against the fetch signature without a hint.
86+
body: content as BodyInit,
5987
});
6088

6189
if (!response.ok) {
@@ -103,10 +131,30 @@ export function registerWriteCommand(parent: Command): void {
103131
)
104132
.option('--json', 'Output raw JSON response')
105133
.action(async (filePath: string, opts: WriteCliOptions) => {
106-
let content: string;
134+
let content: string | Uint8Array;
107135
if (opts.file) {
136+
// Refuse a source/destination binary-classification mismatch
137+
// (e.g., `write notes.md --file image.png`) — otherwise raw
138+
// bytes would land at a text extension and corrupt-on-read.
139+
const srcIsBinary = isBinaryFilename(opts.file);
140+
const dstIsBinary = isBinaryFilename(filePath);
141+
if (srcIsBinary !== dstIsBinary) {
142+
stderr(
143+
`${FG_RED}Error:${RESET} source file ${opts.file} is ${
144+
srcIsBinary ? 'binary' : 'text'
145+
} but destination path ${filePath} is ${
146+
dstIsBinary ? 'binary' : 'text'
147+
}. Refusing to write to avoid silent corruption — rename the destination to match.`,
148+
);
149+
process.exit(1);
150+
}
108151
try {
109-
content = readFileSync(opts.file, 'utf-8');
152+
// Binary source files are read as raw bytes so write() can
153+
// hand them to the realm unchanged; forcing utf-8 would
154+
// corrupt PNG / PDF / font / etc. payloads silently.
155+
content = srcIsBinary
156+
? readFileSync(opts.file)
157+
: readFileSync(opts.file, 'utf-8');
110158
} catch (err) {
111159
stderr(
112160
`${FG_RED}Error:${RESET} Could not read file: ${err instanceof Error ? err.message : String(err)}`,

packages/boxel-cli/src/commands/realm/push.ts

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,23 @@ class RealmPusher extends RealmSyncBase {
200200

201201
const result = await this.uploadFilesAtomic(filesToUpload, addPaths);
202202

203+
// Record every file the server actually wrote before surfacing
204+
// errors. uploadFilesAtomic can return both `succeeded` and
205+
// `error` when the atomic text batch lands but a per-file
206+
// binary POST fails — dropping the manifest update in that
207+
// case would force a re-add on the next push (409 cascade).
208+
if (result.succeeded.length > 0) {
209+
const uploaded = await Promise.all(
210+
result.succeeded.map(async (rel) => ({
211+
rel,
212+
hash: await computeFileHash(filesToUpload.get(rel)!),
213+
})),
214+
);
215+
for (const { rel, hash } of uploaded) {
216+
newManifest.files[rel] = hash;
217+
}
218+
}
219+
203220
if (result.error) {
204221
uploadFailed = true;
205222
this.hasError = true;
@@ -215,16 +232,6 @@ class RealmPusher extends RealmSyncBase {
215232
}
216233
console.error(` ${hint}`);
217234
}
218-
} else if (result.succeeded.length > 0) {
219-
const uploaded = await Promise.all(
220-
result.succeeded.map(async (rel) => ({
221-
rel,
222-
hash: await computeFileHash(filesToUpload.get(rel)!),
223-
})),
224-
);
225-
for (const { rel, hash } of uploaded) {
226-
newManifest.files[rel] = hash;
227-
}
228235
}
229236
}
230237

@@ -270,7 +277,11 @@ class RealmPusher extends RealmSyncBase {
270277
}
271278
}
272279

273-
if (!this.options.dryRun && !uploadFailed && filesToUpload.size > 0) {
280+
// Refresh mtimes and save the manifest even on partial failure —
281+
// newManifest.files only contains files the server actually wrote
282+
// (unchanged carry-overs + succeeded uploads), so persisting it
283+
// is always safe and avoids re-uploading text files that landed.
284+
if (!this.options.dryRun && filesToUpload.size > 0) {
274285
try {
275286
const freshMtimes = await this.getRemoteMtimes();
276287
for (const rel of Object.keys(newManifest.files)) {
@@ -291,7 +302,7 @@ class RealmPusher extends RealmSyncBase {
291302
delete newManifest.remoteMtimes;
292303
}
293304

294-
if (!this.options.dryRun && !uploadFailed) {
305+
if (!this.options.dryRun) {
295306
await saveManifest(this.options.localDir, newManifest);
296307
}
297308

packages/boxel-cli/src/commands/realm/sync.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -314,14 +314,16 @@ class RealmSyncer extends RealmSyncBase {
314314
}
315315

316316
const result = await this.uploadFilesAtomic(filesToUpload, addPaths);
317+
// Record every file the server actually wrote, even when other
318+
// files in the same batch failed — see push.ts for the symmetric
319+
// reasoning.
320+
this.pushedFiles.push(...result.succeeded);
317321
if (result.error) {
318322
this.hasError = true;
319323
console.error(result.error.message);
320324
for (const entry of result.error.perFile) {
321325
console.error(` ${entry.path}: ${entry.title}`);
322326
}
323-
} else {
324-
this.pushedFiles.push(...result.succeeded);
325327
}
326328
}
327329

@@ -371,8 +373,12 @@ class RealmSyncer extends RealmSyncBase {
371373
);
372374
}
373375

374-
// Phase 6: Update manifest
375-
if (!this.options.dryRun && !this.hasError) {
376+
// Phase 6: Update manifest. Persist even on partial failure — we
377+
// only record hashes for files the server actually wrote
378+
// (pushedFiles + pulledFiles), so the manifest stays consistent
379+
// with the realm and the next sync won't re-attempt successful
380+
// files.
381+
if (!this.options.dryRun) {
376382
// Build updated hashes from prior manifest + current local files + executed ops.
377383
// Start with the previous manifest so that files deleted locally but not
378384
// propagated (no --delete) retain their entries and aren't re-pulled next sync.

0 commit comments

Comments
 (0)