Compare commits

...
9 Commits
Author SHA1 Message Date
valknar 5423ca70f4 Fix stale/mismatched engine cache causing "ASM_CONSTS[...] is not a function" live
The service worker's engine cache key relied on a manual CACHE_VERSION
bump that was forgotten across the last two @valknar/vpinball-wasm
version bumps, so returning visitors kept getting served old cached
vpinball.js/.wasm/.data via cache-first with no revalidation — the origin
server itself was serving a correct, matching 0.3.4 set the whole time
(verified via headers/sizes against the local build).

Split CACHE_VERSION into a manually-bumped SHELL_VERSION and an
ENGINE_VERSION that copy-engine-assets.mjs now writes automatically from
the installed engine's package.json on every build, so the engine cache
always busts in lockstep with the dependency version — no step to forget.
2026-08-24 14:42:38 +02:00
valknar 89d4d9b071 Bump @valknar/vpinball-wasm to 0.3.4
0.3.4 skips SDL_SetWindowIcon on the Emscripten target, so the engine no
longer hijacks the browser tab's favicon (and title) via a blob: URL
when a table loads — verified live, no app-side workaround needed.
2026-08-24 14:08:19 +02:00
valknar a2b929dbec Revert "Stop the pinball engine from hijacking the browser tab's favicon"
This reverts commit 4dd6d60457.
2026-08-24 13:52:38 +02:00
valknar 4dd6d60457 Stop the pinball engine from hijacking the browser tab's favicon
vpinball.js sets a table-supplied image as the page favicon via a blob:
URL as a table loads. Revert any blob: href on the icon <link> the
moment it appears, via a MutationObserver, instead of patching the
compiled engine.
2026-08-24 13:46:26 +02:00
valknar 0afa61cc5c Use proper SVG icons in the touch control bar, center the middle cluster absolutely
Swaps the unicode glyphs (◀ ▶ ⬆) for hand-drawn stroke/fill SVG icons
(chevrons, an eject-style plunger glyph, a coin glyph, a play triangle),
and positions the coin/start/plunger cluster with absolute + translate
so it sits on the bar's true center regardless of the flipper buttons.
2026-08-24 13:28:45 +02:00
valknar 9e8f08ba16 Replace mobile touch overlay with a coin-door-styled bottom control bar
The engine's default attachTouchControls() overlay was generic floating
circles; this swaps in an app-themed bottom bar (left/right flipper,
plunger, insert coin, start game) anchored to the screen edges instead.
2026-08-24 13:24:47 +02:00
valknar a193bdb3da fix(sw): stop precaching /404.html, it 403s and breaks the whole addAll
nginx serves /404.html as internal-only (see docker/nginx.conf) so it
preserves the real 404 status via error_page - any direct client fetch,
including the service worker's install-time addAll, gets a 403. Since
addAll is atomic, that one bad URL was failing precache of the entire
app shell.
2026-08-24 12:42:49 +02:00
valknarandClaude Sonnet 5 44504c718d Fix eject hanging the tab on ROM-based tables
Bump @valknar/vpinball-wasm to 0.3.3, which fixes a genuine infinite
busy-wait in the PinMAME plugin's Controller::Stop() under Emscripten
(no OS thread ever clears the flag it spins on) — this fired from
dispose()'s synchronous script Exit event on any ROM-based table,
hanging the tab permanently.

