feat(preview): render XLSX spreadsheets in the file-preview overlay

xlsx files were download-only. Add a read-only, virtualized preview (sheet
tabs, number formats, merges, theme colours) parsed entirely in a browser
Web Worker with exceljs and fflate, loaded only when a spreadsheet is
opened. The workbook is checked against ZIP-bomb, entry and cell limits
before exceljs loads; cell text is written with textContent, formulas are
never evaluated and nothing referenced by the workbook is fetched. On the
server xlsx only joins the existing allowlist and classification, with a
10 MB cap on ?preview=true. xls and ods stay download-only.
This commit is contained in:
Aamer Akhter
2026-09-26 23:13:21 -04:00
parent 45ea2e1d32
commit 0b122e2c76
27 changed files with 3765 additions and 13 deletions
+4
View File
@@ -49,6 +49,9 @@ run('xterm-addon-serialize', 'npx esbuild node_modules/@xterm/addon-serialize/li
run('xterm-addon-webgl', 'cp node_modules/@xterm/addon-webgl/lib/addon-webgl.js dist/web/public/vendor/xterm-addon-webgl.min.js');
run('xterm-addon-unicode11', 'npx esbuild node_modules/@xterm/addon-unicode11/lib/addon-unicode11.js --minify --outfile=dist/web/public/vendor/xterm-addon-unicode11.min.js');
run('xterm-zerolag-input', 'npx esbuild packages/xterm-zerolag-input/src/zerolag-input-addon.ts --bundle --minify --format=iife --global-name=XtermZerolagInput --outfile=dist/web/public/vendor/xterm-zerolag-input.js');
// XLSX preview parser bundles: loaded only inside spreadsheet-preview-worker.js,
// never by the page (see scripts/prepare-spreadsheet-assets.mjs).
run('spreadsheet preview vendors', 'node scripts/prepare-spreadsheet-assets.mjs dist/web/public/vendor');
// Append global aliases so app.js can use `new LocalEchoOverlay(terminal)`
appendFileSync(
@@ -125,6 +128,7 @@ console.log('\n[build] content-hash cache busting');
'api-client.js',
'subagent-windows.js',
'image-input.js',
'spreadsheet-preview.js',
'vendor/xterm-zerolag-input.js',
'vendor/xterm-predictive-echo.js',
];
+39 -1
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import { readdirSync, readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { dirname, extname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -9,6 +10,10 @@ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const publicRoot = resolve(repoRoot, 'src/web/public');
const prettierBin = resolve(repoRoot, 'node_modules/.bin/prettier');
const checkedExtensions = new Set(['.js', '.css', '.html', '.json']);
// Combined budget for the two XLSX-preview vendor bundles (exceljs + fflate).
// They load only inside the spreadsheet worker, but a dependency bump that
// balloons them should be a deliberate decision, not a silent one.
const SPREADSHEET_VENDOR_MAX_BYTES = 1_100_000;
function collectTextAssets(dir) {
const files = [];
@@ -35,6 +40,39 @@ function findNullByte(buffer) {
const files = collectTextAssets(publicRoot);
const failures = [];
// The spreadsheet worker is a stable (unhashed) URL, cache-busted by the
// SPREADSHEET_ASSET_VERSION token in spreadsheet-preview.js. That token must be
// the content hash of everything the worker loads, or a deploy can pair a new
// worker with a stale cached core/vendor file (static assets are cached 1y).
const spreadsheetWorker = join(publicRoot, 'spreadsheet-preview-worker.js');
const spreadsheetCore = join(publicRoot, 'spreadsheet-xlsx-core.js');
const spreadsheetEntry = join(publicRoot, 'spreadsheet-preview.js');
const spreadsheetVendors = [join(publicRoot, 'vendor', 'exceljs.min.js'), join(publicRoot, 'vendor', 'fflate.min.js')];
if ([spreadsheetWorker, spreadsheetCore, spreadsheetEntry, ...spreadsheetVendors].every(existsSync)) {
const vendorBytes = spreadsheetVendors.reduce((total, file) => total + readFileSync(file).length, 0);
if (vendorBytes > SPREADSHEET_VENDOR_MAX_BYTES) {
failures.push(`Spreadsheet vendor bundles exceed ${SPREADSHEET_VENDOR_MAX_BYTES} bytes (${vendorBytes} bytes)`);
}
const expectedVersion = createHash('sha256')
.update(readFileSync(spreadsheetWorker))
.update(readFileSync(spreadsheetCore))
.update(readFileSync(spreadsheetVendors[0]))
.update(readFileSync(spreadsheetVendors[1]))
.digest('hex')
.slice(0, 12);
const entrySource = readFileSync(spreadsheetEntry, 'utf8');
const actualVersion = entrySource.match(/const SPREADSHEET_ASSET_VERSION = '([a-f0-9]+)'/)?.[1];
if (actualVersion !== expectedVersion) {
failures.push(
`SPREADSHEET_ASSET_VERSION mismatch: expected ${expectedVersion}, found ${actualVersion || 'missing'}`
);
}
} else {
failures.push('Spreadsheet preview assets are missing; run `node scripts/prepare-spreadsheet-assets.mjs`');
}
for (const file of files) {
const rel = relative(repoRoot, file);
const data = readFileSync(file);
+16
View File
@@ -341,6 +341,22 @@ if (isGlobalInstall) {
}
}
// ----------------------------------------------------------------------------
// 4a. Copy the XLSX preview's browser bundles (exceljs, fflate) into
// src/web/public/vendor/ for dev mode. The build does the same into dist/.
// ----------------------------------------------------------------------------
if (!isGlobalInstall) {
try {
execSync(`node "${join(import.meta.dirname, 'prepare-spreadsheet-assets.mjs')}"`, { stdio: 'pipe' });
console.log(colors.green('✓ Spreadsheet preview vendor files prepared'));
} catch (err) {
hasWarnings = true;
console.log(colors.yellow('⚠ Failed to prepare spreadsheet preview vendor files'));
console.log(colors.dim(` ${err.message}`));
}
}
// ----------------------------------------------------------------------------
// 4b. Fetch gesture-overlay runtime assets (MediaPipe wasm + model) for dev mode
// (src/web/public/gesture/). Opt-in feature (CODEMAN_GESTURE=1); non-fatal.
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env node
/**
* Copy the XLSX preview's browser bundles (exceljs, fflate) into a public vendor
* dir. Run by postinstall for dev (src/web/public/vendor, gitignored) and by
* build.mjs for prod (dist/web/public/vendor). Both packages are pinned exactly
* in package.json, and check-public-assets.mjs hashes the output into
* SPREADSHEET_ASSET_VERSION (the worker's cache-bust token), so a version bump
* that changes the bytes fails that check until the token is refreshed.
* Source-map comments are stripped: the maps are not shipped.
*/
import { createRequire } from 'node:module';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
const require = createRequire(import.meta.url);
const outputDir = resolve(process.argv[2] || join(import.meta.dirname, '..', 'src', 'web', 'public', 'vendor'));
const excelSource = require.resolve('exceljs/dist/exceljs.min.js');
const fflateSource = join(dirname(require.resolve('fflate')), '..', 'umd', 'index.js');
function copyBrowserBundle(source, outputName) {
const content = readFileSync(source, 'utf8').replace(/\n?\/\/# sourceMappingURL=.*(?:\n|$)/g, '\n');
if (/sourceMappingURL/.test(content)) {
throw new Error(`Failed to strip sourceMappingURL from ${outputName}`);
}
writeFileSync(join(outputDir, outputName), content, 'utf8');
}
mkdirSync(outputDir, { recursive: true });
copyBrowserBundle(excelSource, 'exceljs.min.js');
copyBrowserBundle(fflateSource, 'fflate.min.js');