/** * Extract a zip or tar(.gz) static-site archive into site files. No npm * dependencies: zip central directory + tar headers are parsed here, and * deflate / gzip use the Web Streams `DecompressionStream` (Workers + Node). */ import { contentTypeFor, isIgnoredSitePath, normalizeSitePath, type SiteFile, } from './static-site.ts' export type ArchiveEntry = { path: string; data: Uint8Array } export type ArchiveFormat = 'zip' | 'tar' | 'tar.gz' export function detectArchiveFormat(bytes: Uint8Array): ArchiveFormat { if (bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b && (bytes[2] === 0x03 || bytes[2] === 0x05)) { return 'zip' } if (bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b) return 'tar.gz' if (bytes.length >= 263 && new TextDecoder().decode(bytes.subarray(257, 262)) === 'ustar') { return 'tar' } throw new Error('sourceUrl must point to a .zip, .tar, or .tar.gz archive (unrecognized bytes).') } async function decompress(bytes: Uint8Array, format: 'gzip' | 'deflate-raw'): Promise { const stream = new Blob([bytes as BlobPart]).stream().pipeThrough(new DecompressionStream(format)) return new Uint8Array(await new Response(stream).arrayBuffer()) } /** Parse a (non-zip64, unencrypted) zip: stored and deflate entries. */ export async function parseZip(bytes: Uint8Array): Promise { const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) let eocd = -1 for (let i = bytes.length - 22; i >= Math.max(0, bytes.length - 65557); i--) { if (view.getUint32(i, true) === 0x06054b50) { eocd = i break } } if (eocd === -1) throw new Error('Corrupt zip: end of central directory not found.') const count = view.getUint16(eocd + 10, true) let offset = view.getUint32(eocd + 16, true) if (offset === 0xffffffff || count === 0xffff) { throw new Error('Zip64 archives are not supported; use a .tar.gz or a smaller zip.') } const decoder = new TextDecoder() const out: ArchiveEntry[] = [] for (let n = 0; n < count; n++) { if (view.getUint32(offset, true) !== 0x02014b50) throw new Error('Corrupt zip: bad central directory entry.') const flags = view.getUint16(offset + 8, true) const method = view.getUint16(offset + 10, true) const compressedSize = view.getUint32(offset + 20, true) const nameLength = view.getUint16(offset + 28, true) const extraLength = view.getUint16(offset + 30, true) const commentLength = view.getUint16(offset + 32, true) const localOffset = view.getUint32(offset + 42, true) const path = decoder.decode(bytes.subarray(offset + 46, offset + 46 + nameLength)) offset += 46 + nameLength + extraLength + commentLength if (path.endsWith('/')) continue if (flags & 0x1) throw new Error(`Encrypted zip entry "${path}" is not supported.`) if (view.getUint32(localOffset, true) !== 0x04034b50) throw new Error(`Corrupt zip: bad local header for "${path}".`) const dataStart = localOffset + 30 + view.getUint16(localOffset + 26, true) + view.getUint16(localOffset + 28, true) const raw = bytes.subarray(dataStart, dataStart + compressedSize) if (method === 0) out.push({ path, data: raw.slice() }) else if (method === 8) out.push({ path, data: await decompress(raw, 'deflate-raw') }) else throw new Error(`Zip entry "${path}" uses unsupported compression method ${method}.`) } return out } function readString(bytes: Uint8Array, start: number, length: number): string { const slice = bytes.subarray(start, start + length) const end = slice.indexOf(0) return new TextDecoder().decode(end === -1 ? slice : slice.subarray(0, end)) } /** Parse ustar / GNU / pax tar bytes into regular-file entries. */ export function parseTar(bytes: Uint8Array): ArchiveEntry[] { const out: ArchiveEntry[] = [] let offset = 0 let longName: string | null = null let paxPath: string | null = null while (offset + 512 <= bytes.length) { const header = bytes.subarray(offset, offset + 512) if (header.every((b) => b === 0)) break const name = readString(header, 0, 100) const size = parseInt(readString(header, 124, 12).trim() || '0', 8) const type = String.fromCharCode(header[156] || 0x30) const prefix = readString(header, 345, 155) if (!Number.isFinite(size) || size < 0) throw new Error('Corrupt tar archive (bad size).') const dataStart = offset + 512 const data = bytes.subarray(dataStart, dataStart + size) offset = dataStart + Math.ceil(size / 512) * 512 if (type === 'L') { longName = readString(data, 0, data.length) continue } if (type === 'x') { const text = new TextDecoder().decode(data) const match = /(?:^|\n)\d+ path=([^\n]*)\n/.exec(text) paxPath = match ? match[1] : null continue } if (type === 'g') continue const path = paxPath ?? longName ?? (prefix ? `${prefix}/${name}` : name) longName = null paxPath = null if (type !== '0' && type !== '\0') continue out.push({ path, data: data.slice() }) } return out } /** Read zip / tar / tar.gz bytes into raw entries (directories skipped). */ export async function readArchive(bytes: Uint8Array): Promise<{ format: ArchiveFormat; entries: ArchiveEntry[] }> { const format = detectArchiveFormat(bytes) if (format === 'zip') return { format, entries: await parseZip(bytes) } const tarBytes = format === 'tar.gz' ? await decompress(bytes, 'gzip') : bytes return { format, entries: parseTar(tarBytes) } } /** * Choose which archive entries become the site root. * * - `sourceRoot` given: keep entries under that folder (a leading `*` segment * matches any single top-level folder, e.g. `* /dist` for GitHub tarballs). * - Otherwise, when every entry sits under one shared top-level folder, strip * it (common for `zip -r site.zip dist`). */ export function selectSiteEntries( entries: ArchiveEntry[], sourceRoot?: string, ): { entries: ArchiveEntry[]; root: string } { const cleaned = entries .map((entry) => ({ path: entry.path.replace(/\\/g, '/').replace(/^(\.\/)+/, '').replace(/^\/+/, ''), data: entry.data })) .filter((entry) => entry.path && !entry.path.endsWith('/')) .filter((entry) => !entry.path.split('/').some((segment) => segment === '__MACOSX')) if (sourceRoot !== undefined) { const rootSegments = String(sourceRoot).replace(/\\/g, '/').split('/').filter((s) => s && s !== '.') if (rootSegments.some((segment) => segment === '..')) { throw new Error('sourceRoot must not contain "..".') } const kept: ArchiveEntry[] = [] const matchedRoots = new Set() for (const entry of cleaned) { const segments = entry.path.split('/') if (segments.length <= rootSegments.length) continue const matches = rootSegments.every((segment, i) => segment === '*' || segment === segments[i]) if (!matches) continue matchedRoots.add(segments.slice(0, rootSegments.length).join('/')) kept.push({ path: segments.slice(rootSegments.length).join('/'), data: entry.data }) } if (kept.length === 0) { throw new Error(`sourceRoot "${sourceRoot}" matched no files in the archive.`) } if (matchedRoots.size > 1) { throw new Error(`sourceRoot "${sourceRoot}" matched multiple folders: ${[...matchedRoots].join(', ')}.`) } return { entries: kept, root: [...matchedRoots][0] ?? '' } } const tops = new Set(cleaned.map((entry) => (entry.path.includes('/') ? entry.path.split('/')[0] : ''))) if (tops.size === 1 && !tops.has('')) { const [top] = [...tops] return { entries: cleaned.map((entry) => ({ path: entry.path.slice(top.length + 1), data: entry.data })), root: top, } } return { entries: cleaned, root: '' } } /** Convert selected entries into normalized SiteFiles (ignored junk dropped). */ export function entriesToSiteFiles(entries: ArchiveEntry[]): SiteFile[] { const files: SiteFile[] = [] const seen = new Set() for (const entry of entries) { const path = normalizeSitePath(entry.path) if (isIgnoredSitePath(path)) continue if (seen.has(path)) throw new Error(`Archive contains duplicate path "${path}".`) seen.add(path) files.push({ path, bytes: entry.data, contentType: contentTypeFor(path) }) } return files } /** Fetch a public zip / tar / tar.gz and return its site files. */ export async function loadSiteArchive( sourceUrl: string, sourceRoot?: string, ): Promise<{ files: SiteFile[]; format: ArchiveFormat; root: string; bytes: number }> { const url = String(sourceUrl || '').trim() if (!/^https:\/\//i.test(url)) { throw new Error('sourceUrl must be an https:// URL to a zip or tar(.gz) archive.') } const response = await fetch(url) if (!response.ok) { throw new Error(`sourceUrl fetch failed: HTTP ${response.status}. It must be publicly reachable (no auth).`) } const bytes = new Uint8Array(await response.arrayBuffer()) const { format, entries } = await readArchive(bytes) const selected = selectSiteEntries(entries, sourceRoot) return { files: entriesToSiteFiles(selected.entries), format, root: selected.root, bytes: bytes.length } }