Skip to content

src/archive.ts

209 lines · 8.8 KB · TypeScript
Raw
/**
 * 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<Uint8Array> {
	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<ArchiveEntry[]> {
	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<string>()
		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<string>()
	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 }
}