Monorepo for Aesthetic.Computer aesthetic.computer
at main 1676 lines 59 kB view raw
1// bundle-html.js - Netlify Function [DEPRECATED — bundling now happens on oven] 2// Generates self-contained HTML bundles on-demand via API 3// Supports both KidLisp pieces ($code) and JavaScript pieces (piece=name) 4// 5// Usage: 6// GET /api/bundle-html?code=39j - Bundle a KidLisp piece 7// GET /api/bundle-html?piece=notepat - Bundle a JavaScript .mjs piece 8// 9// Returns: Self-extracting gzip-compressed HTML file 10// 11// Optimization: Core system files are cached per git commit to speed up 12// subsequent bundle requests. Only piece-specific data is fetched per request. 13 14const { promises: fs } = require("fs"); 15const fsSync = require("fs"); 16const path = require("path"); 17const { gzipSync } = require("zlib"); 18const https = require("https"); 19const { execSync } = require("child_process"); 20 21// Netlify streaming support 22const { stream } = require("@netlify/functions"); 23 24// Get git commit from build-time env var, or dynamically from git in dev 25function getGitCommit() { 26 if (process.env.GIT_COMMIT) return process.env.GIT_COMMIT; 27 try { 28 const hash = execSync("git rev-parse --short HEAD", { encoding: "utf8" }).trim(); 29 const isDirty = execSync("git status --porcelain", { encoding: "utf8" }).trim().length > 0; 30 return isDirty ? `${hash} (dirty)` : hash; 31 } catch { 32 return "unknown"; 33 } 34} 35const GIT_COMMIT = getGitCommit(); 36const CONTEXT = process.env.CONTEXT || "production"; 37 38// In-memory cache for core bundle (persists across warm function invocations) 39// Key: git commit, Value: { coreFiles, fontFiles, timestamp } 40let coreBundleCache = null; 41let coreBundleCacheCommit = null; 42 43// Custom fetch that ignores self-signed certs in dev mode 44const devAgent = new https.Agent({ rejectUnauthorized: false }); 45async function devFetch(url, options = {}) { 46 if (CONTEXT === 'dev' && url.startsWith('https://localhost')) { 47 const { default: nodeFetch } = await import('node-fetch'); 48 return nodeFetch(url, { ...options, agent: devAgent }); 49 } 50 return fetch(url, options); 51} 52 53// Essential files for KidLisp bundles (same as CLI) 54const ESSENTIAL_FILES = [ 55 // Core system 56 'boot.mjs', 57 'bios.mjs', 58 59 // Core loop and disk 60 'lib/loop.mjs', 61 'lib/disk.mjs', 62 'lib/parse.mjs', 63 64 // KidLisp interpreter 65 'lib/kidlisp.mjs', 66 67 // Graphics essentials 68 'lib/graph.mjs', 69 'lib/geo.mjs', 70 'lib/2d.mjs', 71 'lib/pen.mjs', 72 'lib/num.mjs', 73 'lib/gl.mjs', 74 'lib/webgl-blit.mjs', 75 76 // System essentials 77 'lib/helpers.mjs', 78 'lib/logs.mjs', 79 'lib/store.mjs', 80 'lib/platform.mjs', 81 'lib/pack-mode.mjs', 82 83 // BIOS dependencies 84 'lib/keyboard.mjs', 85 'lib/gamepad.mjs', 86 'lib/motion.mjs', 87 'lib/speech.mjs', 88 'lib/help.mjs', 89 'lib/midi.mjs', 90 'lib/usb.mjs', 91 'lib/headers.mjs', 92 'lib/glaze.mjs', 93 'lib/ui.mjs', 94 95 // Disk dependencies 96 'disks/common/tape-player.mjs', 97 98 // Sound dependencies 99 'lib/sound/sound-whitelist.mjs', 100 101 // Audio worklet bundled for PACK mode 102 'lib/speaker-bundled.mjs', 103 104 // gl-matrix dependencies 105 'dep/gl-matrix/common.mjs', 106 'dep/gl-matrix/vec2.mjs', 107 'dep/gl-matrix/vec3.mjs', 108 'dep/gl-matrix/vec4.mjs', 109 'dep/gl-matrix/mat3.mjs', 110 'dep/gl-matrix/mat4.mjs', 111 'dep/gl-matrix/quat.mjs', 112 113 // Glaze dependencies 114 'lib/glazes/uniforms.js', 115]; 116 117const SKIP_FILES = []; 118 119// Generate timestamp: YYYY.M.D.H.M.S.mmm 120function timestamp(date = new Date()) { 121 const pad = (n, digits = 2) => n.toString().padStart(digits, "0"); 122 return `${date.getFullYear()}.${date.getMonth() + 1}.${date.getDate()}.${date.getHours()}.${date.getMinutes()}.${date.getSeconds()}.${pad(date.getMilliseconds(), 3)}`; 123} 124 125// Extract painting short codes from KidLisp source 126function extractPaintingCodes(source) { 127 const codes = []; 128 const regex = /#([a-zA-Z0-9]{3})\b/g; 129 let match; 130 while ((match = regex.exec(source)) !== null) { 131 if (!codes.includes(match[1])) codes.push(match[1]); 132 } 133 return codes; 134} 135 136// Resolve painting code to handle+slug via API 137async function resolvePaintingCode(code) { 138 try { 139 const baseUrl = CONTEXT === 'dev' ? 'https://localhost:8888' : 'https://aesthetic.computer'; 140 const response = await devFetch(`${baseUrl}/api/painting-code?code=${code}`); 141 if (!response.ok) return null; 142 const data = await response.json(); 143 return { code, handle: data.handle || 'anon', slug: data.slug }; 144 } catch { 145 return null; 146 } 147} 148 149// Fetch painting PNG as base64 150async function fetchPaintingImage(handle, slug) { 151 const handlePath = handle === 'anon' ? '' : `@${handle}/`; 152 const url = `https://aesthetic.computer/media/${handlePath}painting/${slug}.png`; 153 try { 154 const response = await devFetch(url); 155 if (!response.ok) return null; 156 const buffer = await response.arrayBuffer(); 157 return Buffer.from(buffer).toString('base64'); 158 } catch { 159 return null; 160 } 161} 162 163// Fetch author info from user ID (AC handle and permanent user code) 164async function fetchAuthorInfo(userId) { 165 if (!userId) return { handle: null, userCode: null }; 166 167 let acHandle = null; 168 let userCode = null; 169 170 // Get AC handle via handle endpoint 171 try { 172 const baseUrl = CONTEXT === 'dev' ? 'https://localhost:8888' : 'https://aesthetic.computer'; 173 const response = await devFetch(`${baseUrl}/handle?for=${encodeURIComponent(userId)}`); 174 if (response.ok) { 175 const data = await response.json(); 176 if (data.handle) acHandle = data.handle; // Don't add @ prefix for filenames 177 } 178 } catch { /* ignore */ } 179 180 // Get permanent user code from users collection 181 try { 182 const { connect } = await import('../../backend/database.mjs'); 183 const database = await connect(); 184 const users = database.db.collection('users'); 185 const user = await users.findOne({ _id: userId }, { projection: { code: 1 } }); 186 if (user?.code) { 187 userCode = user.code; 188 } 189 await database.disconnect(); 190 } catch { /* ignore */ } 191 192 return { handle: acHandle, userCode }; 193} 194 195function normalizeHandle(handle) { 196 if (typeof handle !== 'string') return null; 197 const cleaned = handle.trim().replace(/^@+/, ''); 198 return cleaned || null; 199} 200 201// Fetch KidLisp source from API 202async function fetchKidLispFromAPI(pieceName) { 203 const cleanName = pieceName.replace('$', ''); 204 const baseUrl = CONTEXT === 'dev' ? 'https://localhost:8888' : 'https://aesthetic.computer'; 205 const response = await devFetch(`${baseUrl}/api/store-kidlisp?code=${cleanName}`); 206 const data = await response.json(); 207 208 if (data.error || !data.source) { 209 throw new Error(`Piece '$${cleanName}' not found`); 210 } 211 212 return { 213 source: data.source, 214 userId: data.user || null, 215 authorHandle: normalizeHandle(data.handle), 216 }; 217} 218 219// Extract KidLisp refs ($xxx) 220function extractKidLispRefs(source) { 221 const refs = []; 222 const regex = /\$[a-z0-9_-]+/gi; 223 for (const match of source.matchAll(regex)) { 224 const ref = match[0].toLowerCase(); 225 if (!refs.includes(ref)) refs.push(ref); 226 } 227 return refs; 228} 229 230// Get KidLisp source with all dependencies 231async function getKidLispSourceWithDeps(pieceName) { 232 const allSources = {}; 233 const toProcess = [pieceName]; 234 const processed = new Set(); 235 let mainPieceUserId = null; 236 let mainPieceAuthorHandle = null; 237 238 while (toProcess.length > 0) { 239 const current = toProcess.shift(); 240 const cleanName = current.replace('$', ''); 241 242 if (processed.has(cleanName)) continue; 243 processed.add(cleanName); 244 245 const { source, userId, authorHandle } = await fetchKidLispFromAPI(cleanName); 246 allSources[cleanName] = source; 247 248 if (cleanName === pieceName.replace('$', '')) { 249 if (userId) mainPieceUserId = userId; 250 if (authorHandle) mainPieceAuthorHandle = authorHandle; 251 } 252 253 const refs = extractKidLispRefs(source); 254 for (const ref of refs) { 255 const refName = ref.replace('$', ''); 256 if (!processed.has(refName)) { 257 toProcess.push(refName); 258 } 259 } 260 } 261 262 // Resolve author info (AC handle and permanent user code) 263 let authorHandle = mainPieceAuthorHandle || 'anon'; 264 let userCode = null; 265 if (mainPieceUserId) { 266 const authorInfo = await fetchAuthorInfo(mainPieceUserId); 267 if (!mainPieceAuthorHandle && authorInfo.handle) { 268 authorHandle = normalizeHandle(authorInfo.handle) || authorHandle; 269 } 270 if (authorInfo.userCode) userCode = authorInfo.userCode; 271 } 272 273 return { sources: allSources, authorHandle, userCode }; 274} 275 276// Resolve relative import path 277function resolvePath(base, relative) { 278 if (!relative.startsWith('.')) return relative; 279 280 let dir = path.dirname(base); 281 const parts = dir === '.' ? [] : dir.split('/').filter(p => p); 282 const relParts = relative.split('/'); 283 284 for (const part of relParts) { 285 if (part === '..') parts.pop(); 286 else if (part !== '.' && part !== '') parts.push(part); 287 } 288 289 return parts.join('/'); 290} 291 292// Rewrite imports for VFS compatibility 293function rewriteImports(code, filepath) { 294 code = code.replace(/from\s*['"]aesthetic\.computer\/disks\/([^'"]+)['"]/g, 295 (match, p) => 'from \'ac/disks/' + p + '\''); 296 297 code = code.replace(/import\s*\((['"]aesthetic\.computer\/disks\/([^'"]+)['")])\)/g, 298 (match, fullPath, p) => 'import(\'ac/disks/' + p + '\')'); 299 300 code = code.replace(/from\s*['"](\.\.\/[^'"]+|\.\/[^'"]+)(\?[^'"]+)?['"]/g, (match, p) => { 301 const resolved = resolvePath(filepath, p); 302 return 'from"' + resolved + '"'; 303 }); 304 305 code = code.replace(/import\s*\((['"](\.\.\/[^'"]+|\.\/[^'"]+)(\?[^'"]+)?['")])\)/g, (match, fullPath, p) => { 306 const resolved = resolvePath(filepath, p); 307 return 'import("' + resolved + '")'; 308 }); 309 310 // Handle template literal imports like import(`./lib/disk.mjs`) 311 code = code.replace(/import\s*\(\`(\.\.\/[^\`]+|\.\/[^\`]+)\`\)/g, (match, p) => { 312 const clean = p.split('?')[0]; 313 const resolved = resolvePath(filepath, clean); 314 return 'import("' + resolved + '")'; 315 }); 316 317 // Also rewrite string literals that look like relative module paths (for wrapper functions like importWithRetry) 318 // This catches patterns like: importWithRetry("./bios.mjs") or anyFunction("./lib/parse.mjs") 319 // But be careful not to rewrite strings that aren't module paths 320 code = code.replace(/\(\s*['"](\.\.?\/[^'"]+\.m?js)(\?[^'"]+)?['"]\s*\)/g, (match, p) => { 321 const resolved = resolvePath(filepath, p); 322 return '("' + resolved + '")'; 323 }); 324 325 // Rewrite new URL("relative-path", import.meta.url) patterns. 326 // In pack mode, import.meta.url is a blob: URL that can't resolve relative paths. 327 // Resolve the relative path to a VFS-absolute path so the fetch intercept can serve it. 328 code = code.replace(/new\s+URL\(\s*['"](\.\.?\/[^'"]+)['"]\s*,\s*import\.meta\.url\s*\)/g, (match, p) => { 329 const resolved = resolvePath(filepath, p); 330 return 'new URL("/' + resolved + '", location.href)'; 331 }); 332 333 return code; 334} 335 336// Global flag for skipping minification (set per-request) 337let skipMinification = false; 338 339// Minify JS content 340async function minifyJS(content, relativePath) { 341 const ext = path.extname(relativePath); 342 if (ext !== ".mjs" && ext !== ".js") return content; 343 344 let processedContent = rewriteImports(content, relativePath); 345 346 // Skip minification if nominify flag is set 347 if (skipMinification) { 348 return processedContent; 349 } 350 351 try { 352 const { minify } = require("terser"); 353 354 const result = await minify(processedContent, { 355 compress: { 356 dead_code: true, 357 drop_console: true, 358 drop_debugger: true, 359 unused: true, 360 passes: 2, 361 pure_getters: true, 362 // Avoid unsafe optimizations that can cause TDZ errors 363 // ("Cannot access variable before initialization") when 364 // terser reorders declarations across module boundaries. 365 unsafe: false, 366 unsafe_math: true, 367 unsafe_proto: true, 368 }, 369 mangle: true, 370 module: true, 371 format: { comments: false, ascii_only: false, ecma: 2020 } 372 }); 373 374 return result.code || processedContent; 375 } catch (err) { 376 console.error(`[minifyJS] Failed to minify ${relativePath}:`, err.message); 377 return processedContent; 378 } 379} 380 381// Auto-discover dependencies from imports 382async function discoverDependencies(acDir, essentialFiles, skipFiles) { 383 const discovered = new Set(essentialFiles); 384 const toProcess = [...essentialFiles]; 385 386 while (toProcess.length > 0) { 387 const file = toProcess.shift(); 388 const fullPath = path.join(acDir, file); 389 390 if (!fsSync.existsSync(fullPath)) continue; 391 392 try { 393 const content = await fs.readFile(fullPath, 'utf8'); 394 395 const importRegex = /from\s+["'](\.\.[^"']+|\.\/[^"']+)["']/g; 396 const dynamicImportRegex = /import\s*\(\s*["'](\.\.[^"']+|\.\/[^"']+)["']\s*\)/g; 397 398 let match; 399 while ((match = importRegex.exec(content)) !== null) { 400 const resolved = resolvePath(file, match[1]); 401 if (skipFiles.some(skip => resolved.includes(skip))) continue; 402 if (!discovered.has(resolved)) { 403 discovered.add(resolved); 404 toProcess.push(resolved); 405 } 406 } 407 408 while ((match = dynamicImportRegex.exec(content)) !== null) { 409 const resolved = resolvePath(file, match[1]); 410 if (skipFiles.some(skip => resolved.includes(skip))) continue; 411 if (!discovered.has(resolved)) { 412 discovered.add(resolved); 413 toProcess.push(resolved); 414 } 415 } 416 } catch { 417 // Ignore errors 418 } 419 } 420 421 return Array.from(discovered); 422} 423 424// Build or retrieve cached core bundle (minified system files + fonts) 425async function getCoreBundle(acDir, onProgress = () => {}, forceRefresh = false) { 426 // Check if we have a valid cache for this git commit 427 if (!forceRefresh && coreBundleCache && coreBundleCacheCommit === GIT_COMMIT) { 428 console.log(`[bundle-html] Using cached core bundle for commit ${GIT_COMMIT}`); 429 onProgress({ stage: 'cache-hit', message: 'Using cached core files...' }); 430 return coreBundleCache; 431 } 432 433 if (forceRefresh) { 434 console.log(`[bundle-html] Force refresh - rebuilding core bundle...`); 435 } else { 436 console.log(`[bundle-html] Building core bundle for commit ${GIT_COMMIT}...`); 437 } 438 const coreFiles = {}; 439 440 onProgress({ stage: 'discover', message: 'Discovering dependencies...' }); 441 442 // Discover all dependencies 443 const allFiles = await discoverDependencies(acDir, ESSENTIAL_FILES, SKIP_FILES); 444 445 onProgress({ stage: 'minify', message: `Minifying ${allFiles.length} files...` }); 446 447 // Load and minify files in parallel batches for speed 448 let minifiedCount = 0; 449 const BATCH_SIZE = 10; 450 for (let i = 0; i < allFiles.length; i += BATCH_SIZE) { 451 const batch = allFiles.slice(i, i + BATCH_SIZE); 452 const results = await Promise.all(batch.map(async (file) => { 453 const fullPath = path.join(acDir, file); 454 try { 455 if (!fsSync.existsSync(fullPath)) return null; 456 let content = await fs.readFile(fullPath, 'utf8'); 457 content = await minifyJS(content, file); 458 return { file, content }; 459 } catch { 460 return null; 461 } 462 })); 463 for (const r of results) { 464 if (r) { 465 coreFiles[r.file] = { content: r.content, binary: false, type: path.extname(r.file).slice(1) }; 466 minifiedCount++; 467 } 468 } 469 onProgress({ stage: 'minify', message: `Minified ${minifiedCount}/${allFiles.length} files...` }); 470 } 471 472 // Load nanoid 473 const nanoidPath = 'dep/nanoid/index.js'; 474 const nanoidFullPath = path.join(acDir, nanoidPath); 475 if (fsSync.existsSync(nanoidFullPath)) { 476 let content = await fs.readFile(nanoidFullPath, 'utf8'); 477 content = await minifyJS(content, nanoidPath); 478 coreFiles[nanoidPath] = { content, binary: false, type: 'js' }; 479 } 480 481 onProgress({ stage: 'fonts', message: 'Loading fonts...' }); 482 483 // Load font_1 glyphs 484 const font1Dir = path.join(acDir, 'disks/drawings/font_1'); 485 const fontCategories = ['lowercase', 'uppercase', 'numbers', 'symbols']; 486 487 for (const category of fontCategories) { 488 const categoryDir = path.join(font1Dir, category); 489 try { 490 if (fsSync.existsSync(categoryDir)) { 491 const glyphFiles = fsSync.readdirSync(categoryDir).filter(f => f.endsWith('.json')); 492 for (const glyphFile of glyphFiles) { 493 const glyphPath = path.join(categoryDir, glyphFile); 494 const content = await fs.readFile(glyphPath, 'utf8'); 495 const vfsPath = `disks/drawings/font_1/${category}/${glyphFile}`; 496 coreFiles[vfsPath] = { content, binary: false, type: 'json' }; 497 } 498 } 499 } catch { 500 // Skip 501 } 502 } 503 504 // Cache the result 505 coreBundleCache = coreFiles; 506 coreBundleCacheCommit = GIT_COMMIT; 507 console.log(`[bundle-html] Cached core bundle: ${Object.keys(coreFiles).length} files`); 508 509 return coreFiles; 510} 511 512// Create bundle for JavaScript .mjs pieces (notepat, metronome, etc.) 513async function createJSPieceBundle(pieceName, onProgress = () => {}, nocompress = false, density = null) { 514 onProgress({ stage: 'init', message: `Bundling ${pieceName}...` }); 515 516 const packTime = Date.now(); 517 const packDate = new Date().toLocaleString("en-US", { 518 timeZone: "America/Los_Angeles", 519 year: "numeric", 520 month: "long", 521 day: "numeric", 522 hour: "numeric", 523 minute: "2-digit", 524 second: "2-digit", 525 hour12: true, 526 }); 527 528 const bundleTimestamp = timestamp(); 529 530 // Determine acDir 531 const acDir = path.join(__dirname, "..", "..", "public", "aesthetic.computer"); 532 533 console.log("[bundle-html] JS piece bundle - acDir:", acDir); 534 535 // Get core bundle (cached per git commit) 536 const coreFiles = await getCoreBundle(acDir, onProgress); 537 538 // Build VFS starting with core files 539 const files = { ...coreFiles }; 540 541 // Check if the piece exists 542 const piecePath = `disks/${pieceName}.mjs`; 543 const pieceFullPath = path.join(acDir, piecePath); 544 545 if (!fsSync.existsSync(pieceFullPath)) { 546 throw new Error(`Piece '${pieceName}' not found at ${piecePath}`); 547 } 548 549 onProgress({ stage: 'piece', message: `Loading ${pieceName}.mjs...` }); 550 551 // Load the piece file - DO NOT minify the actual piece source code! 552 // Only platform/system code gets minified. Piece code stays readable. 553 const pieceContent = await fs.readFile(pieceFullPath, 'utf8'); 554 // Only rewrite imports, don't minify 555 const rewrittenPiece = rewriteImports(pieceContent, piecePath); 556 files[piecePath] = { content: rewrittenPiece, binary: false, type: 'mjs' }; 557 558 // Discover piece-specific dependencies 559 const pieceDepFiles = await discoverDependencies(acDir, [piecePath], SKIP_FILES); 560 561 onProgress({ stage: 'deps', message: `Found ${pieceDepFiles.length} dependencies...` }); 562 563 // Track which files are piece dependencies (in disks/ folder) vs platform code 564 for (const depFile of pieceDepFiles) { 565 if (files[depFile]) continue; // Already in core bundle 566 567 const depFullPath = path.join(acDir, depFile); 568 try { 569 if (!fsSync.existsSync(depFullPath)) continue; 570 let content = await fs.readFile(depFullPath, 'utf8'); 571 572 // Don't minify files in disks/ folder (piece code), only platform code 573 const isPieceCode = depFile.startsWith('disks/'); 574 if (isPieceCode) { 575 content = rewriteImports(content, depFile); 576 } else { 577 content = await minifyJS(content, depFile); 578 } 579 580 files[depFile] = { content, binary: false, type: path.extname(depFile).slice(1) }; 581 } catch { 582 // Skip files that can't be loaded 583 } 584 } 585 586 onProgress({ stage: 'generate', message: 'Generating HTML bundle...' }); 587 588 // Generate HTML bundle for JS piece 589 const htmlContent = generateJSPieceHTMLBundle({ 590 pieceName, 591 files, 592 packDate, 593 packTime, 594 gitVersion: GIT_COMMIT, 595 }); 596 597 const filename = `${pieceName}-${bundleTimestamp}.html`; 598 599 onProgress({ stage: 'compress', message: nocompress ? 'Skipping compression (nocompress mode)...' : 'Compressing...' }); 600 601 // If nocompress is true, return the raw HTML without gzip wrapper 602 // This is needed for devices without DecompressionStream support (e.g., FF1) 603 if (nocompress) { 604 return { html: htmlContent, filename, sizeKB: Math.round(htmlContent.length / 1024) }; 605 } 606 607 // Create gzip-compressed self-extracting bundle (brotli not supported in DecompressionStream) 608 const compressed = gzipSync(Buffer.from(htmlContent, 'utf-8'), { level: 9 }); 609 const base64 = compressed.toString('base64'); 610 611 const finalHtml = `<!DOCTYPE html> 612<html lang="en"> 613<head> 614 <meta charset="utf-8"> 615 <title>${pieceName} · Aesthetic Computer</title> 616 <style>body{margin:0;background:#000;overflow:hidden}</style> 617</head> 618<body> 619 <script> 620 // Use blob: URL instead of data: URL for CSP compatibility (objkt sandboxing) 621 const b64='${base64}'; 622 const bin=atob(b64); 623 const bytes=new Uint8Array(bin.length); 624 for(let i=0;i<bin.length;i++)bytes[i]=bin.charCodeAt(i); 625 const blob=new Blob([bytes],{type:'application/gzip'}); 626 const url=URL.createObjectURL(blob); 627 fetch(url) 628 .then(r=>r.blob()) 629 .then(b=>b.stream().pipeThrough(new DecompressionStream('gzip'))) 630 .then(s=>new Response(s).text()) 631 .then(h=>{URL.revokeObjectURL(url);document.open();document.write(h);document.close();}) 632 .catch(e=>{document.body.style.color='#fff';document.body.textContent='Bundle error: '+e.message;}); 633 </script> 634</body> 635</html>`; 636 637 return { html: finalHtml, filename, sizeKB: Math.round(finalHtml.length / 1024) }; 638} 639 640// Generate HTML bundle for JavaScript pieces 641function generateJSPieceHTMLBundle(opts) { 642 const { 643 pieceName, 644 files, 645 packDate, 646 packTime, 647 gitVersion, 648 } = opts; 649 650 return `<!DOCTYPE html> 651<html lang="en"> 652<head> 653 <meta charset="utf-8"> 654 <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> 655 <title>${pieceName} · Aesthetic Computer</title> 656 <style> 657 body { margin: 0; padding: 0; background: black; overflow: hidden; } 658 canvas { display: block; image-rendering: pixelated; } 659 </style> 660</head> 661<body> 662 <script> 663 // Phase 1: Setup VFS, blob URLs, import map, and fetch interception. 664 // This MUST run in a regular <script> (not type="module") so the import map 665 // is in the DOM BEFORE any <script type="module"> executes. 666 window.acPACK_MODE = true; 667 window.KIDLISP_SUPPRESS_SNAPSHOT_LOGS = true; 668 window.__acKidlispConsoleEnabled = false; 669 window.acSTARTING_PIECE = "${pieceName}"; 670 window.acPACK_PIECE = "${pieceName}"; 671 window.acPACK_DATE = "${packDate}"; 672 window.acPACK_GIT = "${gitVersion}"; 673 window.acPACK_COLOPHON = { 674 piece: { name: '${pieceName}', isKidLisp: false }, 675 build: { author: '@jeffrey', packTime: ${packTime}, gitCommit: '${gitVersion}', gitIsDirty: false, fileCount: ${Object.keys(files).length} } 676 }; 677 window.VFS = ${JSON.stringify(files).replace(/<\/script>/g, '<\\/script>')}; 678 var originalAppendChild = Element.prototype.appendChild; 679 Element.prototype.appendChild = function(child) { 680 if (child.tagName === 'LINK' && child.rel === 'stylesheet' && child.href && child.href.includes('.css')) return child; 681 return originalAppendChild.call(this, child); 682 }; 683 var originalBodyAppend = HTMLBodyElement.prototype.append; 684 HTMLBodyElement.prototype.append = function() { 685 var args = []; for (var i = 0; i < arguments.length; i++) { var n = arguments[i]; if (!(n.tagName === 'LINK' && n.rel === 'stylesheet')) args.push(n); } 686 return originalBodyAppend.apply(this, args); 687 }; 688 window.VFS_BLOB_URLS = {}; 689 window.modulePaths = []; 690 Object.entries(window.VFS).forEach(function(entry) { 691 var path = entry[0], file = entry[1]; 692 if (path.endsWith('.mjs') || path.endsWith('.js')) { 693 var blob = new Blob([file.content], { type: 'application/javascript' }); 694 window.VFS_BLOB_URLS[path] = URL.createObjectURL(blob); 695 window.modulePaths.push(path); 696 } 697 }); 698 var importMapEntries = {}; 699 for (var i = 0; i < window.modulePaths.length; i++) { 700 var fp = window.modulePaths[i]; 701 if (window.VFS_BLOB_URLS[fp]) { 702 importMapEntries[fp] = window.VFS_BLOB_URLS[fp]; 703 importMapEntries['/' + fp] = window.VFS_BLOB_URLS[fp]; 704 importMapEntries['aesthetic.computer/' + fp] = window.VFS_BLOB_URLS[fp]; 705 importMapEntries['/aesthetic.computer/' + fp] = window.VFS_BLOB_URLS[fp]; 706 importMapEntries['./aesthetic.computer/' + fp] = window.VFS_BLOB_URLS[fp]; 707 importMapEntries['https://aesthetic.computer/' + fp] = window.VFS_BLOB_URLS[fp]; 708 importMapEntries['./' + fp] = window.VFS_BLOB_URLS[fp]; 709 importMapEntries['../' + fp] = window.VFS_BLOB_URLS[fp]; 710 importMapEntries['../../' + fp] = window.VFS_BLOB_URLS[fp]; 711 } 712 } 713 var s = document.createElement('script'); 714 s.type = 'importmap'; 715 s.textContent = JSON.stringify({ imports: importMapEntries }); 716 document.head.appendChild(s); 717 var originalFetch = window.fetch; 718 window.fetch = function(url, options) { 719 var urlStr = typeof url === 'string' ? url : url.toString(); 720 var vfsPath = decodeURIComponent(urlStr) 721 .replace(/^https?:\\/\\/[^\\/]+\\//g, '') 722 .replace(/^aesthetic\\.computer\\//g, '') 723 .replace(/#.*$/g, '') 724 .replace(/\\?.*$/g, ''); 725 vfsPath = vfsPath.replace(/^\\.\\.\\/+/g, '').replace(/^\\.\\//g, '').replace(/^\\//g, '').replace(/^aesthetic\\.computer\\//g, ''); 726 if (window.VFS[vfsPath]) { 727 var file = window.VFS[vfsPath]; var content; var ct = 'text/plain'; 728 if (file.binary) { 729 var bs = atob(file.content); var bytes = new Uint8Array(bs.length); 730 for (var j = 0; j < bs.length; j++) bytes[j] = bs.charCodeAt(j); 731 content = bytes; 732 if (file.type === 'png') ct = 'image/png'; else if (file.type === 'jpg' || file.type === 'jpeg') ct = 'image/jpeg'; 733 } else { 734 content = file.content; 735 if (file.type === 'mjs' || file.type === 'js') ct = 'application/javascript'; else if (file.type === 'json') ct = 'application/json'; 736 } 737 return Promise.resolve(new Response(content, { status: 200, headers: { 'Content-Type': ct } })); 738 } 739 if (vfsPath.includes('disks/drawings/font_') || vfsPath.includes('cursors/') || vfsPath.endsWith('.svg') || vfsPath.endsWith('.css') || urlStr.includes('/type/webfonts/')) { 740 return Promise.resolve(new Response('', { status: 200, headers: { 'Content-Type': 'text/css' } })); 741 } 742 if (vfsPath.endsWith('.mjs')) console.warn('[VFS] Missing .mjs file:', vfsPath); 743 return originalFetch.call(this, url, options); 744 }; 745 </script> 746 <script type="module"> 747 // Phase 2: Boot the app. Import map is already in the DOM from Phase 1. 748 import(window.VFS_BLOB_URLS['boot.mjs']).catch(err => { 749 document.body.style.cssText='color:#fff;background:#000;padding:20px;font-family:monospace'; 750 document.body.textContent='Boot failed: '+err.message; 751 }); 752 </script> 753</body> 754</html>`; 755} 756 757// M4L .amxd binary header for Instrument devices 758// Format: "ampf" + 4-byte type marker + "meta" + 4 zero bytes + "ptch" 759const M4L_HEADER_INSTRUMENT = Buffer.from( 760 'ampf\x04\x00\x00\x00iiiimeta\x04\x00\x00\x00\x00\x00\x00\x00ptch', 'binary' 761); 762 763// Generate a Max for Live instrument patcher with an embedded offline HTML bundle 764function generateM4DPatcher(pieceName, dataUri, width = 400, height = 200) { 765 const density = 1.5; 766 return { 767 patcher: { 768 fileversion: 1, 769 appversion: { major: 9, minor: 0, revision: 7, architecture: "x64", modernui: 1 }, 770 classnamespace: "box", 771 rect: [134.0, 174.0, 800.0, 600.0], 772 openrect: [0.0, 0.0, width, height], 773 openinpresentation: 1, 774 gridsize: [15.0, 15.0], 775 enablehscroll: 0, 776 enablevscroll: 0, 777 devicewidth: width, 778 description: `Aesthetic Computer ${pieceName} (offline)`, 779 boxes: [ 780 { 781 box: { 782 disablefind: 0, 783 id: "obj-jweb", 784 latency: 0, 785 maxclass: "jweb~", 786 numinlets: 1, 787 numoutlets: 3, 788 outlettype: ["signal", "signal", ""], 789 patching_rect: [10.0, 50.0, width, height], 790 presentation: 1, 791 presentation_rect: [0.0, 0.0, width + 1, height + 1], 792 rendermode: 1, 793 url: dataUri 794 } 795 }, 796 { 797 box: { 798 id: "obj-plugout", 799 maxclass: "newobj", 800 numinlets: 2, 801 numoutlets: 0, 802 patching_rect: [10.0, 280.0, 75.0, 22.0], 803 text: "plugout~ 1 2" 804 } 805 }, 806 { 807 box: { 808 id: "obj-thisdevice", 809 maxclass: "newobj", 810 numinlets: 1, 811 numoutlets: 3, 812 outlettype: ["bang", "int", "int"], 813 patching_rect: [350.0, 50.0, 85.0, 22.0], 814 text: "live.thisdevice" 815 } 816 }, 817 { 818 box: { 819 id: "obj-print", 820 maxclass: "newobj", 821 numinlets: 1, 822 numoutlets: 0, 823 patching_rect: [350.0, 80.0, 150.0, 22.0], 824 text: `print [AC-${pieceName.toUpperCase()}]` 825 } 826 }, 827 { 828 box: { 829 id: "obj-route", 830 maxclass: "newobj", 831 numinlets: 1, 832 numoutlets: 2, 833 outlettype: ["", ""], 834 patching_rect: [350.0, 140.0, 60.0, 22.0], 835 text: "route ready" 836 } 837 }, 838 { 839 box: { 840 id: "obj-activate", 841 maxclass: "message", 842 numinlets: 2, 843 numoutlets: 1, 844 outlettype: [""], 845 patching_rect: [350.0, 170.0, 60.0, 22.0], 846 text: "activate 1" 847 } 848 }, 849 { 850 box: { 851 id: "obj-jweb-print", 852 maxclass: "newobj", 853 numinlets: 1, 854 numoutlets: 0, 855 patching_rect: [350.0, 110.0, 90.0, 22.0], 856 text: "print [AC-JWEB]" 857 } 858 }, 859 { 860 box: { 861 id: "obj-route-logs", 862 maxclass: "newobj", 863 numinlets: 1, 864 numoutlets: 4, 865 outlettype: ["", "", "", ""], 866 patching_rect: [470.0, 140.0, 120.0, 22.0], 867 text: "route log error warn" 868 } 869 }, 870 { 871 box: { 872 id: "obj-udpsend", 873 maxclass: "newobj", 874 numinlets: 1, 875 numoutlets: 0, 876 patching_rect: [470.0, 210.0, 160.0, 22.0], 877 text: "udpsend 127.0.0.1 7777" 878 } 879 }, 880 { 881 box: { 882 id: "obj-prepend-log", 883 maxclass: "newobj", 884 numinlets: 1, 885 numoutlets: 1, 886 outlettype: [""], 887 patching_rect: [470.0, 170.0, 55.0, 22.0], 888 text: "prepend log" 889 } 890 }, 891 { 892 box: { 893 id: "obj-prepend-error", 894 maxclass: "newobj", 895 numinlets: 1, 896 numoutlets: 1, 897 outlettype: [""], 898 patching_rect: [530.0, 170.0, 65.0, 22.0], 899 text: "prepend error" 900 } 901 }, 902 { 903 box: { 904 id: "obj-prepend-warn", 905 maxclass: "newobj", 906 numinlets: 1, 907 numoutlets: 1, 908 outlettype: [""], 909 patching_rect: [600.0, 170.0, 60.0, 22.0], 910 text: "prepend warn" 911 } 912 } 913 ], 914 lines: [ 915 { patchline: { destination: ["obj-plugout", 0], source: ["obj-jweb", 0] } }, 916 { patchline: { destination: ["obj-plugout", 1], source: ["obj-jweb", 1] } }, 917 { patchline: { destination: ["obj-print", 0], source: ["obj-thisdevice", 0] } }, 918 { patchline: { destination: ["obj-jweb-print", 0], source: ["obj-jweb", 2] } }, 919 { patchline: { destination: ["obj-route", 0], source: ["obj-jweb", 2] } }, 920 { patchline: { destination: ["obj-activate", 0], source: ["obj-route", 0] } }, 921 { patchline: { destination: ["obj-jweb", 0], source: ["obj-activate", 0] } }, 922 { patchline: { destination: ["obj-route-logs", 0], source: ["obj-jweb", 2] } }, 923 { patchline: { destination: ["obj-prepend-log", 0], source: ["obj-route-logs", 0] } }, 924 { patchline: { destination: ["obj-prepend-error", 0], source: ["obj-route-logs", 1] } }, 925 { patchline: { destination: ["obj-prepend-warn", 0], source: ["obj-route-logs", 2] } }, 926 { patchline: { destination: ["obj-udpsend", 0], source: ["obj-prepend-log", 0] } }, 927 { patchline: { destination: ["obj-udpsend", 0], source: ["obj-prepend-error", 0] } }, 928 { patchline: { destination: ["obj-udpsend", 0], source: ["obj-prepend-warn", 0] } } 929 ], 930 dependency_cache: [], 931 latency: 0, 932 is_mpe: 0, 933 external_mpe_tuning_enabled: 0, 934 minimum_live_version: "", 935 minimum_max_version: "", 936 platform_compatibility: 0, 937 autosave: 0 938 } 939 }; 940} 941 942// Build a complete .amxd binary from a patcher object 943function packAMXD(patcher) { 944 const patcherJson = Buffer.from(JSON.stringify(patcher)); 945 const lengthBuf = Buffer.alloc(4); 946 lengthBuf.writeUInt32LE(patcherJson.length, 0); 947 return Buffer.concat([M4L_HEADER_INSTRUMENT, lengthBuf, patcherJson]); 948} 949 950// Create an offline M4L device (.amxd) with an embedded HTML bundle 951async function createM4DBundle(pieceName, isJSPiece, onProgress = () => {}, density = null) { 952 onProgress({ stage: 'fetch', message: `Building M4L device for ${pieceName}...` }); 953 954 // Generate the offline HTML bundle using existing infrastructure 955 const bundleResult = isJSPiece 956 ? await createJSPieceBundle(pieceName, onProgress, false, density) 957 : await createBundle(pieceName, onProgress, false, density); 958 959 onProgress({ stage: 'generate', message: 'Embedding bundle in M4L device...' }); 960 961 // Encode the full HTML as a data: URI for jweb~ 962 const htmlBase64 = Buffer.from(bundleResult.html).toString('base64'); 963 const dataUri = `data:text/html;base64,${htmlBase64}`; 964 const patcher = generateM4DPatcher(pieceName, dataUri); 965 966 onProgress({ stage: 'compress', message: 'Packing .amxd binary...' }); 967 968 // Pack into .amxd format 969 const amxdBinary = packAMXD(patcher); 970 const filename = `AC ${pieceName} (offline).amxd`; 971 972 return { binary: amxdBinary, filename, sizeKB: Math.round(amxdBinary.length / 1024) }; 973} 974 975// Main bundle creation for KidLisp pieces 976async function createBundle(pieceName, onProgress = () => {}, nocompress = false, density = null) { 977 const PIECE_NAME_NO_DOLLAR = pieceName.replace(/^\$/, ''); 978 const PIECE_NAME = '$' + PIECE_NAME_NO_DOLLAR; 979 980 onProgress({ stage: 'fetch', message: `Fetching $${PIECE_NAME_NO_DOLLAR}...` }); 981 982 // Fetch KidLisp source with dependencies 983 const { sources: kidlispSources, authorHandle, userCode } = await getKidLispSourceWithDeps(PIECE_NAME_NO_DOLLAR); 984 const mainSource = kidlispSources[PIECE_NAME_NO_DOLLAR]; 985 const depCount = Object.keys(kidlispSources).length - 1; 986 987 onProgress({ stage: 'deps', message: `Found ${depCount} dependenc${depCount === 1 ? 'y' : 'ies'}` }); 988 989 const packTime = Date.now(); 990 const packDate = new Date().toLocaleString("en-US", { 991 timeZone: "America/Los_Angeles", 992 year: "numeric", 993 month: "long", 994 day: "numeric", 995 hour: "numeric", 996 minute: "2-digit", 997 second: "2-digit", 998 hour12: true, 999 }); 1000 1001 const bundleTimestamp = timestamp(); 1002 1003 // Determine acDir - in Netlify function context, use __dirname to find bundled files 1004 const acDir = path.join(__dirname, "..", "..", "public", "aesthetic.computer"); 1005 1006 console.log("[bundle-html] acDir:", acDir); 1007 console.log("[bundle-html] acDir exists:", fsSync.existsSync(acDir)); 1008 1009 // Get core bundle (cached per git commit) 1010 const coreFiles = await getCoreBundle(acDir, onProgress); 1011 1012 // Build VFS starting with core files 1013 const files = { ...coreFiles }; 1014 1015 // Extract and embed painting images (per-piece) 1016 const allKidlispSource = Object.values(kidlispSources).join('\n'); 1017 const paintingCodes = extractPaintingCodes(allKidlispSource); 1018 const paintingData = {}; 1019 1020 if (paintingCodes.length > 0) { 1021 onProgress({ stage: 'paintings', message: `Embedding ${paintingCodes.length} painting${paintingCodes.length === 1 ? '' : 's'}...` }); 1022 } 1023 1024 for (const code of paintingCodes) { 1025 const resolved = await resolvePaintingCode(code); 1026 if (resolved) { 1027 paintingData[code] = resolved; 1028 const imageBase64 = await fetchPaintingImage(resolved.handle, resolved.slug); 1029 if (imageBase64) { 1030 const vfsPath = `paintings/${code}.png`; 1031 files[vfsPath] = { content: imageBase64, binary: true, type: 'png' }; 1032 } 1033 } 1034 } 1035 1036 // Create synthetic .lisp files (per-piece) 1037 for (const [name, source] of Object.entries(kidlispSources)) { 1038 const pieceLispPath = `disks/${name}.lisp`; 1039 files[pieceLispPath] = { content: source, binary: false, type: 'lisp' }; 1040 } 1041 1042 onProgress({ stage: 'generate', message: 'Generating HTML bundle...' }); 1043 1044 // Generate filename first so it can be included in colophon 1045 const filename = `$${PIECE_NAME_NO_DOLLAR}-${authorHandle}-${bundleTimestamp}.lisp.html`; 1046 1047 // Generate HTML bundle (same template as CLI) 1048 const htmlContent = generateHTMLBundle({ 1049 PIECE_NAME, 1050 PIECE_NAME_NO_DOLLAR, 1051 mainSource, 1052 kidlispSources, 1053 files, 1054 paintingData, 1055 authorHandle, 1056 packDate, 1057 packTime, 1058 gitVersion: GIT_COMMIT, 1059 filename, 1060 density, // Pass density for FF1/device performance 1061 }); 1062 1063 onProgress({ stage: 'compress', message: nocompress ? 'Skipping compression (nocompress mode)...' : 'Compressing...' }); 1064 1065 // If nocompress is true, return the raw HTML without gzip wrapper 1066 // This is needed for devices without DecompressionStream support (e.g., FF1) 1067 if (nocompress) { 1068 return { 1069 html: htmlContent, 1070 filename, 1071 sizeKB: Math.round(htmlContent.length / 1024), 1072 mainSource, 1073 authorHandle, 1074 userCode, 1075 packDate, 1076 depCount, 1077 }; 1078 } 1079 1080 // Create gzip-compressed self-extracting bundle (brotli not supported in DecompressionStream) 1081 const compressed = gzipSync(Buffer.from(htmlContent, 'utf-8'), { level: 9 }); 1082 const base64 = compressed.toString('base64'); 1083 1084 const finalHtml = `<!DOCTYPE html> 1085<html lang="en"> 1086<head> 1087 <meta charset="utf-8"> 1088 <title>${PIECE_NAME} · Aesthetic Computer</title> 1089 <style>body{margin:0;background:#000;overflow:hidden}</style> 1090</head> 1091<body> 1092 <script> 1093 // Use blob: URL instead of data: URL for CSP compatibility (objkt sandboxing) 1094 const b64='${base64}'; 1095 const bin=atob(b64); 1096 const bytes=new Uint8Array(bin.length); 1097 for(let i=0;i<bin.length;i++)bytes[i]=bin.charCodeAt(i); 1098 const blob=new Blob([bytes],{type:'application/gzip'}); 1099 const url=URL.createObjectURL(blob); 1100 fetch(url) 1101 .then(r=>r.blob()) 1102 .then(b=>b.stream().pipeThrough(new DecompressionStream('gzip'))) 1103 .then(s=>new Response(s).text()) 1104 .then(h=>{URL.revokeObjectURL(url);document.open();document.write(h);document.close();}) 1105 .catch(e=>{document.body.style.color='#fff';document.body.textContent='Bundle error: '+e.message;}); 1106 </script> 1107</body> 1108</html>`; 1109 1110 return { 1111 html: finalHtml, 1112 filename, 1113 sizeKB: Math.round(finalHtml.length / 1024), 1114 mainSource, 1115 authorHandle, 1116 userCode, 1117 packDate, 1118 depCount, 1119 }; 1120} 1121 1122// Generate the inner HTML bundle 1123function generateHTMLBundle(opts) { 1124 const { 1125 PIECE_NAME, 1126 PIECE_NAME_NO_DOLLAR, 1127 mainSource, 1128 kidlispSources, 1129 files, 1130 paintingData, 1131 authorHandle, 1132 packDate, 1133 packTime, 1134 gitVersion, 1135 filename, 1136 density, 1137 } = opts; 1138 1139 return `<!DOCTYPE html> 1140<html lang="en"> 1141<head> 1142 <meta charset="utf-8"> 1143 <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> 1144 <title>${PIECE_NAME} · Aesthetic Computer</title> 1145 <style> 1146 body { margin: 0; padding: 0; background: black; overflow: hidden; } 1147 canvas { display: block; image-rendering: pixelated; } 1148 </style> 1149</head> 1150<body> 1151 <script type="module"> 1152 window.acPACK_MODE = true; // Required for import map resolution from blob URLs 1153 window.KIDLISP_SUPPRESS_SNAPSHOT_LOGS = true; // Disable console screenshots 1154 window.__acKidlispConsoleEnabled = false; // Disable KidLisp console auto-snaps 1155 window.acKEEP_MODE = true; 1156 window.acSTARTING_PIECE = "${PIECE_NAME}"; 1157 window.acPACK_PIECE = "${PIECE_NAME}"; 1158 window.acPACK_DATE = "${packDate}"; 1159 window.acPACK_GIT = "${gitVersion}"; 1160 window.acKIDLISP_SOURCE = ${JSON.stringify(mainSource)}; 1161 1162 // Set density if provided (e.g., density=8 for FF1/device mode) 1163 ${density ? `window.acPACK_DENSITY = ${density};` : '// No density override - using default'} 1164 1165 window.acPACK_COLOPHON = { 1166 piece: { 1167 name: '${PIECE_NAME_NO_DOLLAR}', 1168 sourceCode: ${JSON.stringify(mainSource)}, 1169 isKidLisp: true 1170 }, 1171 build: { 1172 author: '${authorHandle}', 1173 packTime: ${packTime}, 1174 gitCommit: '${gitVersion}', 1175 gitIsDirty: false, 1176 fileCount: ${Object.keys(files).length}, 1177 filename: '${filename}' 1178 } 1179 }; 1180 1181 window.acPAINTING_CODE_MAP = ${JSON.stringify(paintingData)}; 1182 window.VFS = ${JSON.stringify(files).replace(/<\/script>/g, '<\\/script>')}; 1183 1184 window.acEMBEDDED_PAINTING_BITMAPS = {}; 1185 window.acPAINTING_BITMAPS_READY = false; 1186 1187 async function decodePaintingToBitmap(code, base64Data) { 1188 return new Promise((resolve, reject) => { 1189 const img = new Image(); 1190 img.onload = function() { 1191 const canvas = document.createElement('canvas'); 1192 canvas.width = img.width; 1193 canvas.height = img.height; 1194 const ctx = canvas.getContext('2d'); 1195 ctx.drawImage(img, 0, 0); 1196 const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); 1197 resolve({ width: imageData.width, height: imageData.height, pixels: imageData.data }); 1198 }; 1199 img.onerror = reject; 1200 img.src = 'data:image/png;base64,' + base64Data; 1201 }); 1202 } 1203 1204 window.acDecodePaintingsPromise = (async function() { 1205 const paintingPromises = []; 1206 for (const [code, info] of Object.entries(window.acPAINTING_CODE_MAP || {})) { 1207 const vfsPath = 'paintings/' + code + '.png'; 1208 if (window.VFS && window.VFS[vfsPath]) { 1209 const promise = decodePaintingToBitmap(code, window.VFS[vfsPath].content) 1210 .then(bitmap => { 1211 window.acEMBEDDED_PAINTING_BITMAPS['#' + code] = bitmap; 1212 window.acEMBEDDED_PAINTING_BITMAPS[code] = bitmap; 1213 }) 1214 .catch(() => {}); 1215 paintingPromises.push(promise); 1216 } 1217 } 1218 await Promise.all(paintingPromises); 1219 window.acPAINTING_BITMAPS_READY = true; 1220 })(); 1221 1222 window.EMBEDDED_KIDLISP_SOURCE = ${JSON.stringify(mainSource)}; 1223 window.EMBEDDED_KIDLISP_PIECE = '${PIECE_NAME_NO_DOLLAR}'; 1224 window.objktKidlispCodes = ${JSON.stringify(kidlispSources)}; 1225 window.acPREFILL_CODE_CACHE = ${JSON.stringify(kidlispSources)}; 1226 1227 const originalAppendChild = Element.prototype.appendChild; 1228 Element.prototype.appendChild = function(child) { 1229 if (child.tagName === 'LINK' && child.rel === 'stylesheet' && child.href && child.href.includes('.css')) { 1230 return child; 1231 } 1232 return originalAppendChild.call(this, child); 1233 }; 1234 1235 // Also intercept HTMLBodyElement.append for font CSS loading 1236 const originalBodyAppend = HTMLBodyElement.prototype.append; 1237 HTMLBodyElement.prototype.append = function(...nodes) { 1238 const filteredNodes = nodes.filter(node => { 1239 if (node.tagName === 'LINK' && node.rel === 'stylesheet') { 1240 return false; 1241 } 1242 return true; 1243 }); 1244 return originalBodyAppend.call(this, ...filteredNodes); 1245 }; 1246 1247 window.VFS_BLOB_URLS = {}; 1248 window.modulePaths = []; 1249 1250 Object.entries(window.VFS).forEach(([path, file]) => { 1251 if (path.endsWith('.mjs') || path.endsWith('.js')) { 1252 const blob = new Blob([file.content], { type: 'application/javascript' }); 1253 const blobUrl = URL.createObjectURL(blob); 1254 window.VFS_BLOB_URLS[path] = blobUrl; 1255 window.modulePaths.push(path); 1256 } 1257 }); 1258 1259 const importMapEntries = {}; 1260 for (const filepath of window.modulePaths) { 1261 if (window.VFS_BLOB_URLS[filepath]) { 1262 // Only add essential mappings to reduce import map size 1263 importMapEntries[filepath] = window.VFS_BLOB_URLS[filepath]; 1264 importMapEntries['/' + filepath] = window.VFS_BLOB_URLS[filepath]; 1265 importMapEntries[\`./\${filepath}\`] = window.VFS_BLOB_URLS[filepath]; 1266 } 1267 } 1268 1269 const importMap = { imports: importMapEntries }; 1270 const importMapScript = document.createElement('script'); 1271 importMapScript.type = 'importmap'; 1272 importMapScript.textContent = JSON.stringify(importMap); 1273 document.head.appendChild(importMapScript); 1274 1275 const originalFetch = window.fetch; 1276 window.fetch = function(url, options) { 1277 const urlStr = typeof url === 'string' ? url : url.toString(); 1278 1279 if (urlStr.includes('/api/painting-code')) { 1280 const codeMatch = urlStr.match(/[?&]code=([^&]+)/); 1281 if (codeMatch) { 1282 const code = codeMatch[1]; 1283 const paintingInfo = window.acPAINTING_CODE_MAP[code]; 1284 if (paintingInfo) { 1285 return Promise.resolve(new Response(JSON.stringify({ 1286 code: paintingInfo.code, 1287 handle: paintingInfo.handle, 1288 slug: paintingInfo.slug 1289 }), { status: 200, headers: { 'Content-Type': 'application/json' } })); 1290 } 1291 } 1292 return Promise.resolve(new Response(JSON.stringify({ error: 'Not found' }), { status: 404 })); 1293 } 1294 1295 let vfsPath = decodeURIComponent(urlStr) 1296 .replace(/^https?:\\/\\/[^\\/]+\\//g, '') 1297 .replace(/^aesthetic\\.computer\\//g, '') 1298 .replace(/#.*$/g, '') 1299 .replace(/\\?.*$/g, ''); 1300 1301 vfsPath = vfsPath.replace(/^\\.\\.\\/+/g, '').replace(/^\\.\\//g, '').replace(/^\\//g, '').replace(/^aesthetic\\.computer\\//g, ''); 1302 1303 if (urlStr.includes('/media/') && urlStr.includes('/painting/')) { 1304 for (const [code, info] of Object.entries(window.acPAINTING_CODE_MAP || {})) { 1305 if (urlStr.includes(info.slug)) { 1306 const paintingVfsPath = 'paintings/' + code + '.png'; 1307 if (window.VFS[paintingVfsPath]) { 1308 const file = window.VFS[paintingVfsPath]; 1309 const binaryStr = atob(file.content); 1310 const bytes = new Uint8Array(binaryStr.length); 1311 for (let i = 0; i < binaryStr.length; i++) { 1312 bytes[i] = binaryStr.charCodeAt(i); 1313 } 1314 return Promise.resolve(new Response(bytes, { 1315 status: 200, 1316 headers: { 'Content-Type': 'image/png' } 1317 })); 1318 } 1319 } 1320 } 1321 } 1322 1323 if (window.VFS[vfsPath]) { 1324 const file = window.VFS[vfsPath]; 1325 let content; 1326 let contentType = 'text/plain'; 1327 1328 if (file.binary) { 1329 const binaryStr = atob(file.content); 1330 const bytes = new Uint8Array(binaryStr.length); 1331 for (let i = 0; i < binaryStr.length; i++) { 1332 bytes[i] = binaryStr.charCodeAt(i); 1333 } 1334 content = bytes; 1335 if (file.type === 'png') contentType = 'image/png'; 1336 else if (file.type === 'jpg' || file.type === 'jpeg') contentType = 'image/jpeg'; 1337 } else { 1338 content = file.content; 1339 if (file.type === 'mjs' || file.type === 'js') contentType = 'application/javascript'; 1340 else if (file.type === 'json') contentType = 'application/json'; 1341 } 1342 1343 return Promise.resolve(new Response(content, { 1344 status: 200, 1345 headers: { 'Content-Type': contentType } 1346 })); 1347 } 1348 1349 // Silently handle expected missing files (fonts, .mjs pieces, cursors, SVGs, and CSS) 1350 if (vfsPath.includes('disks/drawings/font_') || vfsPath.endsWith('.mjs') || vfsPath.includes('cursors/') || vfsPath.endsWith('.svg') || vfsPath.endsWith('.css') || urlStr.includes('/type/webfonts/')) { 1351 return Promise.resolve(new Response('', { status: 200, headers: { 'Content-Type': 'text/css' } })); 1352 } 1353 1354 return originalFetch.call(this, url, options); 1355 }; 1356 1357 // 📊 Bundle Telemetry - collect FPS and boot data 1358 (function initTelemetry() { 1359 // Skip if not on aesthetic.computer domain (sandboxed iframes have null origin) 1360 const origin = window.location?.origin || ''; 1361 const isAcDomain = origin.includes('aesthetic.computer') || origin.includes('localhost'); 1362 // Telemetry origin check (silent) 1363 if (!isAcDomain) return; 1364 1365 const bootStart = performance.now(); 1366 const sessionId = Math.random().toString(36).slice(2) + Date.now().toString(36); 1367 const telemetryUrl = 'https://aesthetic.computer/api/bundle-telemetry'; 1368 1369 // Collect performance samples 1370 const perfSamples = []; 1371 let sampleCount = 0; 1372 const maxSamples = 60; // 1 minute of data at 1 sample/second 1373 1374 function sendTelemetry(type, data) { 1375 try { 1376 const payload = { 1377 type, 1378 data: { 1379 sessionId, 1380 piece: window.acPACK_PIECE || 'unknown', 1381 density: window.acPACK_DENSITY || 2, 1382 screenWidth: window.innerWidth, 1383 screenHeight: window.innerHeight, 1384 devicePixelRatio: window.devicePixelRatio || 1, 1385 userAgent: navigator.userAgent, 1386 ...data 1387 } 1388 }; 1389 const body = JSON.stringify(payload); 1390 // Try sendBeacon first, fall back to fetch 1391 if (navigator.sendBeacon && navigator.sendBeacon(telemetryUrl, body)) { 1392 return; 1393 } 1394 fetch(telemetryUrl, { method: 'POST', body, headers: {'Content-Type': 'application/json'}, keepalive: true }).catch(() => {}); 1395 } catch (e) { console.warn('Telemetry error:', e); } 1396 } 1397 1398 // Send boot telemetry once loaded 1399 window.addEventListener('load', () => { 1400 const bootTime = performance.now() - bootStart; 1401 sendTelemetry('boot', { 1402 bootTime: Math.round(bootTime), 1403 vfsFileCount: Object.keys(window.VFS || {}).length, 1404 blobUrlCount: Object.keys(window.VFS_BLOB_URLS || {}).length, 1405 }); 1406 }); 1407 1408 // Collect FPS samples every second 1409 let lastFrameTime = performance.now(); 1410 let frameCount = 0; 1411 1412 function measureFrame() { 1413 frameCount++; 1414 const now = performance.now(); 1415 if (now - lastFrameTime >= 1000) { 1416 const fps = frameCount; 1417 frameCount = 0; 1418 lastFrameTime = now; 1419 1420 if (sampleCount < maxSamples) { 1421 perfSamples.push({ t: Math.round(now - bootStart), fps }); 1422 sampleCount++; 1423 1424 // Send perf batch every 10 samples 1425 if (sampleCount % 10 === 0) { 1426 sendTelemetry('perf', { samples: perfSamples.slice(-10) }); 1427 } 1428 } 1429 } 1430 requestAnimationFrame(measureFrame); 1431 } 1432 requestAnimationFrame(measureFrame); 1433 1434 // Send error telemetry 1435 window.addEventListener('error', (e) => { 1436 sendTelemetry('error', { 1437 message: e.message, 1438 filename: e.filename, 1439 lineno: e.lineno, 1440 colno: e.colno, 1441 }); 1442 }); 1443 })(); 1444 1445 (async function() { 1446 if (window.acDecodePaintingsPromise) { 1447 await window.acDecodePaintingsPromise; 1448 } 1449 import(window.VFS_BLOB_URLS['boot.mjs']).catch(err => { 1450 document.body.style.color='#fff'; 1451 document.body.textContent='Boot failed: '+err.message; 1452 }); 1453 })(); 1454 </script> 1455</body> 1456</html>`; 1457} 1458 1459// Main handler - uses Netlify streaming adapter 1460exports.handler = stream(async (event) => { 1461 const code = event.queryStringParameters?.code; 1462 const piece = event.queryStringParameters?.piece; 1463 const format = event.queryStringParameters?.format || 'html'; 1464 const nocache = event.queryStringParameters?.nocache === '1' || event.queryStringParameters?.nocache === 'true'; 1465 const nocompress = event.queryStringParameters?.nocompress === '1' || event.queryStringParameters?.nocompress === 'true'; 1466 const nominify = event.queryStringParameters?.nominify === '1' || event.queryStringParameters?.nominify === 'true'; 1467 const inline = event.queryStringParameters?.inline === '1' || event.queryStringParameters?.inline === 'true'; 1468 const density = parseInt(event.queryStringParameters?.density) || null; // e.g., density=8 for FF1 1469 const mode = event.queryStringParameters?.mode; // 'device' for simple iframe wrapper 1470 1471 // Device mode: return a simple iframe wrapper (like device.kidlisp.com) 1472 // This is much faster and more reliable on devices like FF1 1473 if (mode === 'device') { 1474 const pieceCode = code || piece; 1475 if (!pieceCode) { 1476 return { 1477 statusCode: 400, 1478 headers: { "Content-Type": "text/plain" }, 1479 body: "Missing code or piece parameter", 1480 }; 1481 } 1482 1483 const densityParam = density ? `?density=${density}` : ''; 1484 const pieceUrl = `https://aesthetic.computer/${pieceCode}${densityParam}`; 1485 1486 const deviceHtml = `<!DOCTYPE html> 1487<html lang="en"> 1488<head> 1489 <meta charset="utf-8"> 1490 <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> 1491 <title>${pieceCode} · Aesthetic Computer (Device)</title> 1492 <style> 1493 * { margin: 0; padding: 0; box-sizing: border-box; } 1494 html, body { width: 100%; height: 100%; overflow: hidden; background: black; } 1495 iframe { width: 100%; height: 100%; border: none; } 1496 </style> 1497</head> 1498<body> 1499 <iframe src="${pieceUrl}" allow="autoplay; fullscreen"></iframe> 1500</body> 1501</html>`; 1502 1503 return { 1504 statusCode: 200, 1505 headers: { 1506 "Content-Type": "text/html; charset=utf-8", 1507 "Cache-Control": "public, max-age=60", 1508 }, 1509 body: deviceHtml, 1510 }; 1511 } 1512 1513 // Set minification flag (nominify=1 skips SWC minification for debugging) 1514 skipMinification = nominify; 1515 1516 // Force cache refresh if requested (also needed when nominify changes) 1517 if (nocache || nominify) { 1518 coreBundleCache = null; 1519 coreBundleCacheCommit = null; 1520 } 1521 1522 // Determine which type of bundle to create 1523 const isJSPiece = !!piece; 1524 const bundleTarget = piece || code; 1525 1526 if (!bundleTarget) { 1527 return { 1528 statusCode: 400, 1529 headers: { "Content-Type": "application/json" }, 1530 body: JSON.stringify({ 1531 error: "Missing 'code' or 'piece' parameter.", 1532 usage: { 1533 kidlisp: "/api/bundle-html?code=39j", 1534 javascript: "/api/bundle-html?piece=notepat" 1535 } 1536 }), 1537 }; 1538 } 1539 1540 // M4D mode: generate an offline .amxd Max for Live device 1541 if (format === 'm4d') { 1542 try { 1543 const onProgress = (progress) => { 1544 console.log(`[bundle-html] m4d ${progress.stage}: ${progress.message}`); 1545 }; 1546 1547 const { binary, filename, sizeKB } = await createM4DBundle( 1548 bundleTarget, isJSPiece, onProgress, density 1549 ); 1550 1551 return { 1552 statusCode: 200, 1553 headers: { 1554 "Content-Type": "application/octet-stream", 1555 "Content-Disposition": `attachment; filename="${filename}"`, 1556 "Content-Length": binary.length.toString(), 1557 "Cache-Control": "no-cache", 1558 }, 1559 body: binary.toString('base64'), 1560 isBase64Encoded: true, 1561 }; 1562 } catch (error) { 1563 console.error("M4D bundle creation failed:", error); 1564 return { 1565 statusCode: 500, 1566 headers: { "Content-Type": "application/json" }, 1567 body: JSON.stringify({ error: error.message }), 1568 }; 1569 } 1570 } 1571 1572 // Streaming mode with SSE progress updates 1573 if (format === 'stream') { 1574 const readable = new ReadableStream({ 1575 async start(controller) { 1576 const encoder = new TextEncoder(); 1577 1578 const sendEvent = (eventType, data) => { 1579 controller.enqueue(encoder.encode(`event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`)); 1580 }; 1581 1582 try { 1583 const onProgress = (progress) => { 1584 sendEvent('progress', progress); 1585 }; 1586 1587 const { html, filename, sizeKB } = isJSPiece 1588 ? await createJSPieceBundle(bundleTarget, onProgress, nocompress, density) 1589 : await createBundle(bundleTarget, onProgress, nocompress, density); 1590 1591 sendEvent('complete', { 1592 filename, 1593 content: Buffer.from(html).toString('base64'), 1594 sizeKB, 1595 }); 1596 1597 controller.close(); 1598 } catch (error) { 1599 console.error("Bundle creation failed:", error); 1600 sendEvent('error', { error: error.message }); 1601 controller.close(); 1602 } 1603 } 1604 }); 1605 1606 return { 1607 statusCode: 200, 1608 headers: { 1609 "Content-Type": "text/event-stream", 1610 "Cache-Control": "no-cache", 1611 "Connection": "keep-alive", 1612 }, 1613 body: readable, 1614 }; 1615 } 1616 1617 // Non-streaming modes 1618 try { 1619 const progressLog = []; 1620 const onProgress = (progress) => { 1621 progressLog.push(progress.message); 1622 console.log(`[bundle-html] ${progress.stage}: ${progress.message}`); 1623 }; 1624 1625 const bundleResult = isJSPiece 1626 ? await createJSPieceBundle(bundleTarget, onProgress, nocompress, density) 1627 : await createBundle(bundleTarget, onProgress, nocompress, density); 1628 1629 const { html, filename, sizeKB, mainSource, authorHandle, userCode, packDate, depCount } = bundleResult; 1630 1631 if (format === 'json' || format === 'base64') { 1632 return { 1633 statusCode: 200, 1634 headers: { "Content-Type": "application/json" }, 1635 body: JSON.stringify({ 1636 filename, 1637 content: Buffer.from(html).toString('base64'), 1638 sizeKB, 1639 progress: progressLog, 1640 // KidLisp-specific metadata (undefined for JS pieces) 1641 sourceCode: mainSource, 1642 authorHandle, 1643 userCode, 1644 packDate, 1645 depCount, 1646 }), 1647 }; 1648 } 1649 1650 // Default: return as HTML (inline for viewing, attachment for download) 1651 const headers = { 1652 "Content-Type": "text/html; charset=utf-8", 1653 "Cache-Control": "public, max-age=3600", 1654 }; 1655 1656 // Only add Content-Disposition: attachment if NOT inline mode 1657 // inline=1 serves the HTML directly for viewing in browser/FF1 1658 if (!inline) { 1659 headers["Content-Disposition"] = `attachment; filename="${filename}"`; 1660 } 1661 1662 return { 1663 statusCode: 200, 1664 headers, 1665 body: html, 1666 }; 1667 1668 } catch (error) { 1669 console.error("Bundle creation failed:", error); 1670 return { 1671 statusCode: 500, 1672 headers: { "Content-Type": "application/json" }, 1673 body: JSON.stringify({ error: error.message }), 1674 }; 1675 } 1676});