Worker init (#3850)

## Description:

- Inline the game worker into the main bundle (`?worker&inline`) instead
of loading it from the CDN via a same-origin Blob trampoline.
- Some users were failing to load the worker — most likely a
cross-origin / CDN edge case the trampoline didn't paper over. Inlining
serves the worker as a same-origin Blob from the main bundle, bypassing
the cross-origin `new Worker(url)` restriction entirely.
- Removes `createGameWorker()`, the trampoline script, the
`trampoline_error` message type, and the `onTrampolineError` listener in
`initialize()`. The 60s init timeout backstop stays.
- `cdnBase` is still forwarded in the `init` message — that's for
in-worker asset fetches (maps, etc.), unrelated to loading the worker
module itself.

Tradeoff: the worker bundles all of `src/core`, so the main JS download
grows by that amount on every page load (including lobby/settings pages
that never start a game). Accepted because load reliability > bundle
size here.

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

evan
This commit is contained in:
Evan
2026-05-05 13:22:58 -06:00
committed by GitHub
parent ffbe48ad10
commit af5249655c
2 changed files with 7 additions and 69 deletions
+5 -56
View File
@@ -13,45 +13,10 @@ import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates";
import { ClientID, GameStartInfo, Turn } from "../Schemas";
import { generateID } from "../Util";
import { WorkerMessage } from "./WorkerMessages";
// ?worker&url returns the worker bundle's URL as a string. We load it via a
// same-origin Blob trampoline because browsers refuse cross-origin
// `new Worker(url)` even with valid CORS+CORP. A Blob URL is same-origin to
// the page so the constructor accepts it, and dynamic `import()` inside the
// Blob IS CORS-checked and can fetch the real worker module from the CDN.
// R2 must serve the worker bundle with `Access-Control-Allow-Origin`.
import workerUrl from "./Worker.worker.ts?worker&url";
function createGameWorker(): Worker {
const cdnBase = getCdnBase().replace(/\/+$/, "");
// Same-origin path (dev, or any deploy without CDN_BASE set): construct the
// worker directly. The Blob trampoline below is only needed for cross-origin
// loads — browsers refuse `new Worker(url)` cross-origin even with valid
// CORS+CORP, and Vite's dev server doesn't serve `?worker&url` URLs as
// regular ES modules so the trampoline's dynamic `import()` would hang.
if (!cdnBase) {
return new Worker(workerUrl, { type: "module" });
}
const fullUrl = `${cdnBase}${workerUrl}`;
// Buffer-and-replay: the worker's port enables when the trampoline script
// starts, so any messages posted before the imported module attaches its
// `message` handler would dispatch to no listener and be dropped. Capture
// them here, then re-dispatch after the import resolves.
const trampoline = `
const buffered = [];
const buffer = (e) => buffered.push(e);
self.addEventListener("message", buffer);
import(${JSON.stringify(fullUrl)}).then(() => {
self.removeEventListener("message", buffer);
for (const e of buffered) self.dispatchEvent(new MessageEvent("message", { data: e.data }));
}).catch((e) => self.postMessage({ type: "trampoline_error", message: String((e && e.message) || e) }));
`;
const blobUrl = URL.createObjectURL(
new Blob([trampoline], { type: "application/javascript" }),
);
const worker = new Worker(blobUrl, { type: "module" });
URL.revokeObjectURL(blobUrl);
return worker;
}
// Inlined into the main bundle as a same-origin Blob, sidestepping the
// cross-origin `new Worker(url)` restriction that would otherwise apply when
// the worker bundle is served from the CDN.
import GameWorker from "./Worker.worker.ts?worker&inline";
export class WorkerClient {
private worker: Worker;
@@ -65,7 +30,7 @@ export class WorkerClient {
private gameStartInfo: GameStartInfo,
private clientID: ClientID | undefined,
) {
this.worker = createGameWorker();
this.worker = new GameWorker();
this.messageHandlers = new Map();
// Set up global message handler
@@ -112,21 +77,8 @@ export class WorkerClient {
return new Promise((resolve, reject) => {
const messageId = generateID();
const onTrampolineError = (event: MessageEvent) => {
if (event.data?.type !== "trampoline_error") return;
this.worker.removeEventListener("message", onTrampolineError);
this.messageHandlers.delete(messageId);
reject(
new Error(
`Worker trampoline import failed: ${event.data.message ?? "unknown error"}`,
),
);
};
this.worker.addEventListener("message", onTrampolineError);
this.messageHandlers.set(messageId, (message) => {
if (message.type === "initialized") {
this.worker.removeEventListener("message", onTrampolineError);
this.isInitialized = true;
resolve();
}
@@ -140,11 +92,8 @@ export class WorkerClient {
cdnBase: getCdnBase(),
});
// Backstop for the worker hanging after a successful import (the
// trampoline_error path handles the cross-origin / CORS load failure).
setTimeout(() => {
if (!this.isInitialized) {
this.worker.removeEventListener("message", onTrampolineError);
this.messageHandlers.delete(messageId);
reject(new Error("Worker initialization timeout"));
}
+2 -13
View File
@@ -28,8 +28,7 @@ export type WorkerMessageType =
| "attack_clustered_positions"
| "attack_clustered_positions_result"
| "transport_ship_spawn"
| "transport_ship_spawn_result"
| "trampoline_error";
| "transport_ship_spawn_result";
// Base interface for all messages
interface BaseWorkerMessage {
@@ -139,15 +138,6 @@ export interface TransportShipSpawnResultMessage extends BaseWorkerMessage {
result: TileRef | false;
}
// Posted by the Blob trampoline (see WorkerClient.createGameWorker) when the
// dynamic import of the real worker module fails. The real worker module
// never loaded, so no other message will ever arrive — initialize() must
// reject on this rather than wait out its timeout.
export interface TrampolineErrorMessage extends BaseWorkerMessage {
type: "trampoline_error";
message: string;
}
// Union types for type safety
export type MainThreadMessage =
| InitMessage
@@ -170,5 +160,4 @@ export type WorkerMessage =
| PlayerProfileResultMessage
| PlayerBorderTilesResultMessage
| AttackClusteredPositionsResultMessage
| TransportShipSpawnResultMessage
| TrampolineErrorMessage;
| TransportShipSpawnResultMessage;