190 lines
7.0 KiB
TypeScript
190 lines
7.0 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 './wasmPlugin';
|
|
import imagePlugin from './imagePlugin';
|
|
import fontPlugin from './fontPlugin';
|
|
import audioPlugin from './audioPlugin';
|
|
import filePlugin from './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;
|
|
}
|
|
|
|
async function buildBundle(game: string, production: boolean) {
|
|
const srcDir = path.resolve(import.meta.dir, '..', 'src');
|
|
return Bun.build({
|
|
outdir: '/tmp',
|
|
entrypoints: [path.resolve(srcDir, 'index.ts')],
|
|
sourcemap: production ? 'none' : 'inline',
|
|
define: {
|
|
global: 'window',
|
|
GAME: `"${game}"`,
|
|
GAMES: JSON.stringify(await getGames()),
|
|
},
|
|
plugins: [
|
|
imagePlugin,
|
|
audioPlugin,
|
|
fontPlugin,
|
|
wasmPlugin({ production }),
|
|
filePlugin,
|
|
]
|
|
});
|
|
}
|
|
|
|
export async function buildHTML(game: string, { production = false, mobile = false, local = false }: Args = {}) {
|
|
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');
|
|
|
|
const html = await Bun.file(path.resolve(assetsDir, local ? 'index-local.html' : 'index.html')).text();
|
|
const bundle = await buildBundle(game, production);
|
|
|
|
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 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 { }
|
|
}
|
|
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>`;
|
|
}
|
|
|
|
// 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);
|
|
|
|
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;
|
|
}
|
|
} |