Files
server/src/main/resources/static/js/broadcast/script-worker.js
2026-02-10 13:44:21 +01:00

473 lines
15 KiB
JavaScript

const scripts = new Map();
const allowedFetchUrls = new Set();
let channelName = "";
let tickIntervalId = null;
let lastTick = 0;
let startTime = 0;
const tickIntervalMs = 1000 / 60;
const errorKeys = new Set();
const allowedImportUrls = new Set();
const nativeImportScripts = typeof self.importScripts === "function" ? self.importScripts.bind(self) : null;
const sharedDependencyUrls = ["/js/vendor/three.min.js", "/js/vendor/GLTFLoader.js", "/js/vendor/OBJLoader.js"];
const nativeFetch = typeof self.fetch === "function" ? self.fetch.bind(self) : null;
let activeScriptId = null;
let chatMessages = [];
let emoteCatalog = [];
function normalizeUrl(url) {
try {
return new URL(url, self.location?.href || "http://localhost").toString();
} catch (_error) {
return "";
}
}
function registerAllowedImport(url) {
const normalized = normalizeUrl(url);
if (normalized) {
allowedImportUrls.add(normalized);
}
}
sharedDependencyUrls.forEach(registerAllowedImport);
function importAllowedScripts(...urls) {
if (!nativeImportScripts) {
throw new Error("Network access is disabled in asset scripts.");
}
const resolved = urls.map((url) => normalizeUrl(url));
if (resolved.some((url) => !allowedImportUrls.has(url))) {
throw new Error("Network access is disabled in asset scripts.");
}
return nativeImportScripts(...resolved);
}
function loadSharedDependencies() {
if (!nativeImportScripts || sharedDependencyUrls.length === 0) {
return;
}
importAllowedScripts(...sharedDependencyUrls);
}
loadSharedDependencies();
function sanitizeAllowedDomains(domains) {
if (!Array.isArray(domains)) {
return [];
}
const normalized = [];
domains.forEach((raw) => {
const candidate = typeof raw === "string" ? raw.trim() : "";
if (!candidate) {
return;
}
const withScheme = candidate.includes("://") ? candidate : `https://${candidate}`;
try {
const url = new URL(withScheme, self.location?.href || "http://localhost");
if (!url.hostname) {
return;
}
const host = url.hostname.toLowerCase();
const port = url.port ? `:${url.port}` : "";
const value = `${host}${port}`;
if (!normalized.includes(value) && normalized.length < 32) {
normalized.push(value);
}
} catch (_error) {
// ignore invalid domains from metadata/user input
}
});
return normalized;
}
function extractUrlFromInput(input) {
if (typeof input === "string") {
return input;
}
if (input && typeof input.url === "string") {
return input.url;
}
return "";
}
function extractDomain(url) {
try {
return new URL(url, self.location?.href || "http://localhost").host.toLowerCase();
} catch (_error) {
return "";
}
}
function isSameOrigin(url) {
if (!self.location?.origin) {
return false;
}
try {
return new URL(url, self.location.origin).origin === self.location.origin;
} catch (_error) {
return false;
}
}
function domainMatches(domain, allowed) {
if (!domain || !allowed) {
return false;
}
if (allowed.includes(":")) {
return domain === allowed;
}
return domain === allowed || domain.endsWith(`.${allowed}`);
}
function isFetchAllowedForScript(script, targetUrl) {
const normalized = normalizeUrl(targetUrl);
if (!normalized) {
return { allowed: false, reason: "Invalid or empty URL" };
}
if (isSameOrigin(normalized) || allowedFetchUrls.has(normalized)) {
return { allowed: true, normalized };
}
const domain = extractDomain(normalized);
if (!domain) {
return { allowed: false, reason: "Invalid URL" };
}
const allowedDomains = Array.isArray(script.allowedDomains) ? script.allowedDomains : [];
const allowed = allowedDomains.some((value) => domainMatches(domain, value));
return allowed
? { allowed: true, normalized }
: { allowed: false, reason: `Domain ${domain} is not in the allowed list.`, normalized };
}
function createScriptFetch(script) {
return async function scriptFetch(input, init) {
const targetUrl = extractUrlFromInput(input);
const decision = isFetchAllowedForScript(script, targetUrl);
console.info(
`Script ${script.id} fetch attempt`,
JSON.stringify({
url: targetUrl || "",
normalized: decision.normalized || "",
allowedDomains: script.allowedDomains,
allowed: decision.allowed,
reason: decision.reason,
})
);
if (!decision.allowed) {
const message = `Fetch blocked for script ${script.id}: ${decision.reason}`;
const error = new Error(message);
console.error(message);
reportScriptError(script.id, "fetch", error);
return Promise.reject(error);
}
if (!nativeFetch) {
const error = new Error("Fetch is unavailable in this environment.");
reportScriptError(script.id, "fetch", error);
return Promise.reject(error);
}
try {
return await nativeFetch(input, init);
} catch (error) {
console.error(`Fetch failed for script ${script.id} (${targetUrl || "<unknown>"})`, error);
reportScriptError(script.id, "fetch", error);
throw error;
}
};
}
function fetchForActiveScript(input, init) {
const script = activeScriptId ? scripts.get(activeScriptId) : null;
if (!script) {
const error = new Error("Fetch is only available inside a running script context.");
console.error(error.message);
return Promise.reject(error);
}
if (!script.context.fetch) {
script.context.fetch = createScriptFetch(script);
}
return script.context.fetch(input, init);
}
if (nativeFetch) {
self.fetch = function sandboxedFetch(input, init) {
return fetchForActiveScript(input, init);
};
}
function refreshAllowedFetchUrls() {
allowedFetchUrls.clear();
scripts.forEach((script) => {
const assets = script?.context?.assets;
if (!Array.isArray(assets)) {
return;
}
assets.forEach((asset) => {
if (asset?.url) {
const normalized = normalizeUrl(asset.url);
if (normalized) {
allowedFetchUrls.add(normalized);
}
}
});
});
if (Array.isArray(emoteCatalog)) {
emoteCatalog.forEach((emote) => {
if (emote?.url) {
const normalized = normalizeUrl(emote.url);
if (normalized) {
allowedFetchUrls.add(normalized);
}
}
});
}
if (Array.isArray(chatMessages)) {
chatMessages.forEach((message) => {
const fragments = message?.fragments;
if (!Array.isArray(fragments)) {
return;
}
fragments.forEach((fragment) => {
if (fragment?.url) {
const normalized = normalizeUrl(fragment.url);
if (normalized) {
allowedFetchUrls.add(normalized);
}
}
});
});
}
}
function reportScriptError(id, stage, error) {
if (!id) {
return;
}
const key = `${id}:${stage}:${error?.message ?? error}`;
if (errorKeys.has(key)) {
return;
}
errorKeys.add(key);
self.postMessage({
type: "scriptError",
payload: {
id,
stage,
message: error?.message ?? String(error),
stack: error?.stack || "",
},
});
}
function updateScriptContexts() {
scripts.forEach((script) => {
if (!script.context) {
return;
}
script.context.canvas = script.canvas;
script.context.ctx = script.ctx;
script.context.channelName = channelName;
script.context.width = script.canvas?.width ?? 0;
script.context.height = script.canvas?.height ?? 0;
script.context.chatMessages = chatMessages;
script.context.emoteCatalog = emoteCatalog;
script.context.allowedDomains = script.allowedDomains;
});
}
function ensureTickLoop() {
if (tickIntervalId) {
return;
}
startTime = performance.now();
lastTick = startTime;
tickIntervalId = setInterval(() => {
if (scripts.size === 0) {
return;
}
const now = performance.now();
const deltaMs = now - lastTick;
const elapsedMs = now - startTime;
lastTick = now;
scripts.forEach((script) => {
if (!script.tick || !script.ctx) {
return;
}
script.context.now = now;
script.context.deltaMs = deltaMs;
script.context.elapsedMs = elapsedMs;
try {
activeScriptId = script.id;
script.tick(script.context, script.state);
} catch (error) {
console.error(`Script ${script.id} tick failed`, error);
} finally {
activeScriptId = null;
}
});
}, tickIntervalMs);
}
function stopTickLoopIfIdle() {
if (scripts.size === 0 && tickIntervalId) {
clearInterval(tickIntervalId);
tickIntervalId = null;
}
}
function createScriptHandlers(source, context, state, sourceLabel = "") {
const contextPrelude =
"const { canvas, ctx, channelName, width, height, now, deltaMs, elapsedMs, assets, chatMessages, emoteCatalog, playAudio, fetch, allowedDomains } = context;";
const sourceUrl = sourceLabel ? `\n//# sourceURL=${sourceLabel}` : "";
const factory = new Function(
"context",
"state",
"module",
"exports",
`${contextPrelude}\n${source}${sourceUrl}\nconst resolved = (module && module.exports) || exports || {};\nreturn {\n init: typeof resolved.init === "function" ? resolved.init : typeof init === "function" ? init : null,\n tick: typeof resolved.tick === "function" ? resolved.tick : typeof tick === "function" ? tick : null,\n};`,
);
const module = { exports: {} };
const exports = module.exports;
return factory(context, state, module, exports);
}
// TODO: Code smell Worker command handling relies on a long conditional dispatcher that is hard to maintain.
self.addEventListener("message", (event) => {
const { type, payload } = event.data || {};
if (type === "init") {
channelName = payload.channelName || "";
updateScriptContexts();
return;
}
if (type === "resize") {
scripts.forEach((script) => {
if (!script.canvas) {
return;
}
script.canvas.width = payload.width || script.canvas.width;
script.canvas.height = payload.height || script.canvas.height;
});
updateScriptContexts();
return;
}
if (type === "channel") {
channelName = payload.channelName || channelName;
updateScriptContexts();
return;
}
if (type === "addScript") {
if (!payload?.id || !payload?.source || !payload?.canvas) {
return;
}
const allowedDomains = sanitizeAllowedDomains(payload.allowedDomains);
const canvas = payload.canvas;
canvas.width = payload.width || canvas.width;
canvas.height = payload.height || canvas.height;
const ctx = canvas.getContext("2d");
const state = {};
const context = {
canvas,
ctx,
channelName,
width: canvas.width ?? 0,
height: canvas.height ?? 0,
now: 0,
deltaMs: 0,
elapsedMs: 0,
assets: Array.isArray(payload.attachments) ? payload.attachments : [],
chatMessages,
emoteCatalog,
allowedDomains,
playAudio: (attachment) => {
const attachmentId = typeof attachment === "string" ? attachment : attachment?.id;
if (!attachmentId) {
return;
}
self.postMessage({
type: "scriptAudio",
payload: {
scriptId: payload.id,
attachmentId,
},
});
},
};
const script = {
id: payload.id,
allowedDomains,
canvas,
ctx,
context,
state,
init: null,
tick: null,
};
context.fetch = createScriptFetch(script);
let handlers = {};
try {
activeScriptId = payload.id;
handlers = createScriptHandlers(payload.source, context, state, `user-script-${payload.id}.js`);
} catch (error) {
console.error(`Script ${payload.id} failed to initialize`, error);
reportScriptError(payload.id, "initialize", error);
return;
} finally {
activeScriptId = null;
}
script.init = handlers.init;
script.tick = handlers.tick;
scripts.set(payload.id, script);
refreshAllowedFetchUrls();
if (script.init) {
try {
activeScriptId = script.id;
script.init(script.context, script.state);
} catch (error) {
console.error(`Script ${payload.id} init failed`, error);
reportScriptError(payload.id, "init", error);
} finally {
activeScriptId = null;
}
}
ensureTickLoop();
return;
}
if (type === "removeScript") {
if (!payload?.id) {
return;
}
scripts.delete(payload.id);
refreshAllowedFetchUrls();
stopTickLoopIfIdle();
}
if (type === "updateAttachments") {
if (!payload?.id) {
return;
}
const script = scripts.get(payload.id);
if (!script) {
return;
}
script.context.assets = Array.isArray(payload.attachments) ? payload.attachments : [];
script.allowedDomains = sanitizeAllowedDomains(payload.allowedDomains);
script.context.allowedDomains = script.allowedDomains;
refreshAllowedFetchUrls();
}
if (type === "chatMessages") {
chatMessages = Array.isArray(payload?.messages) ? payload.messages : [];
refreshAllowedFetchUrls();
updateScriptContexts();
}
if (type === "emoteCatalog") {
emoteCatalog = Array.isArray(payload?.emotes) ? payload.emotes : [];
refreshAllowedFetchUrls();
updateScriptContexts();
}
});