import type { BunFile } from 'bun'; import path from 'path'; import { minify } from 'html-minifier'; import UglifyJS from 'uglify-js'; import { Jimp, ResizeStrategy } from 'jimp'; import { Type, type Static } from '@common/typebox'; import wasmPlugin from './plugins/wasmPlugin'; import imagePlugin from './plugins/imagePlugin'; import fontPlugin from './plugins/fontPlugin'; import audioPlugin from './plugins/audioPlugin'; import filePlugin from './plugins/filePlugin'; import { getGames } from './isGame'; export const b64 = async (file: string | BunFile) => ( typeof file === 'string' ? Buffer.from(file) : Buffer.from(await file.arrayBuffer()) ).toString('base64'); const AppConfigScheme = Type.Object({ isApp: Type.Optional(Type.Boolean()), backgroundColor: Type.Optional(Type.String()), themeColor: Type.Optional(Type.String()), }); type AppConfig = Static; const DEFAULT_CONFIG: AppConfig = {}; interface Args { production?: boolean; mobile?: boolean; local?: boolean; } const SW_SCRIPT = ``; const CSS_RELOAD_SCRIPT = ``; async function buildBundle(game: string, production: boolean) { const assetsDir = path.resolve(import.meta.dir, 'assets'); const srcDir = path.resolve(import.meta.dir, '..', 'src'); const gameDir = path.resolve(srcDir, 'games', game); const gameAssetsDir = path.resolve(gameDir, 'assets'); let appConfig = Bun.file(path.resolve(gameAssetsDir, 'config.yml')); let config = DEFAULT_CONFIG; if (await appConfig.exists()) { try { const maybeConfig = Bun.YAML.parse(await appConfig.text()); if (Type.Is(AppConfigScheme, maybeConfig)) { config = maybeConfig; } } catch { } } const entrypoints = [ path.resolve(assetsDir, 'global.css'), ]; if (config.isApp) { entrypoints.push(path.resolve(assetsDir, 'app.css')); } entrypoints.push(path.resolve(srcDir, 'index.ts')); const bundle = await Bun.build({ outdir: '/tmp', entrypoints, sourcemap: production ? 'none' : 'inline', define: { global: 'window', GAME: `"${game}"`, GAMES: JSON.stringify(await getGames()), }, plugins: [ imagePlugin, audioPlugin, fontPlugin, wasmPlugin({ production }), filePlugin, ] }); return { bundle, config, gameDir, gameAssetsDir, srcDir, assetsDir }; } export async function buildHTML(game: string, { production = false, mobile = false, local = false, }: Args = {}) { const { bundle, config, gameAssetsDir, assetsDir, } = await buildBundle(game, production); const html = await Bun.file(path.resolve(assetsDir, 'index.html')).text(); if (bundle.success) { const scriptFile = bundle.outputs.find(a => a.kind === 'entry-point' && a.path.endsWith('.js')); if (!scriptFile) { console.error('No entry point found:', bundle.outputs); return; } const iconFile = Bun.file(path.resolve(gameAssetsDir, 'favicon.ico')); let icon = ''; if (await iconFile.exists()) { icon = ``; } let style = ''; let styleFiles = bundle.outputs.filter(a => a.kind === 'asset' && a.path.endsWith('.css')); for (let styleFile of styleFiles) { style += await styleFile.text(); } const title = game[0].toUpperCase() + game.slice(1).toLowerCase(); let pwaIconFile = path.resolve(gameAssetsDir, 'pwa_icon.png'); if (!await Bun.file(pwaIconFile).exists()) { pwaIconFile = path.resolve(assetsDir, 'pwa_icon.png'); } const pwaIcon = await Jimp.read(pwaIconFile); if (pwaIcon.width !== 192 || pwaIcon.height !== 192) { pwaIcon.resize({ w: 192, h: 192, mode: ResizeStrategy.BICUBIC }); } const pwaIconData = await pwaIcon.getBase64("image/png"); let manifest = ''; if (production && !local) { const publishURL = process.env.PUBLISH_URL ? `${process.env.PUBLISH_URL}${game}` : '.'; const manifestJSON = JSON.stringify({ name: title, short_name: title, start_url: publishURL, id: `/${game}`, display_override: config.isApp ? [] : ["window-controls-overlay"], display: config.isApp ? "standalone" : "fullscreen", background_color: config.backgroundColor ?? "#ffffff", theme_color: config.themeColor ?? "#000000", icons: [{ src: pwaIconData, sizes: '192x192', type: 'image/png' }] }); manifest = ``; } let script = await scriptFile.text(); const inits = new Set(); script = script.replace(/var (init_[^ ]+) = __esm\(\(\)/g, (_, $1) => { inits.add($1); return `var ${$1} = __esm(async ()`; }); for (const init of inits) { script = script.replaceAll(`${init}()`, `await ${init}()`); } script = script.replaceAll('await Promise.resolve().then(() =>', '('); let scriptPrefix = ''; if (production) { const minifyResult = UglifyJS.minify(script, { module: true, toplevel: true, }); if (minifyResult.error) { console.warn(`Minify error: ${minifyResult.error}`); } else { script = minifyResult.code; } } else if (mobile) { const eruda = await Bun.file(path.resolve(import.meta.dir, '..', 'node_modules', 'eruda', 'eruda.js')).text(); scriptPrefix = ``; } if (!local) { scriptPrefix += SW_SCRIPT; } if (local && !production) { scriptPrefix += CSS_RELOAD_SCRIPT; } // function to avoid $& being replaced // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace#specifying_a_string_as_the_replacement const resultHTML = html .replace('', () => `${scriptPrefix}`) .replace('', () => title) .replace('', () => icon) .replace('', () => manifest) .replace('', () => ``); return minify(resultHTML, { collapseWhitespace: production, decodeEntities: true, minifyCSS: production, minifyJS: production, }); } else { console.error('Failed: ', !bundle.success, bundle); } } /** * Rebuild only the CSS bundle (resolves composes: directives via Bun.build). * Used by the dev server for CSS hot reload. */ export async function buildCSS(game: string): Promise { const { bundle } = await buildBundle(game, false); if (bundle.success) { let style = ''; for (const file of bundle.outputs.filter(a => a.kind === 'asset' && a.path.endsWith('.css'))) { style += await file.text(); } return style; } else { console.error('CSS build failed:', bundle); return null; } }