bzl

self-hosted ephemeral community engine
Log | Files | Refs | README | LICENSE

commit a4a0abbef2aac426ab5162563020dfcf540d6a22
parent 666b227a62281f0ec872e0b848bbe87d1aa6535b
Author: SageAzakaela <106701693+SageAzakaela@users.noreply.github.com>
Date:   Sun, 22 Feb 2026 17:31:07 -0700

started adding godot html5 export embed panel - not done yet though

it doesn't work yet *boowomp*

Diffstat:
Mdocs/PLUGINS.md | 5+++++
Mpackage.json | 1+
Aplugins_dev/godot/client.js | 414+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aplugins_dev/godot/godotapp/index.html | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aplugins_dev/godot/plugin.json | 9+++++++++
Aplugins_dev/godot/server.js | 3+++
Mpublic/app.js | 30++++++++++++++++++++++++++++--
Ascripts/build-godot-plugin.js | 22++++++++++++++++++++++
Mserver.js | 37++++++++++++++++++++++++++-----------
9 files changed, 566 insertions(+), 13 deletions(-)

diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md @@ -14,6 +14,7 @@ Use this quick reference when packaging releases or deciding what to enable by d | `library` | Shelf/Reader workflow, text + PDF style content management, community borrowing/return flow | Any instance | Best used as persistent knowledge + story archive. | | `radio` | Community radio stations with uploaded tracks and shared listening | Any instance | Requires upload storage; tune in from panel UI. | | `dice` | Shared dice roller (`XdY+Z`) broadcast to connected users | Any instance | Lightweight RP/TTRPG utility. | +| `godot` | Template plugin for hosting a bundled Godot HTML5 export from `godotapp/` | Any instance | Put your export files in `plugins_dev/godot/godotapp/` before build. | | `directory-server` | Public directory endpoint + moderation queue for listings (`/api/plugins/directory-server/list`) | **Directory host instance only** | Acts as the central directory service. | | `directory-publisher` | Sends this instance announcement payloads to a directory server | Any instance that wants listing | Configure directory URL + instance URL/name, then publish. | @@ -43,6 +44,10 @@ This repo includes starter plugins and zip builders: - Dice: `plugins_dev/dice/` - Build: `node scripts/build-dice-plugin.js` - Upload: `dist/plugins/dice.zip` +- Godot: `plugins_dev/godot/` + - Build: `node scripts/build-godot-plugin.js` + - Upload: `dist/plugins/godot.zip` + - Bundle app: place your Godot HTML5 export in `plugins_dev/godot/godotapp/` (entry file must be `index.html`) - Directory Server (draft): `plugins_dev/directory-server/` - Build: `node scripts/build-directory-server-plugin.js` - Upload: `dist/plugins/directory-server.zip` diff --git a/package.json b/package.json @@ -19,6 +19,7 @@ "build:clean-install-zip": "node scripts/build-clean-install-zip.js", "build:plugin:directory-server": "node scripts/build-directory-server-plugin.js", "build:plugin:directory-publisher": "node scripts/build-directory-publisher-plugin.js", + "build:plugin:godot": "node scripts/build-godot-plugin.js", "multi:init": "node scripts/multi-instance-init.js", "multi:update": "node scripts/multi-instance-update.js", "instances:scan": "node scripts/bzl-instances-update.js", diff --git a/plugins_dev/godot/client.js b/plugins_dev/godot/client.js @@ -0,0 +1,414 @@ +(() => { + if (!window?.BzlPluginHost?.register) return; + + const BRIDGE_VERSION = "bzl.godot.v1"; + const STORAGE_SOURCE_KEY = "source_path"; + const FALLBACK_PLUGIN_ID = "godot"; + + function detectPluginIdFromScript() { + try { + const src = String(document.currentScript?.src || ""); + if (!src) return FALLBACK_PLUGIN_ID; + const url = new URL(src, window.location.origin); + const m = url.pathname.match(/^\/plugins\/([a-z0-9][a-z0-9_.-]{0,31})\//i); + if (m && m[1]) return String(m[1]).toLowerCase(); + } catch { + // ignore + } + return FALLBACK_PLUGIN_ID; + } + + const PLUGIN_ID = detectPluginIdFromScript(); + + function defaultBundledPath(pluginId) { + const id = String(pluginId || FALLBACK_PLUGIN_ID).toLowerCase(); + return `/plugins/${id}/godotapp/index.html`; + } + + function esc(value) { + return String(value ?? "") + .replace(/&/g, "&amp;") + .replace(/</g, "&lt;") + .replace(/>/g, "&gt;") + .replace(/"/g, "&quot;") + .replace(/'/g, "&#39;"); + } + + function ensureStyles() { + if (document.getElementById("bzlGodotPanelStyle")) return; + const style = document.createElement("style"); + style.id = "bzlGodotPanelStyle"; + style.textContent = ` + .godotWrap { display:flex; flex-direction:column; gap:10px; min-height:0; height:100%; } + .godotControls { display:flex; gap:8px; flex-wrap:wrap; align-items:center; } + .godotControls input[type="text"] { flex:1 1 340px; min-width:220px; } + .godotHint { font-size:12px; color: rgba(246,240,255,0.72); } + .godotStatus { font-size:12px; color: rgba(246,240,255,0.85); } + .godotStatus[data-kind="bad"] { color: var(--bad, #ff4d8a); } + .godotStatus[data-kind="good"] { color: var(--good, #3ddc97); } + .godotViewport { + position: relative; + min-height: 360px; + height: 100%; + flex: 1 1 auto; + border: 1px solid rgba(246,240,255,0.14); + border-radius: 14px; + background: linear-gradient(180deg, rgba(0,0,0,0.34), rgba(0,0,0,0.22)); + overflow: hidden; + } + .godotFrame { + width: 100%; + height: 100%; + border: 0; + display: block; + } + .godotEmpty { + position:absolute; + inset:0; + display:flex; + align-items:center; + justify-content:center; + padding:16px; + text-align:center; + color: rgba(246,240,255,0.7); + font-size:13px; + } + .godotMeta { display:flex; gap:10px; align-items:center; flex-wrap:wrap; } + .godotChip { + border:1px solid rgba(246,240,255,0.16); + border-radius:999px; + padding:3px 9px; + font-size:11px; + color: rgba(246,240,255,0.78); + } + `; + document.head.appendChild(style); + } + + function normalizeLocalPath(rawPath) { + const raw = String(rawPath || "").trim(); + if (!raw) return { ok: false, error: "Enter a Godot export path." }; + if (/^https?:\/\//i.test(raw)) { + return { ok: false, error: "Use a same-origin path (for example: /uploads/godot/my-game/index.html)." }; + } + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/i.test(raw)) { + return { ok: false, error: "Only same-origin relative paths are allowed in MVP." }; + } + const normalized = raw.startsWith("/") ? raw : `/${raw.replace(/^\/+/, "")}`; + let url; + try { + url = new URL(normalized, window.location.origin); + } catch { + return { ok: false, error: "Invalid path." }; + } + if (url.origin !== window.location.origin) { + return { ok: false, error: "Only same-origin paths are allowed." }; + } + return { ok: true, path: `${url.pathname}${url.search}${url.hash}` }; + } + + window.BzlPluginHost.register(PLUGIN_ID, (ctx) => { + ensureStyles(); + const bundledPath = defaultBundledPath(ctx?.id || PLUGIN_ID); + + let mountEl = null; + let panelEl = null; + let viewportEl = null; + let frameEl = null; + let sourceInputEl = null; + let statusEl = null; + let lastEventEl = null; + let lastVisibility = null; + let visibilityTimer = 0; + let visibilityObserver = null; + let resizeObserver = null; + let onVisibilityChange = null; + let onWindowResize = null; + let onMessage = null; + let loadId = 0; + let currentPath = ""; + let lastBridgeEvent = ""; + + function setStatus(kind, message) { + if (!statusEl) return; + statusEl.dataset.kind = kind || ""; + statusEl.textContent = String(message || ""); + } + + function setLastEvent(text) { + lastBridgeEvent = String(text || ""); + if (!lastEventEl) return; + lastEventEl.textContent = lastBridgeEvent || "none"; + } + + function panelIsVisible() { + if (!(panelEl instanceof HTMLElement)) return false; + if (!panelEl.isConnected) return false; + if (panelEl.classList.contains("hidden")) return false; + if (document.visibilityState === "hidden") return false; + const style = window.getComputedStyle(panelEl); + if (style.display === "none" || style.visibility === "hidden") return false; + return true; + } + + function postBridge(eventName, payload) { + if (!(frameEl instanceof HTMLIFrameElement)) return false; + if (!frameEl.contentWindow) return false; + const message = { + type: BRIDGE_VERSION, + event: String(eventName || ""), + payload: payload && typeof payload === "object" ? payload : {}, + sentAt: Date.now() + }; + frameEl.contentWindow.postMessage(message, "*"); + setLastEvent(`host->game ${message.event}`); + return true; + } + + function postResize() { + if (!(viewportEl instanceof HTMLElement)) return; + const rect = viewportEl.getBoundingClientRect(); + postBridge("host:resize", { + width: Math.max(0, Math.round(rect.width)), + height: Math.max(0, Math.round(rect.height)) + }); + } + + function syncLifecycle() { + const visible = panelIsVisible(); + if (visible === lastVisibility) return; + lastVisibility = visible; + if (visible) { + postBridge("host:resume", { reason: "visible" }); + setStatus("good", frameEl ? "Running." : "Ready."); + } else { + postBridge("host:pause", { reason: "hidden" }); + setStatus("", frameEl ? "Paused while panel is hidden." : "Ready."); + } + } + + function unloadFrame(reason) { + loadId += 1; + if (frameEl) { + try { + frameEl.remove(); + } catch { + // ignore + } + } + frameEl = null; + if (viewportEl) { + viewportEl.innerHTML = `<div class="godotEmpty">No export loaded.</div>`; + } + if (reason) setStatus("", reason); + setLastEvent("none"); + } + + function loadFrame(path) { + const targetPath = String(path || "").trim(); + if (!targetPath) { + setStatus("bad", "Provide a path first."); + return; + } + loadId += 1; + const id = loadId; + if (viewportEl) viewportEl.innerHTML = ""; + const frame = document.createElement("iframe"); + frame.className = "godotFrame"; + frame.setAttribute("sandbox", "allow-scripts allow-same-origin allow-pointer-lock allow-downloads"); + frame.setAttribute("allow", "fullscreen; gamepad"); + frame.setAttribute("referrerpolicy", "no-referrer"); + frame.src = targetPath; + frame.addEventListener("load", () => { + if (id !== loadId) return; + setStatus("good", `Loaded ${targetPath}`); + postBridge("host:ready", { + user: String(ctx.getUser?.() || ""), + role: String(ctx.getRole?.() || ""), + plugin: PLUGIN_ID, + bridge: BRIDGE_VERSION + }); + postResize(); + syncLifecycle(); + }); + frame.addEventListener("error", () => { + if (id !== loadId) return; + setStatus("bad", "Failed to load export."); + }); + frameEl = frame; + viewportEl?.appendChild(frame); + setStatus("", `Loading ${targetPath} ...`); + } + + function buildUi(api) { + const savedPath = String(api?.storage?.get(STORAGE_SOURCE_KEY) || "").trim(); + currentPath = savedPath || bundledPath; + + mountEl.innerHTML = ` + <div class="godotWrap"> + <div class="godotControls"> + <input type="text" data-godot-source="1" maxlength="300" placeholder="${bundledPath}" value="${esc(currentPath)}" /> + <button type="button" class="primary smallBtn" data-godot-loadbundled="1">Load Bundled App</button> + <button type="button" class="primary smallBtn" data-godot-load="1">Load</button> + <button type="button" class="ghost smallBtn" data-godot-reload="1">Reload</button> + <button type="button" class="ghost smallBtn" data-godot-unload="1">Unload</button> + </div> + <div class="godotHint">Template mode: put your Godot HTML5 export files in <code>godotapp/</code> inside this plugin zip. Default entry: <code>${bundledPath}</code></div> + <div class="godotMeta"> + <div class="godotStatus" data-godot-status="1">Ready.</div> + <div class="godotChip">bridge: ${BRIDGE_VERSION}</div> + <div class="godotChip">last event: <span data-godot-last="1">none</span></div> + </div> + <div class="godotViewport" data-godot-viewport="1"> + <div class="godotEmpty">No export loaded.</div> + </div> + </div> + `; + + sourceInputEl = mountEl.querySelector("[data-godot-source='1']"); + statusEl = mountEl.querySelector("[data-godot-status='1']"); + lastEventEl = mountEl.querySelector("[data-godot-last='1']"); + viewportEl = mountEl.querySelector("[data-godot-viewport='1']"); + panelEl = mountEl.closest(".panel"); + + const loadNow = () => { + const normalized = normalizeLocalPath(sourceInputEl?.value); + if (!normalized.ok) { + setStatus("bad", normalized.error); + return; + } + currentPath = normalized.path; + api?.storage?.set(STORAGE_SOURCE_KEY, currentPath); + loadFrame(currentPath); + }; + + mountEl.querySelector("[data-godot-load='1']")?.addEventListener("click", loadNow); + mountEl.querySelector("[data-godot-loadbundled='1']")?.addEventListener("click", () => { + currentPath = bundledPath; + if (sourceInputEl) sourceInputEl.value = currentPath; + api?.storage?.set(STORAGE_SOURCE_KEY, currentPath); + loadFrame(currentPath); + }); + mountEl.querySelector("[data-godot-reload='1']")?.addEventListener("click", () => { + if (!currentPath) { + setStatus("bad", "Load a source first."); + return; + } + loadFrame(currentPath); + }); + mountEl.querySelector("[data-godot-unload='1']")?.addEventListener("click", () => { + unloadFrame("Unloaded."); + }); + sourceInputEl?.addEventListener("keydown", (e) => { + if (e.key !== "Enter") return; + e.preventDefault(); + loadNow(); + }); + } + + function bindLifecycleObservers() { + if (!panelEl) return; + lastVisibility = null; + syncLifecycle(); + + visibilityObserver = new MutationObserver(() => syncLifecycle()); + visibilityObserver.observe(panelEl, { attributes: true, attributeFilter: ["class", "style"] }); + + onVisibilityChange = () => syncLifecycle(); + document.addEventListener("visibilitychange", onVisibilityChange); + + onWindowResize = () => { + postResize(); + syncLifecycle(); + }; + window.addEventListener("resize", onWindowResize); + + if (window.ResizeObserver && viewportEl) { + resizeObserver = new ResizeObserver(() => postResize()); + resizeObserver.observe(viewportEl); + } + + visibilityTimer = window.setInterval(syncLifecycle, 900); + } + + function bindMessageBridge() { + onMessage = (evt) => { + if (!(frameEl instanceof HTMLIFrameElement)) return; + if (evt.source !== frameEl.contentWindow) return; + const msg = evt.data; + if (!msg || typeof msg !== "object") return; + if (String(msg.type || "") !== BRIDGE_VERSION) return; + const ev = String(msg.event || ""); + const payload = msg.payload && typeof msg.payload === "object" ? msg.payload : {}; + setLastEvent(`game->host ${ev || "unknown"}`); + + if (ev === "ready") { + setStatus("good", "Game reported ready."); + postResize(); + return; + } + if (ev === "error") { + const detail = String(payload.message || payload.code || "unknown error"); + setStatus("bad", `Game error: ${detail}`); + return; + } + if (ev === "state") { + const status = String(payload.status || "state"); + setStatus("", `State: ${status}`); + return; + } + }; + window.addEventListener("message", onMessage); + } + + ctx.ui?.registerPanel?.({ + id: "godot", + title: "Godot", + icon: "G", + defaultRack: "main", + role: "primary", + presetHints: { + defaultSocial: { place: "docked.bottom" }, + mapsSession: { place: "docked.bottom" } + }, + render(mount, api) { + mountEl = mount; + buildUi(api); + bindLifecycleObservers(); + bindMessageBridge(); + + loadFrame(currentPath || bundledPath); + + return () => { + if (visibilityTimer) window.clearInterval(visibilityTimer); + visibilityTimer = 0; + try { + visibilityObserver?.disconnect(); + } catch { + // ignore + } + try { + resizeObserver?.disconnect(); + } catch { + // ignore + } + if (onVisibilityChange) document.removeEventListener("visibilitychange", onVisibilityChange); + if (onWindowResize) window.removeEventListener("resize", onWindowResize); + if (onMessage) window.removeEventListener("message", onMessage); + unloadFrame(""); + mountEl = null; + panelEl = null; + viewportEl = null; + sourceInputEl = null; + statusEl = null; + lastEventEl = null; + visibilityObserver = null; + resizeObserver = null; + onVisibilityChange = null; + onWindowResize = null; + onMessage = null; + }; + } + }); + }); +})(); diff --git a/plugins_dev/godot/godotapp/index.html b/plugins_dev/godot/godotapp/index.html @@ -0,0 +1,58 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>Godot App Template</title> + <style> + html, body { + margin: 0; + width: 100%; + height: 100%; + font-family: system-ui, sans-serif; + background: #0b0d12; + color: #e8ecf6; + } + .wrap { + min-height: 100%; + display: grid; + place-items: center; + padding: 24px; + box-sizing: border-box; + text-align: center; + } + .card { + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 12px; + padding: 18px; + max-width: 760px; + background: rgba(255, 255, 255, 0.04); + } + code { + background: rgba(255, 255, 255, 0.08); + padding: 2px 6px; + border-radius: 6px; + } + </style> + </head> + <body> + <div class="wrap"> + <div class="card"> + <h1>Godot Template Ready</h1> + <p>Replace this file with your Godot HTML5 export entry file.</p> + <p>Copy all export assets into this folder: <code>godotapp/</code></p> + <p>Expected entry path in Bzl: <code>/plugins/godot/godotapp/index.html</code></p> + </div> + </div> + <script> + window.parent?.postMessage?.( + { + type: "bzl.godot.v1", + event: "ready", + payload: { status: "template" } + }, + "*" + ); + </script> + </body> +</html> diff --git a/plugins_dev/godot/plugin.json b/plugins_dev/godot/plugin.json @@ -0,0 +1,9 @@ +{ + "id": "godot", + "name": "Godot Panel", + "version": "0.1.0", + "description": "Embeds a Godot HTML5 export in a sandboxed panel with basic host lifecycle messaging.", + "entryClient": "client.js", + "entryServer": "server.js", + "permissions": ["ui"] +} diff --git a/plugins_dev/godot/server.js b/plugins_dev/godot/server.js @@ -0,0 +1,3 @@ +module.exports = function init(api) { + api.log("info", "godot:server:init", { plugin: "godot" }); +}; diff --git a/public/app.js b/public/app.js @@ -10951,16 +10951,33 @@ modBodyEl?.addEventListener("click", (e) => { renderModPanel(); return; } + const maxZipBytes = 50 * 1024 * 1024; + const fileSize = Number(file.size || 0); + if (fileSize > maxZipBytes) { + pluginAdminStatus = `Plugin zip is too large (${Math.ceil(fileSize / (1024 * 1024))}MB). Max is 50MB.`; + renderModPanel(); + return; + } pluginAdminBusy = true; - pluginAdminStatus = "Uploading plugin..."; + pluginAdminStatus = `Uploading plugin (${Math.ceil(fileSize / 1024)} KB)...`; renderModPanel(); (async () => { + const controller = new AbortController(); + const timeoutMs = 2 * 60 * 1000; + const timeout = setTimeout(() => { + try { + controller.abort(new Error("UPLOAD_TIMEOUT")); + } catch { + // ignore + } + }, timeoutMs); try { const res = await fetch("/api/plugin-install", { method: "POST", headers: { "Content-Type": "application/zip", Authorization: `Bearer ${token}` }, body: file, credentials: "same-origin", + signal: controller.signal, }); const json = await res.json().catch(() => null); if (!res.ok || !json || !json.ok) { @@ -10976,8 +10993,17 @@ modBodyEl?.addEventListener("click", (e) => { renderModPanel(); } catch (err) { pluginAdminBusy = false; - pluginAdminStatus = "Install failed."; + const message = String(err?.message || ""); + if (message.includes("UPLOAD_TIMEOUT")) { + pluginAdminStatus = "Upload timed out after 2 minutes. Try a smaller zip or better network."; + } else if (String(err?.name || "") === "AbortError") { + pluginAdminStatus = "Upload was interrupted."; + } else { + pluginAdminStatus = `Install failed: ${message || "unknown error"}`; + } renderModPanel(); + } finally { + clearTimeout(timeout); } })(); return; diff --git a/scripts/build-godot-plugin.js b/scripts/build-godot-plugin.js @@ -0,0 +1,22 @@ +const fs = require("fs"); +const path = require("path"); +const AdmZip = require("adm-zip"); + +const root = path.resolve(__dirname, ".."); +const pluginDir = path.join(root, "plugins_dev", "godot"); +const distDir = path.join(root, "dist", "plugins"); +const outZip = path.join(distDir, "godot.zip"); + +function main() { + if (!fs.existsSync(pluginDir)) { + console.error("Missing plugin folder:", pluginDir); + process.exit(1); + } + fs.mkdirSync(distDir, { recursive: true }); + const zip = new AdmZip(); + zip.addLocalFolder(pluginDir, ""); + zip.writeZip(outZip); + console.log("Built:", outZip); +} + +main(); diff --git a/server.js b/server.js @@ -3353,6 +3353,10 @@ function readRequestBodyToFile(req, filePath, maxBytes) { out.end(() => finish(null, bytes)); }); + req.on("aborted", () => finish(new Error("REQUEST_ABORTED"))); + req.on("close", () => { + if (!done && !req.complete) finish(new Error("REQUEST_ABORTED")); + }); req.on("error", (e) => finish(e)); out.on("error", (e) => finish(e)); }); @@ -3828,17 +3832,28 @@ function servePluginFile(req, res, pathname) { ? "text/css; charset=utf-8" : ext === ".js" ? "text/javascript; charset=utf-8" - : ext === ".json" - ? "application/json; charset=utf-8" - : ext === ".png" - ? "image/png" - : ext === ".jpg" || ext === ".jpeg" - ? "image/jpeg" - : ext === ".gif" - ? "image/gif" - : ext === ".webp" - ? "image/webp" - : "application/octet-stream"; + : ext === ".mjs" + ? "text/javascript; charset=utf-8" + : ext === ".json" + ? "application/json; charset=utf-8" + : ext === ".wasm" + ? "application/wasm" + : ext === ".svg" + ? "image/svg+xml" + : ext === ".png" + ? "image/png" + : ext === ".jpg" || ext === ".jpeg" + ? "image/jpeg" + : ext === ".gif" + ? "image/gif" + : ext === ".webp" + ? "image/webp" + : "application/octet-stream"; + + // Template app exports under /plugins/<id>/godotapp/* must be embeddable in-app. + if (rel.startsWith("godotapp/")) { + res.setHeader("X-Frame-Options", "SAMEORIGIN"); + } res.writeHead(200, { "Content-Type": contentType, "Cache-Control": "no-store" }); fs.createReadStream(filePath).pipe(res);