import { readdirSync, readFileSync, statSync } from 'node:fs'; import { join, relative } from 'node:path'; // A ZIP writer, in about seventy lines, so the extension download can never be stale. // // ── Why this exists rather than a file in public/ ── // // It was a file in public/: `browser-relay-extension.zip`, built by hand and served as a static asset. // On 2026-08-21 it was found to be 58 lines behind the source it claimed to be — built in August from // something that predated a February commit, and never rebuilt since. Anyone who downloaded it got an // extension missing the multi-session recovery path, which is exactly the failure they would then hit. // // A snapshot drifts because nothing makes it not drift. Building from the directory at request time // removes the class: there is no second copy to fall behind. // // ── Why hand-rolled, and why STORED rather than deflated ── // // The router's own history says the previous version of this shelled out to `zip`. That works and it // makes the plugin need a host binary for a convenience feature — this manifest's "nothing to declare" // is worth more than the compression. Bun ships no zip writer, and the stored format is genuinely small: // a local header per file, a central directory, and an end record. // // Chrome accepts stored entries. The extension is ~50 KB of text; deflate would save perhaps 30 KB on a // download that happens once. /** CRC-32, the one thing in the format that is not just bytes in order. */ function crc32(bytes: Uint8Array): number { let crc = 0xffffffff; for (const byte of bytes) { crc ^= byte; for (let i = 0; i < 8; i++) crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1; } return (crc ^ 0xffffffff) >>> 0; } type Entry = { name: string; data: Uint8Array; crc: number; offset: number }; const u16 = (v: number) => [v & 0xff, (v >>> 8) & 0xff]; const u32 = (v: number) => [v & 0xff, (v >>> 8) & 0xff, (v >>> 16) & 0xff, (v >>> 24) & 0xff]; /** Every file under `dir`, depth first, with paths relative to it. */ function collect(dir: string, root = dir): { name: string; data: Uint8Array }[] { const out: { name: string; data: Uint8Array }[] = []; for (const item of readdirSync(dir).sort()) { const path = join(dir, item); if (statSync(path).isDirectory()) out.push(...collect(path, root)); else out.push({ name: relative(root, path), data: new Uint8Array(readFileSync(path)) }); } return out; } /** * Zip a directory, stored, with a fixed timestamp. * * The timestamp is fixed (1980-01-01, the format's own zero) so the same directory always produces * byte-identical output. That makes the download cacheable and, more usefully, makes "did this change" * answerable by comparing two files rather than unpacking them. */ export function zipDirectory(dir: string): Uint8Array { const files = collect(dir); const parts: number[] = []; const entries: Entry[] = []; for (const file of files) { const nameBytes = [...new TextEncoder().encode(file.name)]; const crc = crc32(file.data); entries.push({ ...file, crc, offset: parts.length }); parts.push( ...u32(0x04034b50), // local file header ...u16(20), // version needed ...u16(0), // flags ...u16(0), // method 0 = stored ...u16(0), // time ...u16(0x0021), // date — 1980-01-01 ...u32(crc), ...u32(file.data.length), // compressed size == uncompressed, stored ...u32(file.data.length), ...u16(nameBytes.length), ...u16(0), // extra length ...nameBytes, ...file.data, ); } const centralStart = parts.length; for (const entry of entries) { const nameBytes = [...new TextEncoder().encode(entry.name)]; parts.push( ...u32(0x02014b50), // central directory header ...u16(20), // version made by ...u16(20), // version needed ...u16(0), ...u16(0), ...u16(0), ...u16(0x0021), ...u32(entry.crc), ...u32(entry.data.length), ...u32(entry.data.length), ...u16(nameBytes.length), ...u16(0), // extra ...u16(0), // comment ...u16(0), // disk ...u16(0), // internal attrs ...u32(0), // external attrs ...u32(entry.offset), ...nameBytes, ); } parts.push( ...u32(0x06054b50), // end of central directory ...u16(0), ...u16(0), ...u16(entries.length), ...u16(entries.length), ...u32(parts.length - centralStart), ...u32(centralStart), ...u16(0), // comment length ); return new Uint8Array(parts); }