|
| 1 | +import fs from 'node:fs'; |
| 2 | +import path from 'node:path'; |
| 3 | +import { fileURLToPath } from 'node:url'; |
| 4 | + |
| 5 | +const __filename = fileURLToPath(import.meta.url); |
| 6 | +const __dirname = path.dirname(__filename); |
| 7 | +const DIST_DIR = path.resolve(__dirname, '../dist'); |
| 8 | +const OUTPUT_FILE = path.resolve(DIST_DIR, 'single-file.html'); |
| 9 | + |
| 10 | +function getBase64(file) { |
| 11 | + const bitmap = fs.readFileSync(file); |
| 12 | + return Buffer.from(bitmap).toString('base64'); |
| 13 | +} |
| 14 | + |
| 15 | +function getMimeType(filePath) { |
| 16 | + const ext = path.extname(filePath).toLowerCase(); |
| 17 | + const mimes = { |
| 18 | + '.js': 'application/javascript', |
| 19 | + '.css': 'text/css', |
| 20 | + '.wasm': 'application/wasm', |
| 21 | + '.png': 'image/png', |
| 22 | + '.jpg': 'image/jpeg', |
| 23 | + '.gif': 'image/gif', |
| 24 | + '.woff': 'font/woff', |
| 25 | + '.woff2': 'font/woff2', |
| 26 | + '.ttf': 'font/ttf', |
| 27 | + '.html': 'text/html', |
| 28 | + }; |
| 29 | + return mimes[ext] || 'application/octet-stream'; |
| 30 | +} |
| 31 | + |
| 32 | +function walkDir(dir, callback) { |
| 33 | + fs.readdirSync(dir).forEach((f) => { |
| 34 | + const dirPath = path.join(dir, f); |
| 35 | + const isDirectory = fs.statSync(dirPath).isDirectory(); |
| 36 | + isDirectory ? walkDir(dirPath, callback) : callback(path.join(dir, f)); |
| 37 | + }); |
| 38 | +} |
| 39 | + |
| 40 | +function bundle() { |
| 41 | + console.log('Starting Single-File HTML bundling...'); |
| 42 | + let html = fs.readFileSync(path.join(DIST_DIR, 'index.html'), 'utf8'); |
| 43 | + |
| 44 | + // 1. Collect all files into VFS |
| 45 | + const vfs = {}; |
| 46 | + walkDir(DIST_DIR, (filePath) => { |
| 47 | + const relativePath = path.relative(DIST_DIR, filePath); |
| 48 | + if (relativePath === 'index.html' || relativePath === 'single-file.html' || relativePath === 'sw.js') return; |
| 49 | + |
| 50 | + console.log(`Bunding: ${relativePath}`); |
| 51 | + const content = getBase64(filePath); |
| 52 | + vfs[`/${relativePath}`] = { |
| 53 | + content, |
| 54 | + mime: getMimeType(filePath) |
| 55 | + }; |
| 56 | + }); |
| 57 | + |
| 58 | + // 2. Inject VFS and Request Interceptor |
| 59 | + const interceptorScript = ` |
| 60 | +<script> |
| 61 | + (function() { |
| 62 | + const VFS = ${JSON.stringify(vfs)}; |
| 63 | + |
| 64 | + function base64ToUint8Array(base64) { |
| 65 | + var binary_string = window.atob(base64); |
| 66 | + var len = binary_string.length; |
| 67 | + var bytes = new Uint8Array(len); |
| 68 | + for (var i = 0; i < len; i++) { |
| 69 | + bytes[i] = binary_string.charCodeAt(i); |
| 70 | + } |
| 71 | + return bytes; |
| 72 | + } |
| 73 | +
|
| 74 | + // Intercept Fetch |
| 75 | + const originalFetch = window.fetch; |
| 76 | + window.fetch = function(input, init) { |
| 77 | + const url = typeof input === 'string' ? input : input.url; |
| 78 | + const path = new URL(url, window.location.origin).pathname; |
| 79 | + const normalizedPath = path.startsWith('./') ? path.substring(1) : path; |
| 80 | + |
| 81 | + if (VFS[normalizedPath]) { |
| 82 | + const file = VFS[normalizedPath]; |
| 83 | + const data = base64ToUint8Array(file.content); |
| 84 | + const response = new Response(data, { |
| 85 | + status: 200, |
| 86 | + headers: { 'Content-Type': file.mime } |
| 87 | + }); |
| 88 | + return Promise.resolve(response); |
| 89 | + } |
| 90 | + return originalFetch.apply(this, arguments); |
| 91 | + }; |
| 92 | +
|
| 93 | + // Intercept XHR |
| 94 | + const originalOpen = XMLHttpRequest.prototype.open; |
| 95 | + XMLHttpRequest.prototype.open = function(method, url) { |
| 96 | + this._url = url; |
| 97 | + return originalOpen.apply(this, arguments); |
| 98 | + }; |
| 99 | +
|
| 100 | + const originalSend = XMLHttpRequest.prototype.send; |
| 101 | + XMLHttpRequest.prototype.send = function() { |
| 102 | + const path = new URL(this._url, window.location.origin).pathname; |
| 103 | + if (VFS[path]) { |
| 104 | + const file = VFS[path]; |
| 105 | + const data = base64ToUint8Array(file.content); |
| 106 | + |
| 107 | + Object.defineProperty(this, 'status', { writable: true, value: 200 }); |
| 108 | + Object.defineProperty(this, 'readyState', { writable: true, value: 4 }); |
| 109 | + Object.defineProperty(this, 'response', { writable: true, value: data.buffer }); |
| 110 | + Object.defineProperty(this, 'responseText', { writable: true, value: new TextDecoder().decode(data) }); |
| 111 | + |
| 112 | + if (this.onreadystatechange) this.onreadystatechange(); |
| 113 | + if (this.onload) this.onload(); |
| 114 | + return; |
| 115 | + } |
| 116 | + return originalSend.apply(this, arguments); |
| 117 | + }; |
| 118 | + |
| 119 | + console.log('VFS Interceptor active'); |
| 120 | + })(); |
| 121 | +</script> |
| 122 | +`; |
| 123 | + |
| 124 | + // Insert interceptor at the top of head |
| 125 | + html = html.replace('<head>', '<head>' + interceptorScript); |
| 126 | + |
| 127 | + // 3. Fix relative paths in HTML to be absolute for the interceptor |
| 128 | + html = html.replace(/(src|href)="\.?\//g, '$1="/'); |
| 129 | + |
| 130 | + fs.writeFileSync(OUTPUT_FILE, html); |
| 131 | + console.log(`Successfully generated: ${OUTPUT_FILE} (${(fs.statSync(OUTPUT_FILE).size / 1024 / 1024).toFixed(2)} MB)`); |
| 132 | +} |
| 133 | + |
| 134 | +bundle(); |
0 commit comments