← Public packages
@kentcdodds/cloudflare
Cloudflare helpers: API v4, static site publishing to Pages, analytics, WAF rules, logs, and docs.
src/archive.ts
209 lines · 8.8 KB · TypeScript/**
* 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 }
}