Monorepo for Aesthetic.Computer
aesthetic.computer
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});