// bundle-html.js - Netlify Function [DEPRECATED — bundling now happens on oven] // Generates self-contained HTML bundles on-demand via API // Supports both KidLisp pieces ($code) and JavaScript pieces (piece=name) // // Usage: // GET /api/bundle-html?code=39j - Bundle a KidLisp piece // GET /api/bundle-html?piece=notepat - Bundle a JavaScript .mjs piece // // Returns: Self-extracting gzip-compressed HTML file // // Optimization: Core system files are cached per git commit to speed up // subsequent bundle requests. Only piece-specific data is fetched per request. const { promises: fs } = require("fs"); const fsSync = require("fs"); const path = require("path"); const { gzipSync } = require("zlib"); const https = require("https"); const { execSync } = require("child_process"); // Netlify streaming support const { stream } = require("@netlify/functions"); // Get git commit from build-time env var, or dynamically from git in dev function getGitCommit() { if (process.env.GIT_COMMIT) return process.env.GIT_COMMIT; try { const hash = execSync("git rev-parse --short HEAD", { encoding: "utf8" }).trim(); const isDirty = execSync("git status --porcelain", { encoding: "utf8" }).trim().length > 0; return isDirty ? `${hash} (dirty)` : hash; } catch { return "unknown"; } } const GIT_COMMIT = getGitCommit(); const CONTEXT = process.env.CONTEXT || "production"; // In-memory cache for core bundle (persists across warm function invocations) // Key: git commit, Value: { coreFiles, fontFiles, timestamp } let coreBundleCache = null; let coreBundleCacheCommit = null; // Custom fetch that ignores self-signed certs in dev mode const devAgent = new https.Agent({ rejectUnauthorized: false }); async function devFetch(url, options = {}) { if (CONTEXT === 'dev' && url.startsWith('https://localhost')) { const { default: nodeFetch } = await import('node-fetch'); return nodeFetch(url, { ...options, agent: devAgent }); } return fetch(url, options); } // Essential files for KidLisp bundles (same as CLI) const ESSENTIAL_FILES = [ // Core system 'boot.mjs', 'bios.mjs', // Core loop and disk 'lib/loop.mjs', 'lib/disk.mjs', 'lib/parse.mjs', // KidLisp interpreter 'lib/kidlisp.mjs', // Graphics essentials 'lib/graph.mjs', 'lib/geo.mjs', 'lib/2d.mjs', 'lib/pen.mjs', 'lib/num.mjs', 'lib/gl.mjs', 'lib/webgl-blit.mjs', // System essentials 'lib/helpers.mjs', 'lib/logs.mjs', 'lib/store.mjs', 'lib/platform.mjs', 'lib/pack-mode.mjs', // BIOS dependencies 'lib/keyboard.mjs', 'lib/gamepad.mjs', 'lib/motion.mjs', 'lib/speech.mjs', 'lib/help.mjs', 'lib/midi.mjs', 'lib/usb.mjs', 'lib/headers.mjs', 'lib/glaze.mjs', 'lib/ui.mjs', // Disk dependencies 'disks/common/tape-player.mjs', // Sound dependencies 'lib/sound/sound-whitelist.mjs', // Audio worklet bundled for PACK mode 'lib/speaker-bundled.mjs', // gl-matrix dependencies 'dep/gl-matrix/common.mjs', 'dep/gl-matrix/vec2.mjs', 'dep/gl-matrix/vec3.mjs', 'dep/gl-matrix/vec4.mjs', 'dep/gl-matrix/mat3.mjs', 'dep/gl-matrix/mat4.mjs', 'dep/gl-matrix/quat.mjs', // Glaze dependencies 'lib/glazes/uniforms.js', ]; const SKIP_FILES = []; // Generate timestamp: YYYY.M.D.H.M.S.mmm function timestamp(date = new Date()) { const pad = (n, digits = 2) => n.toString().padStart(digits, "0"); return `${date.getFullYear()}.${date.getMonth() + 1}.${date.getDate()}.${date.getHours()}.${date.getMinutes()}.${date.getSeconds()}.${pad(date.getMilliseconds(), 3)}`; } // Extract painting short codes from KidLisp source function extractPaintingCodes(source) { const codes = []; const regex = /#([a-zA-Z0-9]{3})\b/g; let match; while ((match = regex.exec(source)) !== null) { if (!codes.includes(match[1])) codes.push(match[1]); } return codes; } // Resolve painting code to handle+slug via API async function resolvePaintingCode(code) { try { const baseUrl = CONTEXT === 'dev' ? 'https://localhost:8888' : 'https://aesthetic.computer'; const response = await devFetch(`${baseUrl}/api/painting-code?code=${code}`); if (!response.ok) return null; const data = await response.json(); return { code, handle: data.handle || 'anon', slug: data.slug }; } catch { return null; } } // Fetch painting PNG as base64 async function fetchPaintingImage(handle, slug) { const handlePath = handle === 'anon' ? '' : `@${handle}/`; const url = `https://aesthetic.computer/media/${handlePath}painting/${slug}.png`; try { const response = await devFetch(url); if (!response.ok) return null; const buffer = await response.arrayBuffer(); return Buffer.from(buffer).toString('base64'); } catch { return null; } } // Fetch author info from user ID (AC handle and permanent user code) async function fetchAuthorInfo(userId) { if (!userId) return { handle: null, userCode: null }; let acHandle = null; let userCode = null; // Get AC handle via handle endpoint try { const baseUrl = CONTEXT === 'dev' ? 'https://localhost:8888' : 'https://aesthetic.computer'; const response = await devFetch(`${baseUrl}/handle?for=${encodeURIComponent(userId)}`); if (response.ok) { const data = await response.json(); if (data.handle) acHandle = data.handle; // Don't add @ prefix for filenames } } catch { /* ignore */ } // Get permanent user code from users collection try { const { connect } = await import('../../backend/database.mjs'); const database = await connect(); const users = database.db.collection('users'); const user = await users.findOne({ _id: userId }, { projection: { code: 1 } }); if (user?.code) { userCode = user.code; } await database.disconnect(); } catch { /* ignore */ } return { handle: acHandle, userCode }; } function normalizeHandle(handle) { if (typeof handle !== 'string') return null; const cleaned = handle.trim().replace(/^@+/, ''); return cleaned || null; } // Fetch KidLisp source from API async function fetchKidLispFromAPI(pieceName) { const cleanName = pieceName.replace('$', ''); const baseUrl = CONTEXT === 'dev' ? 'https://localhost:8888' : 'https://aesthetic.computer'; const response = await devFetch(`${baseUrl}/api/store-kidlisp?code=${cleanName}`); const data = await response.json(); if (data.error || !data.source) { throw new Error(`Piece '$${cleanName}' not found`); } return { source: data.source, userId: data.user || null, authorHandle: normalizeHandle(data.handle), }; } // Extract KidLisp refs ($xxx) function extractKidLispRefs(source) { const refs = []; const regex = /\$[a-z0-9_-]+/gi; for (const match of source.matchAll(regex)) { const ref = match[0].toLowerCase(); if (!refs.includes(ref)) refs.push(ref); } return refs; } // Get KidLisp source with all dependencies async function getKidLispSourceWithDeps(pieceName) { const allSources = {}; const toProcess = [pieceName]; const processed = new Set(); let mainPieceUserId = null; let mainPieceAuthorHandle = null; while (toProcess.length > 0) { const current = toProcess.shift(); const cleanName = current.replace('$', ''); if (processed.has(cleanName)) continue; processed.add(cleanName); const { source, userId, authorHandle } = await fetchKidLispFromAPI(cleanName); allSources[cleanName] = source; if (cleanName === pieceName.replace('$', '')) { if (userId) mainPieceUserId = userId; if (authorHandle) mainPieceAuthorHandle = authorHandle; } const refs = extractKidLispRefs(source); for (const ref of refs) { const refName = ref.replace('$', ''); if (!processed.has(refName)) { toProcess.push(refName); } } } // Resolve author info (AC handle and permanent user code) let authorHandle = mainPieceAuthorHandle || 'anon'; let userCode = null; if (mainPieceUserId) { const authorInfo = await fetchAuthorInfo(mainPieceUserId); if (!mainPieceAuthorHandle && authorInfo.handle) { authorHandle = normalizeHandle(authorInfo.handle) || authorHandle; } if (authorInfo.userCode) userCode = authorInfo.userCode; } return { sources: allSources, authorHandle, userCode }; } // Resolve relative import path function resolvePath(base, relative) { if (!relative.startsWith('.')) return relative; let dir = path.dirname(base); const parts = dir === '.' ? [] : dir.split('/').filter(p => p); const relParts = relative.split('/'); for (const part of relParts) { if (part === '..') parts.pop(); else if (part !== '.' && part !== '') parts.push(part); } return parts.join('/'); } // Rewrite imports for VFS compatibility function rewriteImports(code, filepath) { code = code.replace(/from\s*['"]aesthetic\.computer\/disks\/([^'"]+)['"]/g, (match, p) => 'from \'ac/disks/' + p + '\''); code = code.replace(/import\s*\((['"]aesthetic\.computer\/disks\/([^'"]+)['")])\)/g, (match, fullPath, p) => 'import(\'ac/disks/' + p + '\')'); code = code.replace(/from\s*['"](\.\.\/[^'"]+|\.\/[^'"]+)(\?[^'"]+)?['"]/g, (match, p) => { const resolved = resolvePath(filepath, p); return 'from"' + resolved + '"'; }); code = code.replace(/import\s*\((['"](\.\.\/[^'"]+|\.\/[^'"]+)(\?[^'"]+)?['")])\)/g, (match, fullPath, p) => { const resolved = resolvePath(filepath, p); return 'import("' + resolved + '")'; }); // Handle template literal imports like import(`./lib/disk.mjs`) code = code.replace(/import\s*\(\`(\.\.\/[^\`]+|\.\/[^\`]+)\`\)/g, (match, p) => { const clean = p.split('?')[0]; const resolved = resolvePath(filepath, clean); return 'import("' + resolved + '")'; }); // Also rewrite string literals that look like relative module paths (for wrapper functions like importWithRetry) // This catches patterns like: importWithRetry("./bios.mjs") or anyFunction("./lib/parse.mjs") // But be careful not to rewrite strings that aren't module paths code = code.replace(/\(\s*['"](\.\.?\/[^'"]+\.m?js)(\?[^'"]+)?['"]\s*\)/g, (match, p) => { const resolved = resolvePath(filepath, p); return '("' + resolved + '")'; }); // Rewrite new URL("relative-path", import.meta.url) patterns. // In pack mode, import.meta.url is a blob: URL that can't resolve relative paths. // Resolve the relative path to a VFS-absolute path so the fetch intercept can serve it. code = code.replace(/new\s+URL\(\s*['"](\.\.?\/[^'"]+)['"]\s*,\s*import\.meta\.url\s*\)/g, (match, p) => { const resolved = resolvePath(filepath, p); return 'new URL("/' + resolved + '", location.href)'; }); return code; } // Global flag for skipping minification (set per-request) let skipMinification = false; // Minify JS content async function minifyJS(content, relativePath) { const ext = path.extname(relativePath); if (ext !== ".mjs" && ext !== ".js") return content; let processedContent = rewriteImports(content, relativePath); // Skip minification if nominify flag is set if (skipMinification) { return processedContent; } try { const { minify } = require("terser"); const result = await minify(processedContent, { compress: { dead_code: true, drop_console: true, drop_debugger: true, unused: true, passes: 2, pure_getters: true, // Avoid unsafe optimizations that can cause TDZ errors // ("Cannot access variable before initialization") when // terser reorders declarations across module boundaries. unsafe: false, unsafe_math: true, unsafe_proto: true, }, mangle: true, module: true, format: { comments: false, ascii_only: false, ecma: 2020 } }); return result.code || processedContent; } catch (err) { console.error(`[minifyJS] Failed to minify ${relativePath}:`, err.message); return processedContent; } } // Auto-discover dependencies from imports async function discoverDependencies(acDir, essentialFiles, skipFiles) { const discovered = new Set(essentialFiles); const toProcess = [...essentialFiles]; while (toProcess.length > 0) { const file = toProcess.shift(); const fullPath = path.join(acDir, file); if (!fsSync.existsSync(fullPath)) continue; try { const content = await fs.readFile(fullPath, 'utf8'); const importRegex = /from\s+["'](\.\.[^"']+|\.\/[^"']+)["']/g; const dynamicImportRegex = /import\s*\(\s*["'](\.\.[^"']+|\.\/[^"']+)["']\s*\)/g; let match; while ((match = importRegex.exec(content)) !== null) { const resolved = resolvePath(file, match[1]); if (skipFiles.some(skip => resolved.includes(skip))) continue; if (!discovered.has(resolved)) { discovered.add(resolved); toProcess.push(resolved); } } while ((match = dynamicImportRegex.exec(content)) !== null) { const resolved = resolvePath(file, match[1]); if (skipFiles.some(skip => resolved.includes(skip))) continue; if (!discovered.has(resolved)) { discovered.add(resolved); toProcess.push(resolved); } } } catch { // Ignore errors } } return Array.from(discovered); } // Build or retrieve cached core bundle (minified system files + fonts) async function getCoreBundle(acDir, onProgress = () => {}, forceRefresh = false) { // Check if we have a valid cache for this git commit if (!forceRefresh && coreBundleCache && coreBundleCacheCommit === GIT_COMMIT) { console.log(`[bundle-html] Using cached core bundle for commit ${GIT_COMMIT}`); onProgress({ stage: 'cache-hit', message: 'Using cached core files...' }); return coreBundleCache; } if (forceRefresh) { console.log(`[bundle-html] Force refresh - rebuilding core bundle...`); } else { console.log(`[bundle-html] Building core bundle for commit ${GIT_COMMIT}...`); } const coreFiles = {}; onProgress({ stage: 'discover', message: 'Discovering dependencies...' }); // Discover all dependencies const allFiles = await discoverDependencies(acDir, ESSENTIAL_FILES, SKIP_FILES); onProgress({ stage: 'minify', message: `Minifying ${allFiles.length} files...` }); // Load and minify files in parallel batches for speed let minifiedCount = 0; const BATCH_SIZE = 10; for (let i = 0; i < allFiles.length; i += BATCH_SIZE) { const batch = allFiles.slice(i, i + BATCH_SIZE); const results = await Promise.all(batch.map(async (file) => { const fullPath = path.join(acDir, file); try { if (!fsSync.existsSync(fullPath)) return null; let content = await fs.readFile(fullPath, 'utf8'); content = await minifyJS(content, file); return { file, content }; } catch { return null; } })); for (const r of results) { if (r) { coreFiles[r.file] = { content: r.content, binary: false, type: path.extname(r.file).slice(1) }; minifiedCount++; } } onProgress({ stage: 'minify', message: `Minified ${minifiedCount}/${allFiles.length} files...` }); } // Load nanoid const nanoidPath = 'dep/nanoid/index.js'; const nanoidFullPath = path.join(acDir, nanoidPath); if (fsSync.existsSync(nanoidFullPath)) { let content = await fs.readFile(nanoidFullPath, 'utf8'); content = await minifyJS(content, nanoidPath); coreFiles[nanoidPath] = { content, binary: false, type: 'js' }; } onProgress({ stage: 'fonts', message: 'Loading fonts...' }); // Load font_1 glyphs const font1Dir = path.join(acDir, 'disks/drawings/font_1'); const fontCategories = ['lowercase', 'uppercase', 'numbers', 'symbols']; for (const category of fontCategories) { const categoryDir = path.join(font1Dir, category); try { if (fsSync.existsSync(categoryDir)) { const glyphFiles = fsSync.readdirSync(categoryDir).filter(f => f.endsWith('.json')); for (const glyphFile of glyphFiles) { const glyphPath = path.join(categoryDir, glyphFile); const content = await fs.readFile(glyphPath, 'utf8'); const vfsPath = `disks/drawings/font_1/${category}/${glyphFile}`; coreFiles[vfsPath] = { content, binary: false, type: 'json' }; } } } catch { // Skip } } // Cache the result coreBundleCache = coreFiles; coreBundleCacheCommit = GIT_COMMIT; console.log(`[bundle-html] Cached core bundle: ${Object.keys(coreFiles).length} files`); return coreFiles; } // Create bundle for JavaScript .mjs pieces (notepat, metronome, etc.) async function createJSPieceBundle(pieceName, onProgress = () => {}, nocompress = false, density = null) { onProgress({ stage: 'init', message: `Bundling ${pieceName}...` }); const packTime = Date.now(); const packDate = new Date().toLocaleString("en-US", { timeZone: "America/Los_Angeles", year: "numeric", month: "long", day: "numeric", hour: "numeric", minute: "2-digit", second: "2-digit", hour12: true, }); const bundleTimestamp = timestamp(); // Determine acDir const acDir = path.join(__dirname, "..", "..", "public", "aesthetic.computer"); console.log("[bundle-html] JS piece bundle - acDir:", acDir); // Get core bundle (cached per git commit) const coreFiles = await getCoreBundle(acDir, onProgress); // Build VFS starting with core files const files = { ...coreFiles }; // Check if the piece exists const piecePath = `disks/${pieceName}.mjs`; const pieceFullPath = path.join(acDir, piecePath); if (!fsSync.existsSync(pieceFullPath)) { throw new Error(`Piece '${pieceName}' not found at ${piecePath}`); } onProgress({ stage: 'piece', message: `Loading ${pieceName}.mjs...` }); // Load the piece file - DO NOT minify the actual piece source code! // Only platform/system code gets minified. Piece code stays readable. const pieceContent = await fs.readFile(pieceFullPath, 'utf8'); // Only rewrite imports, don't minify const rewrittenPiece = rewriteImports(pieceContent, piecePath); files[piecePath] = { content: rewrittenPiece, binary: false, type: 'mjs' }; // Discover piece-specific dependencies const pieceDepFiles = await discoverDependencies(acDir, [piecePath], SKIP_FILES); onProgress({ stage: 'deps', message: `Found ${pieceDepFiles.length} dependencies...` }); // Track which files are piece dependencies (in disks/ folder) vs platform code for (const depFile of pieceDepFiles) { if (files[depFile]) continue; // Already in core bundle const depFullPath = path.join(acDir, depFile); try { if (!fsSync.existsSync(depFullPath)) continue; let content = await fs.readFile(depFullPath, 'utf8'); // Don't minify files in disks/ folder (piece code), only platform code const isPieceCode = depFile.startsWith('disks/'); if (isPieceCode) { content = rewriteImports(content, depFile); } else { content = await minifyJS(content, depFile); } files[depFile] = { content, binary: false, type: path.extname(depFile).slice(1) }; } catch { // Skip files that can't be loaded } } onProgress({ stage: 'generate', message: 'Generating HTML bundle...' }); // Generate HTML bundle for JS piece const htmlContent = generateJSPieceHTMLBundle({ pieceName, files, packDate, packTime, gitVersion: GIT_COMMIT, }); const filename = `${pieceName}-${bundleTimestamp}.html`; onProgress({ stage: 'compress', message: nocompress ? 'Skipping compression (nocompress mode)...' : 'Compressing...' }); // If nocompress is true, return the raw HTML without gzip wrapper // This is needed for devices without DecompressionStream support (e.g., FF1) if (nocompress) { return { html: htmlContent, filename, sizeKB: Math.round(htmlContent.length / 1024) }; } // Create gzip-compressed self-extracting bundle (brotli not supported in DecompressionStream) const compressed = gzipSync(Buffer.from(htmlContent, 'utf-8'), { level: 9 }); const base64 = compressed.toString('base64'); const finalHtml = `