251 lines
8.6 KiB
TypeScript
251 lines
8.6 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';
|
|
|
|
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;
|
|
exported?: 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') || '';
|
|
if (!game) return;
|
|
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 = () => {
|
|
ws.removeEventListener('message', onMessage);
|
|
ws.removeEventListener('close', onClose);
|
|
setTimeout(connect, 500);
|
|
};
|
|
ws.addEventListener('message', onMessage);
|
|
ws.addEventListener('close', onClose);
|
|
}
|
|
connect();
|
|
</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 = '';
|
|
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;
|
|
}
|
|
} |