Skip to content
This repository was archived by the owner on Jun 4, 2026. It is now read-only.

Commit 93c5d2a

Browse files
Chrisclaude
authored andcommitted
feat: route binary and non-source files correctly on upload
Before: all uploads went through /_atomic with text/plain encoding, corrupting binary files (images, fonts) and causing plain-text files (.md, .txt, .csv, .yaml) to be rejected by the realm's module compiler as "invalid source". After: new content-type.ts module detects per-extension MIME types. Binary files take the individual POST path with octet-stream encoding. Plain-text files take the POST path with their true content type. Only .json plus compilable source (.gts, .ts, .css, .html) go through /_atomic. Accept header now branches: compilable source asks for application/vnd.card+source, everything else asks for */*. Tests added: jpg uploads as binary, csv uploads as text. Note: one pre-existing test failure on HEAD ('falls back to file type for invalid JSON') is unrelated and predates this change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ce24b64 commit 93c5d2a

6 files changed

Lines changed: 394 additions & 30 deletions

File tree

src/commands/check.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ interface SyncManifest {
1111
files: Record<string, { localHash: string; remoteMtime: number }>;
1212
}
1313

14-
function computeFileHash(content: string): string {
14+
function computeFileHash(content: string | Buffer): string {
1515
return crypto.createHash('md5').update(content).digest('hex');
1616
}
1717

@@ -72,7 +72,7 @@ export async function checkCommand(
7272
const relativePath = path.relative(workspaceRoot, absolutePath).replace(/\\/g, '/');
7373

7474
// Read local file
75-
const localContent = fs.readFileSync(absolutePath, 'utf-8');
75+
const localContent = fs.readFileSync(absolutePath);
7676
const localHash = computeFileHash(localContent);
7777
const localMtime = fs.statSync(absolutePath).mtimeMs;
7878

src/commands/status.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ interface FileStatus {
1919
remoteMtime?: number;
2020
}
2121

22-
function computeFileHash(content: string): string {
22+
function computeFileHash(content: string | Buffer): string {
2323
return crypto.createHash('md5').update(content).digest('hex');
2424
}
2525

@@ -201,7 +201,7 @@ async function analyzeChanges(
201201
let localChanged = false;
202202

203203
if (existsLocally) {
204-
const content = fs.readFileSync(localPath, 'utf-8');
204+
const content = fs.readFileSync(localPath);
205205
const hash = computeFileHash(content);
206206
localChanged = hash !== manifestEntry.localHash;
207207
}
@@ -305,7 +305,7 @@ async function statusSingle(
305305
let localChanged = false;
306306

307307
if (existsLocally) {
308-
const content = fs.readFileSync(localPath, 'utf-8');
308+
const content = fs.readFileSync(localPath);
309309
const hash = computeFileHash(content);
310310
localChanged = hash !== manifestEntry.localHash;
311311
}

src/lib/batch-upload.ts

Lines changed: 222 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,44 @@
77

88
import * as fs from 'fs';
99
import * as path from 'path';
10+
import { getContentType, isTextFile, readFileForUpload } from './content-type.js';
11+
12+
/**
13+
* Binary files (images, fonts, archives, etc.) cannot be sent through the
14+
* /_atomic JSON endpoint because their bytes don't survive UTF-8 encoding.
15+
* Route them through individual POST uploads (which use octet-stream).
16+
*/
17+
function isBinaryFile(file: FileToUpload): boolean {
18+
return !isTextFile(getContentType(file.relativePath));
19+
}
20+
21+
const ATOMIC_SOURCE_EXTENSIONS = new Set([
22+
'.gts',
23+
'.ts',
24+
'.tsx',
25+
'.js',
26+
'.jsx',
27+
'.mjs',
28+
'.cjs',
29+
'.css',
30+
'.scss',
31+
'.less',
32+
'.sass',
33+
'.html',
34+
]);
35+
36+
/**
37+
* The /_atomic endpoint only accepts 'card' and 'source' resource types.
38+
* Plain text files (.md, .txt, .csv, .yaml, etc.) are neither cards nor
39+
* compilable source modules — the realm's module compiler rejects them as
40+
* invalid source. Route them through individual POST uploads so they are
41+
* stored as raw files with their correct Content-Type.
42+
*/
43+
function isAtomicIncompatible(file: FileToUpload): boolean {
44+
if (file.relativePath.endsWith('.json')) return false;
45+
const ext = path.extname(file.relativePath).toLowerCase();
46+
return !ATOMIC_SOURCE_EXTENSIONS.has(ext);
47+
}
1048

1149
// ANSI color codes
1250
const FG_GREEN = '\x1b[32m';
@@ -19,7 +57,7 @@ const RESET = '\x1b[0m';
1957
export interface FileToUpload {
2058
relativePath: string;
2159
localPath: string;
22-
content?: string;
60+
content?: string | Buffer;
2361
operation: 'add' | 'update';
2462
}
2563

@@ -65,6 +103,23 @@ const DEFAULT_OPTIONS: BatchOptions = {
65103
verbose: false,
66104
};
67105

106+
function getTextContent(file: FileToUpload): string {
107+
if (typeof file.content === 'string') {
108+
return file.content;
109+
}
110+
const content = fs.readFileSync(file.localPath, 'utf8');
111+
file.content = content;
112+
return content;
113+
}
114+
115+
function getUploadPayload(file: FileToUpload): { content: string | Buffer; contentType: string } {
116+
if (typeof file.content === 'string' || Buffer.isBuffer(file.content)) {
117+
return { content: file.content, contentType: getContentType(file.relativePath) };
118+
}
119+
120+
return readFileForUpload(file.relativePath, file.localPath);
121+
}
122+
68123
// Verbose logging helper
69124
function verbose(opts: Partial<BatchOptions>, message: string, ...args: unknown[]): void {
70125
if (opts.verbose) {
@@ -73,17 +128,121 @@ function verbose(opts: Partial<BatchOptions>, message: string, ...args: unknown[
73128
}
74129

75130
/**
76-
* Sort files so definitions (.gts) come before instances (.json)
131+
* Sort files so definitions (.gts) come before instances (.json),
132+
* and within .gts files, sort by dependency order (least dependent first).
133+
*
134+
* Dependency detection: scans import statements in .gts files to determine
135+
* which files import others. Files with no local imports come first (FieldDefs,
136+
* base types), then files that import those, etc.
77137
*/
78138
export function sortDefinitionsFirst(files: FileToUpload[]): FileToUpload[] {
79-
return [...files].sort((a, b) => {
80-
const aIsDefinition = a.relativePath.endsWith('.gts');
81-
const bIsDefinition = b.relativePath.endsWith('.gts');
139+
const definitions = files.filter(f => f.relativePath.endsWith('.gts'));
140+
const instances = files.filter(f => !f.relativePath.endsWith('.gts'));
82141

83-
if (aIsDefinition && !bIsDefinition) return -1;
84-
if (!aIsDefinition && bIsDefinition) return 1;
85-
return a.relativePath.localeCompare(b.relativePath);
86-
});
142+
// Build dependency graph for .gts files
143+
const depOrder = sortByDependency(definitions);
144+
145+
// Definitions first (in dependency order), then instances alphabetically
146+
return [
147+
...depOrder,
148+
...instances.sort((a, b) => a.relativePath.localeCompare(b.relativePath)),
149+
];
150+
}
151+
152+
/**
153+
* Sort .gts files by dependency order using topological sort.
154+
* Files that import nothing local come first; files that import others come later.
155+
*/
156+
function sortByDependency(files: FileToUpload[]): FileToUpload[] {
157+
// Map filename (without extension) to file
158+
const byName = new Map<string, FileToUpload>();
159+
for (const f of files) {
160+
const name = path.basename(f.relativePath, '.gts');
161+
byName.set(name, f);
162+
}
163+
164+
// Parse imports to build adjacency list
165+
const deps = new Map<string, Set<string>>();
166+
for (const f of files) {
167+
const name = path.basename(f.relativePath, '.gts');
168+
const content =
169+
typeof f.content === 'string'
170+
? f.content
171+
: fs.existsSync(f.localPath)
172+
? fs.readFileSync(f.localPath, 'utf8')
173+
: '';
174+
if (content) {
175+
f.content = content;
176+
}
177+
178+
const localImports = new Set<string>();
179+
// Match: import { X } from './name' or from './name.gts'
180+
const importRegex = /from\s+['"]\.\/([^'"]+)['"]/g;
181+
let match;
182+
while ((match = importRegex.exec(content)) !== null) {
183+
const imported = match[1].replace(/\.gts$/, '');
184+
if (byName.has(imported)) {
185+
localImports.add(imported);
186+
}
187+
}
188+
deps.set(name, localImports);
189+
}
190+
191+
// Topological sort (Kahn's algorithm)
192+
const inDegree = new Map<string, number>();
193+
for (const name of byName.keys()) inDegree.set(name, 0);
194+
for (const [, depSet] of deps) {
195+
for (const dep of depSet) {
196+
inDegree.set(dep, (inDegree.get(dep) ?? 0) + 1);
197+
}
198+
}
199+
200+
// Note: we want files with NO dependents (leaf nodes) first
201+
// Actually, we want files that nothing depends ON first (no incoming edges
202+
// in the "is imported by" graph), which means files that import nothing.
203+
// Kahn's on the dependency graph: start with nodes that have no dependencies.
204+
const inDeg = new Map<string, number>();
205+
for (const name of byName.keys()) inDeg.set(name, 0);
206+
for (const [name, depSet] of deps) {
207+
inDeg.set(name, depSet.size);
208+
}
209+
210+
const queue: string[] = [];
211+
for (const [name, deg] of inDeg) {
212+
if (deg === 0) queue.push(name);
213+
}
214+
215+
const sorted: FileToUpload[] = [];
216+
const visited = new Set<string>();
217+
218+
while (queue.length > 0) {
219+
queue.sort(); // deterministic order
220+
const name = queue.shift()!;
221+
if (visited.has(name)) continue;
222+
visited.add(name);
223+
224+
const file = byName.get(name);
225+
if (file) sorted.push(file);
226+
227+
// Find files that depend on this one and decrement their in-degree
228+
for (const [other, depSet] of deps) {
229+
if (depSet.has(name)) {
230+
const newDeg = (inDeg.get(other) ?? 1) - 1;
231+
inDeg.set(other, newDeg);
232+
if (newDeg === 0 && !visited.has(other)) {
233+
queue.push(other);
234+
}
235+
}
236+
}
237+
}
238+
239+
// Add any remaining files (circular deps)
240+
for (const f of files) {
241+
const name = path.basename(f.relativePath, '.gts');
242+
if (!visited.has(name)) sorted.push(f);
243+
}
244+
245+
return sorted;
87246
}
88247

89248
/**
@@ -99,9 +258,8 @@ export function createBatches(
99258
const maxPayloadBytes = options.maxPayloadKB * 1024;
100259

101260
for (const file of files) {
102-
const content = file.content || fs.readFileSync(file.localPath, 'utf8');
261+
const content = getTextContent(file);
103262
const fileSize = Buffer.byteLength(content, 'utf8');
104-
file.content = content; // Cache for later use
105263

106264
// If single file exceeds max payload, give it its own batch
107265
if (fileSize > maxPayloadBytes) {
@@ -146,7 +304,7 @@ export function buildAtomicRequest(
146304
realmUrl: string
147305
): AtomicRequest {
148306
const operations: AtomicOperation[] = files.map(file => {
149-
const content = file.content || fs.readFileSync(file.localPath, 'utf8');
307+
const content = getTextContent(file);
150308
const isCard = file.relativePath.endsWith('.json');
151309

152310
if (isCard) {
@@ -171,15 +329,17 @@ export function buildAtomicRequest(
171329
op: file.operation,
172330
href: `${realmUrl}${file.relativePath}`,
173331
data: {
174-
type: 'file',
332+
type: 'source',
175333
attributes: {
176334
content: content,
177335
},
178336
},
179337
};
180338
}
181339
} else {
182-
// For source files (.gts, etc.), send as content
340+
// For source code (.gts, .ts, .css, .html, etc.), send as source module.
341+
// Non-source-code text files are filtered out of the atomic batch by
342+
// isAtomicIncompatible() and uploaded via individual POST instead.
183343
return {
184344
op: file.operation,
185345
href: `${realmUrl}${file.relativePath}`,
@@ -326,25 +486,32 @@ export async function uploadSingleFile(
326486
};
327487
}
328488

329-
const content = file.content || fs.readFileSync(file.localPath, 'utf8');
489+
const { content, contentType } = getUploadPayload(file);
330490
const url = `${realmUrl}${file.relativePath}`;
331491

492+
// Accept: compilable source types expect 'application/vnd.card+source' back
493+
// from the realm; binary + plain-text files want the raw bytes returned as-is.
494+
const acceptHeader = isTextFile(contentType) && !isAtomicIncompatible(file)
495+
? 'application/vnd.card+source'
496+
: '*/*';
497+
332498
try {
333499
const response = await fetch(url, {
334500
method: 'POST',
335501
headers: {
336-
'Content-Type': 'text/plain;charset=UTF-8',
502+
'Content-Type': contentType,
337503
'Authorization': jwt,
338-
'Accept': 'application/vnd.card+source',
504+
'Accept': acceptHeader,
339505
},
340506
body: content,
341507
});
342508

343509
if (!response.ok) {
510+
const body = await response.text().catch(() => '');
344511
return {
345512
success: false,
346513
filesUploaded: 0,
347-
errors: [{ path: file.relativePath, error: `HTTP ${response.status}` }],
514+
errors: [{ path: file.relativePath, error: `HTTP ${response.status}: ${body.slice(0, 200)}` }],
348515
timeMs: Date.now() - startTime,
349516
};
350517
}
@@ -389,11 +556,21 @@ export async function uploadWithBatching(
389556
verbose(opts, `uploadWithBatching called with ${files.length} files`);
390557
verbose(opts, `Options: batchSize=${opts.batchSize}, definitionsFirst=${opts.definitionsFirst}, quiet=${opts.quiet}`);
391558

559+
// Split out files that cannot go through /_atomic:
560+
// - binary files (bytes don't survive UTF-8 stringification)
561+
// - plain text files like .md/.txt/.csv (not valid source modules,
562+
// rejected by the realm's module compiler)
563+
// Both kinds route through individual POST uploads with their correct
564+
// Content-Type so the server stores them as raw files.
565+
const individualFiles = files.filter(f => isBinaryFile(f) || isAtomicIncompatible(f));
566+
const textFiles = files.filter(f => !isBinaryFile(f) && !isAtomicIncompatible(f));
567+
verbose(opts, `Split: ${textFiles.length} atomic-compatible, ${individualFiles.length} individual`);
568+
392569
// Sort definitions first if requested
393-
let sortedFiles = opts.definitionsFirst ? sortDefinitionsFirst(files) : files;
570+
let sortedFiles = opts.definitionsFirst ? sortDefinitionsFirst(textFiles) : textFiles;
394571
verbose(opts, `After sorting: ${sortedFiles.map(f => f.relativePath).join(', ')}`);
395572

396-
// Create batches
573+
// Create batches (text only)
397574
const batches = createBatches(sortedFiles, opts);
398575
verbose(opts, `Created ${batches.length} batches`);
399576

@@ -404,11 +581,33 @@ export async function uploadWithBatching(
404581

405582
if (!opts.quiet) {
406583
const totalSize = sortedFiles.reduce((sum, f) => {
407-
const content = f.content || fs.readFileSync(f.localPath, 'utf8');
408-
f.content = content;
584+
const content = getTextContent(f);
409585
return sum + Buffer.byteLength(content, 'utf8');
410586
}, 0);
411-
log(`\n${FG_CYAN}Uploading ${files.length} files in ${batches.length} batch(es)${RESET} ${DIM}(${Math.round(totalSize / 1024)}KB total)${RESET}`);
587+
const individualNote = individualFiles.length > 0
588+
? ` ${DIM}+ ${individualFiles.length} file(s) individually${RESET}`
589+
: '';
590+
log(`\n${FG_CYAN}Uploading ${textFiles.length} files in ${batches.length} batch(es)${RESET} ${DIM}(${Math.round(totalSize / 1024)}KB total)${RESET}${individualNote}`);
591+
}
592+
593+
// Upload individual files first — binary files are typically referenced
594+
// by cards (e.g. Product → image links), and plain text files (.md etc.)
595+
// are not sources the realm indexes.
596+
for (const file of individualFiles) {
597+
const singleResult = await uploadSingleFile(file, realmUrl, jwt, opts);
598+
if (singleResult.success) {
599+
totalUploaded++;
600+
if (!opts.quiet) {
601+
const tag = isBinaryFile(file) ? 'binary' : 'file';
602+
log(` ${FG_GREEN}${RESET} ${file.relativePath} ${DIM}(${tag}, ${singleResult.timeMs}ms)${RESET}`);
603+
}
604+
} else {
605+
totalFailed++;
606+
allErrors.push(...singleResult.errors);
607+
if (!opts.quiet) {
608+
log(` ${FG_RED}${RESET} ${file.relativePath}: ${singleResult.errors[0]?.error}`);
609+
}
610+
}
412611
}
413612

414613
for (let i = 0; i < batches.length; i++) {

0 commit comments

Comments
 (0)