import JSZip from 'jszip' export type BuildZipInput = { html: string css: string js: string assets?: Record // root-level extra files like favicon.ico, site.webmanifest, robots.txt extras?: Record } export async function buildZip(input: BuildZipInput): Promise { const zip = new JSZip() zip.file('index.html', input.html) zip.file('styles.css', input.css) zip.file('app.js', input.js) // guard: basic path sanitizer to avoid directory traversal or absolute paths const isSafePath = (p: string) => { if (!p || typeof p !== 'string') return false if (p.startsWith('/')) return false if (p.includes('..')) return false if (p.includes('\\')) return false return true } if (input.assets) { for (const [path, bytes] of Object.entries(input.assets)) { if (!isSafePath(path)) throw new Error(`Invalid asset path: ${path}`) // In Node/Vitest, use Buffer for robust binary handling const buf = Buffer.from(bytes) zip.file(path, buf as unknown as Buffer) } } if (input.extras) { // validate manifest if present if (Object.prototype.hasOwnProperty.call(input.extras, 'site.webmanifest')) { try { const manifestBytes = input.extras['site.webmanifest'] const manifestStr = Buffer.from(manifestBytes).toString('utf8') JSON.parse(manifestStr) } catch { throw new Error('Invalid site.webmanifest JSON') } } for (const [path, bytes] of Object.entries(input.extras)) { if (!isSafePath(path)) throw new Error(`Invalid extra path: ${path}`) const buf = Buffer.from(bytes) zip.file(path, buf as unknown as Buffer) } } const content = await zip.generateAsync({ type: 'uint8array' }) return content }