1
0
Fork 0
tsgames/build/html.ts

253 lines
8.7 KiB
TypeScript

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<typeof AppConfigScheme>;
const DEFAULT_CONFIG: AppConfig = {};
interface Args {
production?: boolean;
mobile?: boolean;
local?: boolean;
}
const SW_SCRIPT = `<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(registration => console.log('SW registered'))
.catch(err => console.error('SW registration failed:', err));
});
}
</script>`;
const CSS_RELOAD_SCRIPT = `<script>
function connect() {
const params = new URLSearchParams(location.search);
const game = params.get('game') || 'index';
const ws = new WebSocket('css-ws?game=' + encodeURIComponent(game));
const onMessage = (e) => {
try {
const { type, css } = JSON.parse(e.data);
if (type === 'css') {
const styleTag = document.querySelector('style');
if (styleTag) styleTag.textContent = css;
}
} catch { }
};
const onClose = (e) => {
ws.removeEventListener('message', onMessage);
ws.removeEventListener('close', onClose);
setTimeout(connect, 500);
};
ws.addEventListener('message', onMessage);
ws.addEventListener('close', onClose);
}
connect();
</script>`;
const POLYFILL = `<script>
if (!Symbol.metadata) Symbol.metadata = Symbol.for('Symbol.metadata');
</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 = `<link rel="icon" href="data:;base64,${await b64(iconFile)}" />`;
}
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 = `<link rel="manifest" href="data:application/json;base64,${await b64(manifestJSON)}" />`;
}
let script = await scriptFile.text();
const inits = new Set<string>();
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 = POLYFILL;
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 += `<script>${eruda};\neruda.init();</script>`;
}
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('<!--$SCRIPT$-->', () => `${scriptPrefix}<script type="module">${script}</script>`)
.replace('<!--$TITLE$-->', () => title)
.replace('<!--$ICON$-->', () => icon)
.replace('<!--$MANIFEST$-->', () => manifest)
.replace('<!--$STYLE$-->', () => `<style>${style}</style>`);
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<string | null> {
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;
}
}