Sequence unmount cleanup as stop() then two animation frames then
dispose(), instead of dispose() alone, matching the consumer pattern
the engine fix expects (stop() only takes effect on its next internal
step, so disposing immediately after can still race it).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 10:27:35 +02:00
valknarandClaude Sonnet 5 53c8ea03ac Show a spinner while the table is starting
instance.start() runs synchronously and blocks the main thread for a
noticeable moment on heavy WASM init, so pressing "Tap to Start" would
freeze on the old screen with no feedback until the HUD suddenly
appeared. Add a "starting" status between "ready" and "running", and
defer the actual start() call by a nested rAF so the browser gets a
chance to paint the spinner before the blocking call runs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 09:52:30 +02:00
11 changed files with 241 additions and 73 deletions
+2 -5
View File
@@ -64,11 +64,8 @@ export default function CoinDoorHud({ instanceRef, tableInfo, loadTimeMs }: Coin
}; };
const handleEject = () => { const handleEject = () => {
// dispose() (called by usePinballInstance's unmount cleanup, triggered by // usePinballInstance's unmount cleanup (triggered by this navigation)
// this navigation) tears the instance down immediately. Calling stop() // handles stop()+dispose() itself, sequenced to avoid hanging the tab.
// here too raced it — stop() defers teardown to the engine's next
// internal step, and dispose() freeing WASM memory before that step runs
// hung the tab.
router.push("/"); router.push("/");
}; };
+159
View File
@@ -0,0 +1,159 @@
"use client";
import { useCallback, useState } from "react";
interface KeyBinding {
key: string;
code: string;
}
const LEFT_FLIPPER: KeyBinding = { key: "Shift", code: "ShiftLeft" };
const RIGHT_FLIPPER: KeyBinding = { key: "Shift", code: "ShiftRight" };
const PLUNGER: KeyBinding = { key: "Enter", code: "Enter" };
const INSERT_COIN: KeyBinding = { key: "4", code: "Digit4" };
const START_GAME: KeyBinding = { key: "1", code: "Digit1" };
function dispatchKey(type: "keydown" | "keyup", binding: KeyBinding) {
// SDL3's Emscripten video backend listens on the default "#window" target,
// so a real KeyboardEvent dispatched on window reaches the engine the same
// way a physical keypress would (see @valknar/vpinball-wasm's own
// touch-controls.js, which this bar replaces with app-styled UI).
window.dispatchEvent(new KeyboardEvent(type, { key: binding.key, code: binding.code, bubbles: true }));
}
function isTouchDevice(): boolean {
return typeof window !== "undefined" && ("ontouchstart" in window || navigator.maxTouchPoints > 0);
}
function FlipperLeftIcon() {
return (
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" strokeWidth="3">
<polyline points="15 5 8 12 15 19" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function FlipperRightIcon() {
return (
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" strokeWidth="3">
<polyline points="9 5 16 12 9 19" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
/** Eject-style glyph (triangle over a bar) for the plunger/launch control. */
function PlungerIcon() {
return (
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M12 4.5 19 15H5l7-10.5Z" />
<rect x="5" y="17.5" width="14" height="2.5" rx="1" />
</svg>
);
}
function CoinIcon() {
return (
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.75">
<circle cx="12" cy="12" r="8.5" />
<path
d="M14.5 9.3c-.4-.8-1.3-1.3-2.5-1.3-1.7 0-3 .9-3 2.1 0 2.9 5.5 1.3 5.5 4.1 0 1.2-1.3 2.1-3 2.1-1.2 0-2.1-.5-2.5-1.3M12 6.8v1.2M12 16v1.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function PlayIcon() {
return (
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M7 4.5v15l13-7.5-13-7.5Z" />
</svg>
);
}
function ControlButton({
binding,
label,
icon,
size,
}: {
binding: KeyBinding;
label: string;
icon: React.ReactNode;
size: "lg" | "md" | "sm";
}) {
const [pressed, setPressed] = useState(false);
const onDown = useCallback(
(e: React.PointerEvent<HTMLButtonElement>) => {
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
setPressed(true);
dispatchKey("keydown", binding);
},
[binding],
);
const onUp = useCallback(() => {
setPressed(false);
dispatchKey("keyup", binding);
}, [binding]);
const sizeClasses = size === "lg" ? "h-20 w-20" : size === "md" ? "h-14 w-14" : "h-12 w-12";
return (
<button
type="button"
aria-label={label}
onPointerDown={onDown}
onPointerUp={onUp}
onPointerCancel={onUp}
onContextMenu={(e) => e.preventDefault()}
className={`flex touch-none select-none items-center justify-center rounded-full border-2 transition ${sizeClasses} ${
pressed
? "border-marquee bg-marquee/30 text-marquee shadow-[0_0_16px_rgba(255,122,41,0.5)]"
: "border-chrome/40 bg-black/40 text-chrome"
}`}
>
{icon}
</button>
);
}
export interface TouchControlBarProps {
active: boolean;
}
/**
* Coin-door-styled bottom control bar for mobile play. Replaces
* @valknar/vpinball-wasm's default floating overlay buttons with UI that
* matches the cabinet look and puts the flippers at the screen's bottom
* corners, in thumb reach when holding the device landscape.
*/
export default function TouchControlBar({ active }: TouchControlBarProps) {
// Computed once at mount (matches usePinballInstance's hasWebGL2 pattern) —
// touch support doesn't change over a session, so this doesn't need an
// effect/listener, just a lazy initial state.
const [isTouch] = useState(isTouchDevice);
if (!active || !isTouch) return null;
return (
<div
data-hud
className="absolute inset-x-0 bottom-0 z-10 flex items-center justify-between border-t border-chrome/30 bg-ash/90 px-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3 backdrop-blur sm:hidden"
>
<ControlButton binding={LEFT_FLIPPER} label="Left flipper" icon={<FlipperLeftIcon />} size="lg" />
{/* Positioned absolutely so it sits on the bar's true center, independent
of the (equal-width, so already symmetric) flipper buttons on either side. */}
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center gap-3">
<ControlButton binding={INSERT_COIN} label="Insert coin" icon={<CoinIcon />} size="sm" />
<ControlButton binding={START_GAME} label="Start game" icon={<PlayIcon />} size="sm" />
<ControlButton binding={PLUNGER} label="Plunger" icon={<PlungerIcon />} size="md" />
</div>
<ControlButton binding={RIGHT_FLIPPER} label="Right flipper" icon={<FlipperRightIcon />} size="lg" />
</div>
);
}
+11 -4
View File
@@ -2,8 +2,8 @@
import { useRef } from "react"; import { useRef } from "react";
import { usePinballInstance, type RomSelection } from "@/lib/pinball/usePinballInstance"; import { usePinballInstance, type RomSelection } from "@/lib/pinball/usePinballInstance";
import { useTouchControls } from "@/lib/pinball/useTouchControls";
import CoinDoorHud from "@/components/hud/CoinDoorHud"; import CoinDoorHud from "@/components/hud/CoinDoorHud";
import TouchControlBar from "@/components/hud/TouchControlBar";
import type { TableInfo } from "@/lib/pinball/types"; import type { TableInfo } from "@/lib/pinball/types";
export interface PinballCanvasImplProps { export interface PinballCanvasImplProps {
@@ -14,16 +14,14 @@ export interface PinballCanvasImplProps {
export default function PinballCanvasImpl({ tableData, rom, tableInfo }: PinballCanvasImplProps) { export default function PinballCanvasImpl({ tableData, rom, tableInfo }: PinballCanvasImplProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null); const canvasRef = useRef<HTMLCanvasElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const { status, progress, error, loadTimeMs, instanceRef, start } = usePinballInstance( const { status, progress, error, loadTimeMs, instanceRef, start } = usePinballInstance(
canvasRef, canvasRef,
tableData, tableData,
rom, rom,
); );
useTouchControls(containerRef, status === "running");
return ( return (
<div ref={containerRef} className="relative h-full w-full overflow-hidden bg-black"> <div className="relative h-full w-full overflow-hidden bg-black">
{/* SDL3's Emscripten backend resolves its own window/GL-context target via {/* SDL3's Emscripten backend resolves its own window/GL-context target via
document.querySelector("#canvas") independently of Module.canvas — the document.querySelector("#canvas") independently of Module.canvas — the
id is required or context creation silently no-ops (findCanvasEventTarget id is required or context creation silently no-ops (findCanvasEventTarget
@@ -35,6 +33,8 @@ export default function PinballCanvasImpl({ tableData, rom, tableInfo }: Pinball
<CoinDoorHud instanceRef={instanceRef} tableInfo={tableInfo} loadTimeMs={loadTimeMs} /> <CoinDoorHud instanceRef={instanceRef} tableInfo={tableInfo} loadTimeMs={loadTimeMs} />
)} )}
<TouchControlBar active={status === "running"} />
{status === "loading" && ( {status === "loading" && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-ink/90 text-paper"> <div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-ink/90 text-paper">
<div className="font-display text-2xl tracking-wide text-marquee">LOADING TABLE</div> <div className="font-display text-2xl tracking-wide text-marquee">LOADING TABLE</div>
@@ -74,6 +74,13 @@ export default function PinballCanvasImpl({ tableData, rom, tableInfo }: Pinball
</p> </p>
</div> </div>
)} )}
{status === "starting" && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-ink/90 text-paper">
<div className="h-12 w-12 animate-spin rounded-full border-4 border-chrome/20 border-t-marquee" />
<div className="font-display text-2xl tracking-wide text-marquee">STARTING TABLE</div>
</div>
)}
</div> </div>
); );
} }
+24 -2
View File
@@ -10,7 +10,7 @@ import { hasWebGL2 } from "./webglSupport";
// outside webpack's module graph (see scripts/copy-engine-assets.mjs). // outside webpack's module graph (see scripts/copy-engine-assets.mjs).
const ENGINE_BASE_URL = "/vendor/vpinball-wasm"; const ENGINE_BASE_URL = "/vendor/vpinball-wasm";
export type PinballStatus = "idle" | "loading" | "ready" | "running" | "error"; export type PinballStatus = "idle" | "loading" | "ready" | "starting" | "running" | "error";
interface EngineModule { interface EngineModule {
loadPinball(options: LoadPinballOptions): Promise<PinballInstance>; loadPinball(options: LoadPinballOptions): Promise<PinballInstance>;
@@ -104,8 +104,20 @@ export function usePinballInstance(
return () => { return () => {
disposedRef.current = true; disposedRef.current = true;
instanceRef.current?.dispose(); const instance = instanceRef.current;
instanceRef.current = null; instanceRef.current = null;
if (instance) {
// stop() halts the engine's render loop, but only takes effect on
// its next internal step rather than immediately. Calling dispose()
// synchronously right after — freeing WASM memory before that step
// runs — is what hangs the tab. Give it a couple of frames first.
instance.stop();
requestAnimationFrame(() => {
requestAnimationFrame(() => {
instance.dispose();
});
});
}
}; };
// Intentionally run once: PlayView mounts a fresh PinballCanvasImpl // Intentionally run once: PlayView mounts a fresh PinballCanvasImpl
// (via `key`) per table rather than swapping tableData in place. // (via `key`) per table rather than swapping tableData in place.
@@ -114,8 +126,18 @@ export function usePinballInstance(
const start = useCallback(() => { const start = useCallback(() => {
if (!instanceRef.current) return; if (!instanceRef.current) return;
setStatus("starting");
// instance.start() runs synchronously and blocks the main thread for a
// noticeable moment (heavy WASM init) — a nested rAF lets the browser
// paint the "starting" spinner from the state update above before that
// block hits, instead of the UI freezing on the old screen mid-click.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (disposedRef.current || !instanceRef.current) return;
instanceRef.current.start(); instanceRef.current.start();
setStatus("running"); setStatus("running");
});
});
}, []); }, []);
return { status, progress, error, loadTimeMs, instanceRef, start }; return { status, progress, error, loadTimeMs, instanceRef, start };
-38
View File
@@ -1,38 +0,0 @@
"use client";
import { useEffect } from "react";
import type { attachTouchControls as AttachTouchControls } from "@valknar/vpinball-wasm";
const ENGINE_BASE_URL = "/vendor/vpinball-wasm";
function isTouchDevice(): boolean {
return typeof window !== "undefined" && ("ontouchstart" in window || navigator.maxTouchPoints > 0);
}
/** Attaches the engine's on-screen touch overlay to `containerRef` while `active` is true. */
export function useTouchControls(
containerRef: React.RefObject<HTMLElement | null>,
active: boolean,
): void {
useEffect(() => {
if (!active || !isTouchDevice()) return;
const container = containerRef.current;
if (!container) return;
let handle: { detach: () => void } | undefined;
let cancelled = false;
(async () => {
const { attachTouchControls } = (await import(
/* webpackIgnore: true */ `${ENGINE_BASE_URL}/index.js`
)) as { attachTouchControls: typeof AttachTouchControls };
if (cancelled) return;
handle = attachTouchControls({ container });
})();
return () => {
cancelled = true;
handle?.detach();
};
}, [containerRef, active]);
}
+1 -1
View File
@@ -12,7 +12,7 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@valknar/vpinball-wasm": "0.3.2", "@valknar/vpinball-wasm": "0.3.4",
"next": "16.3.2", "next": "16.3.2",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8"
+5 -5
View File
@@ -9,8 +9,8 @@ importers:
.: .:
dependencies: dependencies:
'@valknar/vpinball-wasm': '@valknar/vpinball-wasm':
specifier: 0.3.2 specifier: 0.3.4
version: 0.3.2 version: 0.3.4
next: next:
specifier: 16.3.2 specifier: 16.3.2
version: 16.3.2(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@20.19.43)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) version: 16.3.2(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@20.19.43)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -754,8 +754,8 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@valknar/vpinball-wasm@0.3.2': '@valknar/vpinball-wasm@0.3.4':
resolution: {integrity: sha512-52vkjVzev92khiO8a50N2afYIviX1hHCMzTilN24Pe/7pu5m9LlSQVS3o11rRdItSF5h1RVmTLpoh1ENYrLQJw==, tarball: https://dev.pivoine.art/api/packages/valknar/npm/%40valknar%2Fvpinball-wasm/-/0.3.2/vpinball-wasm-0.3.2.tgz} resolution: {integrity: sha512-ULJ8jF3Q8sxmkkSdC70SSKzkvaNHORPZEFuJzQbmfH9aeqh4kYmqj59KX7bNywmAxLz4yLtr8r4i/oWEo8bbSw==, tarball: https://dev.pivoine.art/api/packages/valknar/npm/%40valknar%2Fvpinball-wasm/-/0.3.4/vpinball-wasm-0.3.4.tgz}
acorn-jsx@5.3.2: acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
@@ -2645,7 +2645,7 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.12.2': '@unrs/resolver-binding-win32-x64-msvc@1.12.2':
optional: true optional: true
'@valknar/vpinball-wasm@0.3.2': {} '@valknar/vpinball-wasm@0.3.4': {}
acorn-jsx@5.3.2(acorn@8.18.0): acorn-jsx@5.3.2(acorn@8.18.0):
dependencies: dependencies:
+1 -1
View File
@@ -2,5 +2,5 @@ allowBuilds:
sharp: false sharp: false
unrs-resolver: false unrs-resolver: false
minimumReleaseAgeExclude: minimumReleaseAgeExclude:
- '@valknar/vpinball-wasm@0.2.0 || 0.3.1 || 0.3.2' - '@valknar/vpinball-wasm@0.2.0 || 0.3.1 || 0.3.2 || 0.3.3 || 0.3.4'
updateNotifier: false updateNotifier: false
+12 -6
View File
@@ -1,14 +1,20 @@
// Bump this on every release that changes the app shell or the vendored // Bump manually (via `pnpm sw:bump`) whenever SHELL_ASSETS or the shell's
// engine build — it's the only thing that invalidates old caches, since // caching behavior changes — nothing else invalidates the shell cache, since
// none of the cached URLs below are content-hashed by us. // none of the cached URLs below are content-hashed by us.
const CACHE_VERSION = "v1"; const SHELL_VERSION = "v2";
const SHELL_CACHE = `shell-${CACHE_VERSION}`; // Written automatically by scripts/copy-engine-assets.mjs from the installed
const ENGINE_CACHE = `engine-${CACHE_VERSION}`; // @valknar/vpinball-wasm version — do not edit by hand. Ties the engine
// cache key to the actual engine build, so a version bump always busts
// stale caches instead of relying on a human to remember a separate step
// (which is exactly how returning visitors ended up on a stale, and
// possibly internally inconsistent, engine bundle before this existed).
const ENGINE_VERSION = "0.3.4";
const SHELL_CACHE = `shell-${SHELL_VERSION}`;
const ENGINE_CACHE = `engine-${ENGINE_VERSION}`;
const SHELL_ASSETS = [ const SHELL_ASSETS = [
"/", "/",
"/play/", "/play/",
"/404.html",
"/manifest.webmanifest", "/manifest.webmanifest",
"/icons/icon-192.png", "/icons/icon-192.png",
"/icons/icon-512.png", "/icons/icon-512.png",
+8 -7
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env node #!/usr/bin/env node
// Bumps public/sw.js's CACHE_VERSION so a deploy invalidates old caches — // Bumps public/sw.js's SHELL_VERSION so a deploy invalidates old shell
// run this before cutting a release that changes the app shell or updates // caches — run this before cutting a release that changes SHELL_ASSETS or
// the vendored vpinball-wasm engine build. // the shell's caching behavior. (The engine cache busts itself automatically
// from the installed vpinball-wasm version — see copy-engine-assets.mjs.)
import { readFile, writeFile } from "node:fs/promises"; import { readFile, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@@ -10,12 +11,12 @@ const rootDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const swPath = path.join(rootDir, "public", "sw.js"); const swPath = path.join(rootDir, "public", "sw.js");
const contents = await readFile(swPath, "utf8"); const contents = await readFile(swPath, "utf8");
const match = contents.match(/CACHE_VERSION = "v(\d+)"/); const match = contents.match(/SHELL_VERSION = "v(\d+)"/);
if (!match) { if (!match) {
console.error(`Couldn't find CACHE_VERSION in ${swPath}`); console.error(`Couldn't find SHELL_VERSION in ${swPath}`);
process.exit(1); process.exit(1);
} }
const next = Number(match[1]) + 1; const next = Number(match[1]) + 1;
const updated = contents.replace(/CACHE_VERSION = "v\d+"/, `CACHE_VERSION = "v${next}"`); const updated = contents.replace(/SHELL_VERSION = "v\d+"/, `SHELL_VERSION = "v${next}"`);
await writeFile(swPath, updated); await writeFile(swPath, updated);
console.log(`Bumped service worker CACHE_VERSION to v${next}`); console.log(`Bumped service worker SHELL_VERSION to v${next}`);
+16 -2
View File
@@ -3,13 +3,14 @@
// so it's served as plain static files (see lib/pinball/usePinballInstance.ts for why: // so it's served as plain static files (see lib/pinball/usePinballInstance.ts for why:
// the package's own loadPinball() does a dynamic import() of its glue script that must // the package's own loadPinball() does a dynamic import() of its glue script that must
// never pass through webpack's module graph). // never pass through webpack's module graph).
import { cp, mkdir, rm } from "node:fs/promises"; import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
const rootDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))); const rootDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const pkgDistDir = path.join(rootDir, "node_modules", "@valknar", "vpinball-wasm", "dist"); const pkgDir = path.join(rootDir, "node_modules", "@valknar", "vpinball-wasm");
const pkgDistDir = path.join(pkgDir, "dist");
const targetDir = path.join(rootDir, "public", "vendor", "vpinball-wasm"); const targetDir = path.join(rootDir, "public", "vendor", "vpinball-wasm");
if (!existsSync(pkgDistDir)) { if (!existsSync(pkgDistDir)) {
@@ -24,3 +25,16 @@ await mkdir(targetDir, { recursive: true });
await cp(pkgDistDir, targetDir, { recursive: true }); await cp(pkgDistDir, targetDir, { recursive: true });
console.log(`Copied vpinball-wasm engine assets to ${path.relative(rootDir, targetDir)}/`); console.log(`Copied vpinball-wasm engine assets to ${path.relative(rootDir, targetDir)}/`);
// Keep the service worker's engine cache key in lockstep with the installed
// engine version, so a version bump always busts stale caches for returning
// visitors — see sw.js's ENGINE_VERSION comment for why this must not be a
// manual step.
const { version: engineVersion } = JSON.parse(await readFile(path.join(pkgDir, "package.json"), "utf8"));
const swPath = path.join(rootDir, "public", "sw.js");
const swContents = await readFile(swPath, "utf8");
const updatedSw = swContents.replace(/ENGINE_VERSION = "[^"]*"/, `ENGINE_VERSION = "${engineVersion}"`);
if (updatedSw !== swContents) {
await writeFile(swPath, updatedSw);
console.log(`Set sw.js ENGINE_VERSION to ${engineVersion}`);
}