commit 890eda228fb01fa3513924acda1bcd398d78733c
parent 691dce96f6ef52194dc75749457b58fcc7335167
Author: SageAzakaela <106701693+SageAzakaela@users.noreply.github.com>
Date: Wed, 18 Feb 2026 15:11:52 -0700
stream installer pack, mobile ux roadmap, other light bugfixes
Diffstat:
29 files changed, 2263 insertions(+), 24 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -24,6 +24,7 @@ tmp_server_err.log
tmp_server_*.log
launcher-ui.crash.log
Hives
+stream_pack/
# Keep the data folder in git, but never commit runtime contents
data/*
diff --git a/CLEAN_INSTALL/.gitignore b/CLEAN_INSTALL/.gitignore
@@ -10,3 +10,4 @@ data/reports.json
data/sessions.json
data/uploads/
.env
+stream_pack/
diff --git a/CLEAN_INSTALL/docs/STREAM_PACK.md b/CLEAN_INSTALL/docs/STREAM_PACK.md
@@ -0,0 +1,30 @@
+# Stream Pack (optional, dedicated servers)
+
+Bzl core is designed to run without any domain name. **Streaming** (game/screen share + voice) is different: it needs a real-time media server and **HTTPS**.
+
+This repo ships an **optional “Stream Pack”** that you can install on dedicated servers. It runs:
+- **LiveKit** (SFU) for scalable WebRTC (one streamer, many viewers)
+- **coturn** for NAT traversal reliability
+
+Core Bzl remains unchanged: if you don’t install Stream Pack, everything still works (just no streaming).
+
+## Quick start
+
+1. Decide a hostname for streaming (example): `stream.yourdomain.com`
+2. Create DNS A record to your server (set to **DNS-only**, not proxied).
+3. Generate the pack:
+
+```bash
+node scripts/stream-pack-init.js --domain=stream.yourdomain.com --email=you@yourdomain.com
+```
+
+4. Edit `stream_pack/.env` and set `TURN_EXTERNAL_IP` to your public server IP.
+5. Add `stream_pack/Caddyfile.snippet` to your Caddy config and reload.
+6. Open firewall ports listed in `stream_pack/README.md`.
+7. Start it:
+
+```bash
+cd stream_pack
+docker compose up -d
+```
+
diff --git a/CLEAN_INSTALL/package.json b/CLEAN_INSTALL/package.json
@@ -17,7 +17,11 @@
"backup-data": "node scripts/backup-data.js",
"restore-data": "node scripts/restore-data.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:directory-publisher": "node scripts/build-directory-publisher-plugin.js",
+ "stream:init": "node scripts/stream-pack-init.js",
+ "stream:up": "node scripts/stream-pack-up.js",
+ "stream:down": "node scripts/stream-pack-down.js",
+ "stream:status": "node scripts/stream-pack-status.js"
},
"dependencies": {
"adm-zip": "^0.5.16",
diff --git a/CLEAN_INSTALL/plugins_dev/directory-publisher/client.js b/CLEAN_INSTALL/plugins_dev/directory-publisher/client.js
@@ -1,5 +1,120 @@
window.BzlPluginHost.register("directory-publisher", (ctx) => {
ctx.devLog("info", "directory-publisher client loaded");
- // UI draft: config + publish button will be added once the directory UX is finalized.
-});
+ let mountEl = null;
+ let config = null;
+ let lastResult = null;
+
+ const el = (tag, props = {}, children = []) => {
+ const node = document.createElement(tag);
+ for (const [k, v] of Object.entries(props || {})) {
+ if (k === "className") node.className = String(v || "");
+ else if (k === "text") node.textContent = String(v ?? "");
+ else if (k.startsWith("on") && typeof v === "function") node.addEventListener(k.slice(2).toLowerCase(), v);
+ else if (v === false || v == null) continue;
+ else node.setAttribute(k, String(v));
+ }
+ for (const c of children) node.appendChild(c);
+ return node;
+ };
+
+ const safe = (v) => String(v ?? "");
+
+ function render() {
+ if (!mountEl) return;
+ mountEl.innerHTML = "";
+
+ const c = config && typeof config === "object" ? config : {};
+ const inst = c.instance && typeof c.instance === "object" ? c.instance : {};
+
+ const directoryUrl = el("input", { value: safe(c.directoryUrl), placeholder: "https://chat.bzl.one" });
+ const token = el("input", { value: safe(c.token), placeholder: "Directory token (shared secret)" });
+
+ const id = el("input", { value: safe(inst.id), placeholder: "instance id (e.g. temple)" });
+ const url = el("input", { value: safe(inst.url), placeholder: "https://your.instance" });
+ const name = el("input", { value: safe(inst.name), placeholder: "Display name" });
+ const description = el("input", { value: safe(inst.description), placeholder: "Short description" });
+ const bzlVersion = el("input", { value: safe(inst.bzlVersion), placeholder: "bzl version (optional)" });
+ const requiresReg = el("input", { type: "checkbox", checked: inst.requiresRegistrationCode ? "checked" : null });
+
+ const statusText =
+ lastResult && typeof lastResult === "object"
+ ? lastResult.ok
+ ? `Published (HTTP ${lastResult.status || 200})`
+ : `Publish failed: ${safe(lastResult.error || lastResult.body || `HTTP ${lastResult.status || 0}`)}`
+ : "";
+
+ const status = statusText ? el("div", { className: lastResult?.ok ? "good small" : "bad small", text: statusText, style: "margin-top:10px" }) : null;
+
+ const saveBtn = el("button", {
+ type: "button",
+ className: "primary",
+ text: "Save",
+ onclick: () => {
+ ctx.send("setConfig", {
+ config: {
+ directoryUrl: safe(directoryUrl.value).trim(),
+ token: safe(token.value).trim(),
+ instance: {
+ id: safe(id.value).trim(),
+ url: safe(url.value).trim(),
+ name: safe(name.value).trim(),
+ description: safe(description.value).trim(),
+ bzlVersion: safe(bzlVersion.value).trim(),
+ requiresRegistrationCode: Boolean(requiresReg.checked),
+ },
+ },
+ });
+ },
+ });
+
+ const publishBtn = el("button", { type: "button", className: "ghost", text: "Publish now", onclick: () => ctx.send("publishNow", {}) });
+
+ mountEl.appendChild(
+ el("div", { className: "panel", style: "padding:12px" }, [
+ el("div", { text: "Directory publisher", style: "font-weight:700; margin-bottom:8px" }),
+ el("div", { className: "muted small", text: "Announces this instance to a directory (owner-only)." }),
+ el("div", { className: "row", style: "gap:10px; margin-top:10px" }, [
+ el("label", { style: "flex:1" }, [el("span", { className: "muted small", text: "Directory URL" }), directoryUrl]),
+ ]),
+ el("div", { className: "row", style: "gap:10px; margin-top:10px" }, [el("label", { style: "flex:1" }, [el("span", { className: "muted small", text: "Token" }), token])] ),
+ el("div", { className: "muted small", text: "Instance metadata", style: "margin-top:14px" }),
+ el("div", { className: "row", style: "gap:10px; margin-top:8px" }, [
+ el("label", { style: "flex:1" }, [el("span", { className: "muted small", text: "Instance id" }), id]),
+ el("label", { style: "flex:1" }, [el("span", { className: "muted small", text: "Instance URL" }), url]),
+ ]),
+ el("div", { className: "row", style: "gap:10px; margin-top:10px" }, [
+ el("label", { style: "flex:1" }, [el("span", { className: "muted small", text: "Name" }), name]),
+ el("label", { style: "flex:1" }, [el("span", { className: "muted small", text: "Bzl version" }), bzlVersion]),
+ ]),
+ el("div", { className: "row", style: "gap:10px; margin-top:10px" }, [el("label", { style: "flex:1" }, [el("span", { className: "muted small", text: "Description" }), description])] ),
+ el("label", { className: "row small", style: "gap:10px; align-items:center; margin-top:10px" }, [requiresReg, el("span", { text: "Requires registration code" })]),
+ el("div", { className: "row", style: "gap:10px; margin-top:14px" }, [saveBtn, publishBtn]),
+ ...(status ? [status] : []),
+ ])
+ );
+ }
+
+ ctx.on("config", (msg) => {
+ config = msg?.config || null;
+ render();
+ });
+ ctx.on("configSaved", () => {
+ ctx.toast("Saved", "Directory publisher config saved.");
+ });
+ ctx.on("result", (msg) => {
+ lastResult = msg || null;
+ render();
+ });
+
+ ctx.ui.registerModTab({
+ id: "directory",
+ title: "Directory publish",
+ ownerOnly: true,
+ render(mount) {
+ mountEl = mount;
+ render();
+ ctx.send("getConfig", {});
+ },
+ });
+});
diff --git a/CLEAN_INSTALL/plugins_dev/directory-publisher/server.js b/CLEAN_INSTALL/plugins_dev/directory-publisher/server.js
@@ -1,5 +1,6 @@
const fs = require("fs");
const path = require("path");
+const http = require("http");
const https = require("https");
const CONFIG_PATH = path.join(__dirname, "config.json");
@@ -39,12 +40,14 @@ function postJson(targetUrl, token, payload) {
return;
}
+ const transport = u.protocol === "http:" ? http : https;
+ const port = u.port ? Number(u.port) : u.protocol === "http:" ? 80 : 443;
const body = Buffer.from(JSON.stringify(payload), "utf8");
- const req = https.request(
+ const req = transport.request(
{
method: "POST",
hostname: u.hostname,
- port: u.port || 443,
+ port,
path: u.pathname + u.search,
headers: {
"Content-Type": "application/json",
@@ -105,4 +108,3 @@ module.exports = function init(api) {
api.log("info", "directory-publisher loaded");
};
-
diff --git a/CLEAN_INSTALL/plugins_dev/directory-server/client.js b/CLEAN_INSTALL/plugins_dev/directory-server/client.js
@@ -1,4 +1,292 @@
window.BzlPluginHost.register("directory-server", (ctx) => {
ctx.devLog("info", "directory-server client loaded");
-});
+ const pluginId = ctx.id;
+ const apiBase = `/api/plugins/${encodeURIComponent(pluginId)}`;
+
+ let panelMount = null;
+ let modMount = null;
+ let lastList = [];
+ let lastConfig = null;
+ let lastEntries = [];
+
+ const el = (tag, props = {}, children = []) => {
+ const node = document.createElement(tag);
+ for (const [k, v] of Object.entries(props || {})) {
+ if (k === "className") node.className = String(v || "");
+ else if (k === "text") node.textContent = String(v ?? "");
+ else if (k === "html") node.innerHTML = String(v ?? "");
+ else if (k.startsWith("on") && typeof v === "function") node.addEventListener(k.slice(2).toLowerCase(), v);
+ else if (v === false || v == null) continue;
+ else node.setAttribute(k, String(v));
+ }
+ for (const c of children) node.appendChild(c);
+ return node;
+ };
+
+ const fmtTime = (t) => {
+ const n = Number(t || 0);
+ if (!n) return "-";
+ try {
+ return new Date(n).toLocaleString();
+ } catch {
+ return String(n);
+ }
+ };
+
+ const getHost = (rawUrl) => {
+ try {
+ return new URL(String(rawUrl || "")).hostname || "";
+ } catch {
+ return "";
+ }
+ };
+
+ async function loadPublicList() {
+ try {
+ const r = await fetch(`${apiBase}/list`, { cache: "no-store" });
+ const j = await r.json();
+ if (!j?.ok) throw new Error(j?.error || "Failed to load list");
+ lastList = Array.isArray(j.entries) ? j.entries : [];
+ } catch (e) {
+ ctx.devLog("warn", "directory list fetch failed", { error: e?.message || String(e) });
+ lastList = [];
+ }
+ renderDirectoryPanel();
+ }
+
+ function renderDirectoryPanel() {
+ if (!panelMount) return;
+ panelMount.innerHTML = "";
+
+ const top = el("div", { className: "row", style: "justify-content:space-between; align-items:center; gap:10px; margin-bottom:10px" }, [
+ el("div", { className: "row", style: "gap:10px; align-items:center" }, [
+ el("div", { text: "Directory", style: "font-weight:700" }),
+ el("div", { className: "muted small", text: `${lastList.length} instance${lastList.length === 1 ? "" : "s"}` }),
+ ]),
+ el("div", { className: "row", style: "gap:8px; align-items:center" }, [
+ el("button", {
+ type: "button",
+ className: "ghost",
+ text: "Refresh",
+ onclick: () => loadPublicList(),
+ }),
+ ]),
+ ]);
+
+ const list = el("div", { style: "display:flex; flex-direction:column; gap:10px" });
+ if (!lastList.length) {
+ list.appendChild(el("div", { className: "muted", text: "No directory entries yet." }));
+ } else {
+ for (const entry of lastList) {
+ const inst = entry?.instance || {};
+ const name = String(inst.name || inst.id || "Instance").slice(0, 80);
+ const url = String(inst.url || "");
+ const desc = String(inst.description || "").slice(0, 240);
+ const hives = Array.isArray(entry?.publicHives) ? entry.publicHives : [];
+
+ const card = el("div", { className: "panel", style: "padding:12px" });
+ card.appendChild(
+ el("div", { className: "row", style: "justify-content:space-between; gap:10px; align-items:flex-start" }, [
+ el("div", {}, [
+ el("div", { text: name, style: "font-weight:700; margin-bottom:2px" }),
+ el("div", { className: "muted small", text: url }),
+ ]),
+ el("a", { className: "ghost smallBtn", href: url || "#", target: "_blank", rel: "noreferrer", text: "Open" }),
+ ])
+ );
+ if (desc) card.appendChild(el("div", { className: "small", text: desc, style: "margin-top:8px" }));
+ card.appendChild(el("div", { className: "muted small", text: `Last seen: ${fmtTime(entry?.lastSeenAt)}`, style: "margin-top:8px" }));
+
+ if (hives.length) {
+ const ul = el("ul", { style: "margin:8px 0 0 18px" });
+ for (const h of hives.slice(0, 10)) {
+ const li = el("li", { className: "small" });
+ const a = el("a", { href: String(h?.url || "#"), target: "_blank", rel: "noreferrer", text: String(h?.title || "Hive") });
+ li.appendChild(a);
+ const hd = String(h?.description || "").trim();
+ if (hd) li.appendChild(el("span", { className: "muted", text: ` — ${hd}` }));
+ ul.appendChild(li);
+ }
+ card.appendChild(el("div", { className: "muted small", text: "Public hives:", style: "margin-top:10px" }));
+ card.appendChild(ul);
+ }
+
+ list.appendChild(card);
+ }
+ }
+
+ panelMount.appendChild(top);
+ panelMount.appendChild(list);
+ }
+
+ function renderMod() {
+ if (!modMount) return;
+ modMount.innerHTML = "";
+
+ const tokenSet = Boolean(lastConfig?.tokenSet);
+ const hiddenIds = Array.isArray(lastConfig?.hiddenIds) ? lastConfig.hiddenIds : [];
+ const blockedHosts = Array.isArray(lastConfig?.blockedHosts) ? lastConfig.blockedHosts : [];
+
+ const tokenInput = el("input", {
+ placeholder: tokenSet ? "Token is set (enter to replace)" : "Set directory token (shared secret)",
+ style: "flex:1",
+ });
+ const saveBtn = el("button", {
+ type: "button",
+ className: "primary",
+ text: "Save token",
+ onclick: () => {
+ const token = String(tokenInput.value || "").trim();
+ ctx.send("setToken", { token });
+ tokenInput.value = "";
+ },
+ });
+
+ modMount.appendChild(
+ el("div", { className: "panel", style: "padding:12px; margin-bottom:12px" }, [
+ el("div", { className: "row", style: "justify-content:space-between; align-items:center; gap:10px" }, [
+ el("div", {}, [el("div", { text: "Directory settings", style: "font-weight:700" }), el("div", { className: "muted small", text: tokenSet ? "Token: set" : "Token: not set" })]),
+ el("button", { type: "button", className: "ghost", text: "Refresh", onclick: () => ctx.send("getConfig", {}) }),
+ ]),
+ el("div", { className: "row", style: "gap:10px; margin-top:10px" }, [tokenInput, saveBtn]),
+ ])
+ );
+
+ const blockedWrap = el("div", { className: "panel", style: "padding:12px; margin-bottom:12px" });
+ blockedWrap.appendChild(el("div", { text: "Blocked hosts", style: "font-weight:700; margin-bottom:6px" }));
+ if (!blockedHosts.length) blockedWrap.appendChild(el("div", { className: "muted small", text: "None." }));
+ else {
+ const ul = el("ul", { style: "margin:0 0 0 18px" });
+ for (const host of blockedHosts.slice(0, 200)) {
+ const li = el("li", { className: "small" });
+ li.appendChild(el("span", { text: host }));
+ li.appendChild(
+ el("button", {
+ type: "button",
+ className: "ghost smallBtn",
+ text: "Unblock",
+ style: "margin-left:10px",
+ onclick: () => ctx.send("setBlockedHost", { host, blocked: false }),
+ })
+ );
+ ul.appendChild(li);
+ }
+ blockedWrap.appendChild(ul);
+ }
+ modMount.appendChild(blockedWrap);
+
+ const entriesWrap = el("div", { className: "panel", style: "padding:12px" });
+ entriesWrap.appendChild(
+ el("div", { className: "row", style: "justify-content:space-between; align-items:center; gap:10px; margin-bottom:8px" }, [
+ el("div", { text: "Entries", style: "font-weight:700" }),
+ el("button", { type: "button", className: "ghost", text: "Refresh", onclick: () => ctx.send("getEntries", {}) }),
+ ])
+ );
+
+ if (!lastEntries.length) {
+ entriesWrap.appendChild(el("div", { className: "muted small", text: "No entries yet." }));
+ } else {
+ const list = el("div", { style: "display:flex; flex-direction:column; gap:10px" });
+ for (const entry of lastEntries) {
+ const inst = entry?.instance || {};
+ const id = String(inst.id || "").trim().toLowerCase();
+ const name = String(inst.name || inst.id || "Instance").slice(0, 60);
+ const url = String(inst.url || "");
+ const host = getHost(url).toLowerCase();
+ const isHidden = Boolean(id && hiddenIds.includes(id));
+ const isBlocked = Boolean(host && blockedHosts.includes(host));
+
+ const row = el("div", { className: "row", style: "gap:10px; align-items:flex-start; justify-content:space-between" });
+ const left = el("div", { style: "flex:1; min-width:0" }, [
+ el("div", { text: name, style: "font-weight:700" }),
+ el("div", { className: "muted small", text: url }),
+ el("div", { className: "muted small", text: `Last seen: ${fmtTime(entry?.lastSeenAt)}` }),
+ ]);
+
+ const controls = el("div", { className: "row", style: "gap:8px; align-items:center; flex-wrap:wrap; justify-content:flex-end" });
+ const hideCb = el("input", {
+ type: "checkbox",
+ checked: isHidden ? "checked" : null,
+ onchange: (e) => ctx.send("setHidden", { id, hidden: Boolean(e?.target?.checked) }),
+ });
+ controls.appendChild(el("label", { className: "row small", style: "gap:8px; align-items:center" }, [hideCb, el("span", { text: "Hide" })]));
+ if (host) {
+ controls.appendChild(
+ el("button", {
+ type: "button",
+ className: "ghost",
+ text: isBlocked ? "Blocked" : "Block host",
+ onclick: () => ctx.send("setBlockedHost", { host, blocked: true }),
+ disabled: isBlocked ? "disabled" : null,
+ })
+ );
+ }
+ controls.appendChild(
+ el("button", {
+ type: "button",
+ className: "ghost",
+ text: "Delete",
+ onclick: () => {
+ if (!confirm(`Delete entry "${name}"?`)) return;
+ ctx.send("deleteEntry", { id });
+ },
+ })
+ );
+
+ row.appendChild(left);
+ row.appendChild(controls);
+ list.appendChild(el("div", { style: "border-top:1px solid rgba(255,255,255,0.06); padding-top:10px" }, [row]));
+ }
+ entriesWrap.appendChild(list);
+ }
+
+ modMount.appendChild(entriesWrap);
+ }
+
+ ctx.on("updated", () => {
+ loadPublicList();
+ if (ctx.getRole() === "owner") ctx.send("getEntries", {});
+ });
+ ctx.on("config", (msg) => {
+ lastConfig = msg && typeof msg === "object" ? msg : null;
+ renderMod();
+ });
+ ctx.on("entries", (msg) => {
+ lastEntries = Array.isArray(msg?.entries) ? msg.entries : [];
+ renderMod();
+ });
+ ctx.on("configUpdated", () => {
+ if (ctx.getRole() === "owner") {
+ ctx.send("getConfig", {});
+ ctx.send("getEntries", {});
+ }
+ });
+
+ ctx.ui.registerPanel({
+ id: "directory",
+ title: "Directory",
+ icon: "📡",
+ defaultRack: "main",
+ role: "primary",
+ render(mount) {
+ panelMount = mount;
+ loadPublicList();
+ return () => {
+ if (panelMount === mount) panelMount = null;
+ };
+ },
+ });
+
+ ctx.ui.registerModTab({
+ id: "directory",
+ title: "Directory feed",
+ ownerOnly: true,
+ render(mount) {
+ modMount = mount;
+ renderMod();
+ ctx.send("getConfig", {});
+ ctx.send("getEntries", {});
+ },
+ });
+});
diff --git a/CLEAN_INSTALL/plugins_dev/directory-server/server.js b/CLEAN_INSTALL/plugins_dev/directory-server/server.js
@@ -65,10 +65,23 @@ function sanitizePublicHives(payload) {
}
module.exports = function init(api) {
- const config = readJson(CONFIG_PATH, { token: "" });
+ const config = readJson(CONFIG_PATH, { token: "", hiddenIds: [], blockedHosts: [] });
const state = readJson(STATE_PATH, { version: 1, entries: {} });
const entries = new Map(Object.entries(state.entries || {}));
+ const normalizeId = (s) => String(s || "").trim().toLowerCase();
+ const normalizeHost = (rawUrl) => {
+ try {
+ const u = new URL(String(rawUrl || ""));
+ return String(u.hostname || "").trim().toLowerCase();
+ } catch {
+ return "";
+ }
+ };
+
+ config.hiddenIds = Array.isArray(config.hiddenIds) ? config.hiddenIds.map(normalizeId).filter(Boolean) : [];
+ config.blockedHosts = Array.isArray(config.blockedHosts) ? config.blockedHosts.map((h) => String(h || "").trim().toLowerCase()).filter(Boolean) : [];
+
const persist = () => {
const out = { version: 1, entries: Object.fromEntries(entries) };
writeJson(STATE_PATH, out);
@@ -76,7 +89,13 @@ module.exports = function init(api) {
api.registerWs("getConfig", (ws) => {
if (ws?.user?.role !== "owner") return;
- api.sendToUsers([ws.user.username], { type: "plugin:directory-server:config", tokenSet: Boolean(config.token) });
+ api.sendToUsers([ws.user.username], {
+ type: "plugin:directory-server:config",
+ tokenSet: Boolean(config.token),
+ hiddenIds: config.hiddenIds.slice(0, 500),
+ blockedHosts: config.blockedHosts.slice(0, 500),
+ entryCount: entries.size
+ });
});
api.registerWs("setToken", (ws, msg) => {
@@ -87,9 +106,70 @@ module.exports = function init(api) {
api.broadcast({ type: "plugin:directory-server:configUpdated", tokenSet: Boolean(config.token) });
});
+ api.registerWs("getEntries", (ws) => {
+ if (ws?.user?.role !== "owner") return;
+ const list = Array.from(entries.values())
+ .map((e) => {
+ const host = normalizeHost(e?.instance?.url);
+ const id = normalizeId(e?.instance?.id);
+ return {
+ ...e,
+ host,
+ hidden: Boolean(id && config.hiddenIds.includes(id)),
+ blocked: Boolean(host && config.blockedHosts.includes(host))
+ };
+ })
+ .sort((a, b) => (b.lastSeenAt || 0) - (a.lastSeenAt || 0));
+ api.sendToUsers([ws.user.username], { type: "plugin:directory-server:entries", entries: list });
+ });
+
+ api.registerWs("setHidden", (ws, msg) => {
+ if (ws?.user?.role !== "owner") return;
+ const id = normalizeId(msg?.id);
+ const hidden = Boolean(msg?.hidden);
+ if (!id) return;
+ const set = new Set(config.hiddenIds);
+ if (hidden) set.add(id);
+ else set.delete(id);
+ config.hiddenIds = Array.from(set.values()).sort();
+ writeJson(CONFIG_PATH, config);
+ api.broadcast({ type: "plugin:directory-server:configUpdated", tokenSet: Boolean(config.token) });
+ });
+
+ api.registerWs("setBlockedHost", (ws, msg) => {
+ if (ws?.user?.role !== "owner") return;
+ const host = String(msg?.host || "").trim().toLowerCase();
+ const blocked = Boolean(msg?.blocked);
+ if (!host) return;
+ const set = new Set(config.blockedHosts);
+ if (blocked) set.add(host);
+ else set.delete(host);
+ config.blockedHosts = Array.from(set.values()).sort();
+ writeJson(CONFIG_PATH, config);
+ api.broadcast({ type: "plugin:directory-server:configUpdated", tokenSet: Boolean(config.token) });
+ });
+
+ api.registerWs("deleteEntry", (ws, msg) => {
+ if (ws?.user?.role !== "owner") return;
+ const id = normalizeId(msg?.id);
+ if (!id) return;
+ entries.delete(id);
+ persist();
+ api.broadcast({ type: "plugin:directory-server:updated", id, deleted: true });
+ });
+
api.registerHttp("GET", "/list", (_req, res, ctx) => {
+ const hidden = new Set(config.hiddenIds);
+ const blocked = new Set(config.blockedHosts);
const list = Array.from(entries.values())
.map((e) => e)
+ .filter((e) => {
+ const id = normalizeId(e?.instance?.id);
+ if (id && hidden.has(id)) return false;
+ const host = normalizeHost(e?.instance?.url);
+ if (host && blocked.has(host)) return false;
+ return true;
+ })
.sort((a, b) => (b.lastSeenAt || 0) - (a.lastSeenAt || 0));
ctx.sendJson(200, { ok: true, entries: list });
res.end();
@@ -120,4 +200,3 @@ module.exports = function init(api) {
api.log("info", "directory-server loaded", { http: ["/announce", "/list"] });
};
-
diff --git a/CLEAN_INSTALL/public/app.js b/CLEAN_INSTALL/public/app.js
@@ -4163,12 +4163,17 @@ function sendDevLog(level, scope, message, data) {
window.bzlDevLog = sendDevLog;
+// Plugin event handlers: pluginId -> eventName -> Set<fn(msg)>
+const pluginClientHandlers = new Map();
+// Moderation plugin tabs: fullTabId -> { title, ownerOnly, render(mount, api), pluginId }
+const modPluginTabs = new Map();
+
// Minimal plugin host (client-side). Plugins are trusted by the owner who installs them.
// Plugin scripts can call `window.BzlPluginHost.register("pluginId", (ctx) => { ... })`.
if (!window.BzlPluginHost) {
const pluginInits = new Map();
window.BzlPluginHost = {
- apiVersion: 2,
+ apiVersion: 3,
register(pluginId, initFn) {
const id = String(pluginId || "").trim().toLowerCase();
if (!/^[a-z0-9][a-z0-9_.-]{0,31}$/.test(id)) throw new Error("Invalid plugin id");
@@ -4181,7 +4186,59 @@ if (!window.BzlPluginHost) {
toast,
getUser: () => loggedInUser,
getRole: () => loggedInRole,
+ on(eventName, handler) {
+ const ev = String(eventName || "").trim();
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/.test(ev)) throw new Error("Invalid event name");
+ if (typeof handler !== "function") throw new Error("handler must be a function");
+ let byEvent = pluginClientHandlers.get(id);
+ if (!byEvent) {
+ byEvent = new Map();
+ pluginClientHandlers.set(id, byEvent);
+ }
+ let set = byEvent.get(ev);
+ if (!set) {
+ set = new Set();
+ byEvent.set(ev, set);
+ }
+ set.add(handler);
+ return () => {
+ try {
+ set.delete(handler);
+ } catch {
+ // ignore
+ }
+ };
+ },
ui: {
+ registerModTab(tabDef) {
+ const tabId = String(tabDef?.id || id).trim().toLowerCase();
+ if (!/^[a-z0-9][a-z0-9_.-]{0,31}$/.test(tabId)) throw new Error("Invalid tab id");
+ const title = typeof tabDef?.title === "string" ? tabDef.title.trim().slice(0, 22) : tabId;
+ const ownerOnly = Boolean(tabDef?.ownerOnly);
+ const render = tabDef?.render;
+ if (typeof render !== "function") throw new Error("render must be a function");
+
+ const fullId = `plugin:${id}:${tabId}`;
+ modPluginTabs.set(fullId, { title, ownerOnly, render, pluginId: id });
+
+ const tabsEl = modPanelEl?.querySelector?.(".modTabs");
+ if (tabsEl && !tabsEl.querySelector(`[data-modtab="${CSS.escape(fullId)}"]`)) {
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "ghost";
+ btn.textContent = title;
+ btn.setAttribute("data-modtab", fullId);
+ btn.dataset.ownerOnly = ownerOnly ? "1" : "0";
+ tabsEl.appendChild(btn);
+ }
+
+ // If the tab isn't visible for this user, don't allow it to become active.
+ if (ownerOnly && loggedInRole !== "owner" && modTab === fullId) {
+ modTab = "server";
+ renderModPanel();
+ }
+ return true;
+ },
registerPanel(panelDef) {
const panelId = String(panelDef?.id || id).trim().toLowerCase();
if (!/^[a-z0-9][a-z0-9_.-]{0,31}$/.test(panelId)) throw new Error("Invalid panel id");
@@ -5546,6 +5603,49 @@ function renderModPanel() {
const on = btn.getAttribute("data-modtab") === modTab;
btn.classList.toggle("primary", on);
btn.classList.toggle("ghost", !on);
+ // Owner-only plugin tabs should not show for non-owners.
+ const ownerOnly = btn.dataset.ownerOnly === "1";
+ btn.classList.toggle("hidden", Boolean(ownerOnly && loggedInRole !== "owner"));
+ }
+
+ // Plugin-provided moderation tabs (render into modBody).
+ if (modPluginTabs.has(modTab)) {
+ const def = modPluginTabs.get(modTab);
+ if (def?.ownerOnly && loggedInRole !== "owner") {
+ modTab = "server";
+ renderModPanel();
+ return;
+ }
+ modBodyEl.innerHTML = `
+ <div class="modCard">
+ <div class="modRowTop"><div><b>${escapeHtml(def?.title || "Plugin")}</b></div></div>
+ <div id="modPluginMount" class="modActions"></div>
+ </div>
+ `;
+ const mount = modBodyEl.querySelector("#modPluginMount");
+ if (mount) {
+ const api = {
+ toast,
+ send: (eventName, payload) => {
+ const ev = String(eventName || "").trim();
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/.test(ev)) return false;
+ const wsRef = window.__bzlWs;
+ if (!wsRef || wsRef.readyState !== WebSocket.OPEN) return false;
+ const msg = payload && typeof payload === "object" ? payload : {};
+ wsRef.send(JSON.stringify({ ...msg, type: `plugin:${def.pluginId}:${ev}` }));
+ return true;
+ },
+ getUser: () => loggedInUser,
+ getRole: () => loggedInRole,
+ };
+ try {
+ def.render(mount, api);
+ } catch (e) {
+ mount.textContent = "Failed to render plugin tab.";
+ console.warn(`Plugin tab render failed (${modTab}):`, e?.message || e);
+ }
+ }
+ return;
}
if (modTab === "server") {
@@ -8698,6 +8798,28 @@ function onWsMessage(evt) {
return;
}
+ // Generic plugin event dispatch: `plugin:<pluginId>:<eventName>`
+ // (Maps has some core-handled messages below; for other plugins, dispatch + stop.)
+ if (typeof msg.type === "string") {
+ const m = msg.type.match(/^plugin:([a-z0-9][a-z0-9_.-]{0,31}):([a-zA-Z0-9][a-zA-Z0-9_.-]{0,63})$/);
+ if (m) {
+ const pluginId = String(m[1] || "").toLowerCase();
+ const ev = String(m[2] || "");
+ const byEvent = pluginClientHandlers.get(pluginId);
+ const set = byEvent ? byEvent.get(ev) : null;
+ if (set && set.size) {
+ for (const fn of Array.from(set)) {
+ try {
+ fn(msg);
+ } catch (e) {
+ console.warn(`Plugin handler failed (${pluginId}:${ev}):`, e?.message || e);
+ }
+ }
+ }
+ if (pluginId !== "maps") return;
+ }
+ }
+
if (msg.type === "plugin:maps:joinOk") {
const map = msg.map && typeof msg.map === "object" ? msg.map : null;
const mapId = map && typeof map.id === "string" ? map.id.trim().toLowerCase() : "";
diff --git a/CLEAN_INSTALL/public/index.html b/CLEAN_INSTALL/public/index.html
@@ -534,6 +534,6 @@
</div>
<div id="dockHotbar" class="dockHotbar hidden" aria-label="Docked panels"></div>
- <script src="/app.js?v=120"></script>
+ <script src="/app.js?v=121"></script>
</body>
</html>
diff --git a/CLEAN_INSTALL/scripts/stream-pack-down.js b/CLEAN_INSTALL/scripts/stream-pack-down.js
@@ -0,0 +1,25 @@
+const { spawnSync } = require("child_process");
+const fs = require("fs");
+const path = require("path");
+
+const ROOT = path.join(__dirname, "..");
+const PACK_DIR = path.join(ROOT, "stream_pack");
+
+function run(cmd, args) {
+ return spawnSync(cmd, args, { stdio: "inherit", cwd: PACK_DIR });
+}
+
+function main() {
+ if (!fs.existsSync(path.join(PACK_DIR, "docker-compose.yml"))) {
+ console.error("[stream-pack] Missing stream_pack/docker-compose.yml.");
+ process.exit(1);
+ }
+
+ let r = run("docker", ["compose", "down"]);
+ if (r.status === 0) return;
+ r = run("docker-compose", ["down"]);
+ process.exit(r.status || 1);
+}
+
+main();
+
diff --git a/CLEAN_INSTALL/scripts/stream-pack-init.js b/CLEAN_INSTALL/scripts/stream-pack-init.js
@@ -0,0 +1,197 @@
+const fs = require("fs");
+const path = require("path");
+const crypto = require("crypto");
+
+const ROOT = path.join(__dirname, "..");
+const PACK_DIR = path.join(ROOT, "stream_pack");
+
+function log(msg) {
+ console.log(`[stream-pack] ${msg}`);
+}
+
+function fail(msg) {
+ console.error(`[stream-pack] ERROR: ${msg}`);
+ process.exit(1);
+}
+
+function parseArgs() {
+ const args = process.argv.slice(2);
+ const out = {};
+ for (const a of args) {
+ const m = String(a || "").match(/^--([^=]+)=(.*)$/);
+ if (m) out[m[1]] = m[2];
+ }
+ return out;
+}
+
+function randToken(bytes) {
+ return crypto.randomBytes(bytes).toString("base64url");
+}
+
+function normalizeDomain(s) {
+ const v = String(s || "").trim().toLowerCase();
+ if (!v) return "";
+ if (!/^[a-z0-9.-]+$/.test(v)) return "";
+ if (!v.includes(".")) return "";
+ return v;
+}
+
+function writeFileAlways(filePath, content) {
+ fs.writeFileSync(filePath, content, "utf8");
+}
+
+function readEnvFile(envPath) {
+ try {
+ const raw = fs.readFileSync(envPath, "utf8");
+ const out = {};
+ for (const line of raw.split(/\r?\n/)) {
+ const s = line.trim();
+ if (!s || s.startsWith("#")) continue;
+ const m = s.match(/^([A-Z0-9_]+)=(.*)$/);
+ if (!m) continue;
+ let v = m[2] || "";
+ v = v.trim();
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
+ out[m[1]] = v;
+ }
+ return out;
+ } catch {
+ return {};
+ }
+}
+
+function main() {
+ const args = parseArgs();
+ const domain = normalizeDomain(args.domain);
+ const email = String(args.email || "").trim();
+
+ if (!domain) {
+ fail('Missing/invalid `--domain=...` (example: --domain=stream.example.com)');
+ }
+ if (!email || !email.includes("@")) {
+ fail('Missing/invalid `--email=...` (needed for HTTPS certs; example: --email=you@example.com)');
+ }
+
+ fs.mkdirSync(PACK_DIR, { recursive: true });
+
+ const envPath = path.join(PACK_DIR, ".env");
+ const existing = readEnvFile(envPath);
+
+ const API_KEY = existing.LIVEKIT_API_KEY || randToken(8);
+ const API_SECRET = existing.LIVEKIT_API_SECRET || randToken(24);
+ const TURN_USER = existing.TURN_USER || "bzl";
+ const TURN_PASS = existing.TURN_PASS || randToken(18);
+ const TURN_REALM = existing.TURN_REALM || domain;
+ const TURN_EXTERNAL_IP = existing.TURN_EXTERNAL_IP || "";
+ const RTC_UDP_START = existing.RTC_UDP_START || "50000";
+ const RTC_UDP_END = existing.RTC_UDP_END || "50100";
+ const TURN_RELAY_START = existing.TURN_RELAY_START || "49160";
+ const TURN_RELAY_END = existing.TURN_RELAY_END || "49200";
+
+ const lines = [
+ "# Stream Pack (optional)",
+ "#",
+ "# This stack is separate from core Bzl. Core stays runnable without any domain.",
+ "# Streaming requires HTTPS and a public server.",
+ "",
+ `STREAM_DOMAIN=${domain}`,
+ `STREAM_EMAIL=${email}`,
+ "",
+ "# LiveKit auth (Bzl will mint JWTs with these)",
+ `LIVEKIT_API_KEY=${API_KEY}`,
+ `LIVEKIT_API_SECRET=${API_SECRET}`,
+ "",
+ "# TURN (coturn) credentials for LiveKit clients",
+ `TURN_USER=${TURN_USER}`,
+ `TURN_PASS=${TURN_PASS}`,
+ `TURN_REALM=${TURN_REALM}`,
+ `TURN_EXTERNAL_IP=${TURN_EXTERNAL_IP}`,
+ "",
+ "# WebRTC UDP port range exposed by livekit-server (keep small for firewall sanity)",
+ `RTC_UDP_START=${RTC_UDP_START}`,
+ `RTC_UDP_END=${RTC_UDP_END}`,
+ "",
+ "# TURN relay UDP ports (coturn will allocate from this range)",
+ `TURN_RELAY_START=${TURN_RELAY_START}`,
+ `TURN_RELAY_END=${TURN_RELAY_END}`,
+ ""
+ ];
+ writeFileAlways(envPath, lines.join("\n"));
+ log(`Wrote ${path.relative(ROOT, envPath)}`);
+
+ const livekitYamlPath = path.join(PACK_DIR, "livekit.yaml");
+ const livekitYaml = `port: 7880
+log_level: info
+
+rtc:
+ tcp_port: 7881
+ port_range_start: ${RTC_UDP_START}
+ port_range_end: ${RTC_UDP_END}
+ use_external_ip: true
+ turn_servers:
+ - host: ${domain}
+ port: 3478
+ protocol: udp
+ username: ${TURN_USER}
+ credential: ${TURN_PASS}
+
+keys:
+ ${API_KEY}: ${API_SECRET}
+`;
+ writeFileAlways(livekitYamlPath, livekitYaml);
+ log(`Wrote ${path.relative(ROOT, livekitYamlPath)}`);
+
+ const composePath = path.join(PACK_DIR, "docker-compose.yml");
+ const composeBody = `services:
+ livekit:
+ image: livekit/livekit-server:latest
+ container_name: bzl_livekit
+ restart: unless-stopped
+ command: --config /etc/livekit.yaml
+ volumes:
+ - ./livekit.yaml:/etc/livekit.yaml:ro
+ ports:
+ - "127.0.0.1:7880:7880"
+ - "7881:7881"
+ - "${RTC_UDP_START}-${RTC_UDP_END}:${RTC_UDP_START}-${RTC_UDP_END}/udp"
+
+ turn:
+ image: coturn/coturn:latest
+ container_name: bzl_turn
+ restart: unless-stopped
+ network_mode: host
+ command:
+ - -n
+ - --log-file=stdout
+ - --realm=${TURN_REALM}
+ - --fingerprint
+ - --lt-cred-mech
+ - --no-tls
+ - --no-dtls
+ - --no-cli
+ - --no-multicast-peers
+ - --no-loopback-peers
+ - --listening-port=3478
+ - --min-port=${TURN_RELAY_START}
+ - --max-port=${TURN_RELAY_END}
+ - --user=${TURN_USER}:${TURN_PASS}
+ - --external-ip=${TURN_EXTERNAL_IP}
+`;
+ writeFileAlways(composePath, composeBody);
+ log(`Wrote ${path.relative(ROOT, composePath)}`);
+
+ const caddySnippetPath = path.join(PACK_DIR, "Caddyfile.snippet");
+ const caddySnippet = `${domain} {
+ encode zstd gzip
+ reverse_proxy 127.0.0.1:7880
+}
+`;
+ writeFileAlways(caddySnippetPath, caddySnippet);
+ log(`Wrote ${path.relative(ROOT, caddySnippetPath)}`);
+
+ log("Done.");
+ log("Next: edit stream_pack/.env (TURN_EXTERNAL_IP), add the Caddy snippet, open firewall ports, then run `docker compose up -d`.");
+}
+
+main();
+
diff --git a/CLEAN_INSTALL/scripts/stream-pack-status.js b/CLEAN_INSTALL/scripts/stream-pack-status.js
@@ -0,0 +1,25 @@
+const { spawnSync } = require("child_process");
+const fs = require("fs");
+const path = require("path");
+
+const ROOT = path.join(__dirname, "..");
+const PACK_DIR = path.join(ROOT, "stream_pack");
+
+function run(cmd, args) {
+ return spawnSync(cmd, args, { stdio: "inherit", cwd: PACK_DIR });
+}
+
+function main() {
+ if (!fs.existsSync(path.join(PACK_DIR, "docker-compose.yml"))) {
+ console.error("[stream-pack] Missing stream_pack/docker-compose.yml.");
+ process.exit(1);
+ }
+
+ let r = run("docker", ["compose", "ps"]);
+ if (r.status === 0) return;
+ r = run("docker-compose", ["ps"]);
+ process.exit(r.status || 1);
+}
+
+main();
+
diff --git a/CLEAN_INSTALL/scripts/stream-pack-up.js b/CLEAN_INSTALL/scripts/stream-pack-up.js
@@ -0,0 +1,25 @@
+const { spawnSync } = require("child_process");
+const fs = require("fs");
+const path = require("path");
+
+const ROOT = path.join(__dirname, "..");
+const PACK_DIR = path.join(ROOT, "stream_pack");
+
+function run(cmd, args) {
+ return spawnSync(cmd, args, { stdio: "inherit", cwd: PACK_DIR });
+}
+
+function main() {
+ if (!fs.existsSync(path.join(PACK_DIR, "docker-compose.yml"))) {
+ console.error("[stream-pack] Missing stream_pack/docker-compose.yml. Run `node scripts/stream-pack-init.js ...` first.");
+ process.exit(1);
+ }
+
+ let r = run("docker", ["compose", "up", "-d"]);
+ if (r.status === 0) return;
+ r = run("docker-compose", ["up", "-d"]);
+ process.exit(r.status || 1);
+}
+
+main();
+
diff --git a/docs/MOBILE_UX.md b/docs/MOBILE_UX.md
@@ -0,0 +1,218 @@
+# Mobile UX (Draft)
+
+This doc proposes a mobile-first UX that stays consistent with Bzl’s modular “everything is a panel” direction, without trying to squeeze desktop racks onto a phone.
+
+## Goals
+
+- Mobile feels **intentional** (not a scaled-down desktop).
+- Keep the modular mental model: **surfaces are registered modules** (core + plugins).
+- Fast access to the few things you use constantly (Hives / Chat / People / Maps).
+- Works for both **small phones** and **odd aspect ratios**.
+- Supports moderator-only surfaces without breaking non-mod UX.
+
+## Non-goals (MVP)
+
+- Full rack / multi-column layout on mobile.
+- Drag-and-drop panel layout editing.
+- Multi-window / splits.
+- Perfect parity with every desktop feature (we’ll ship in phases).
+
+## Core idea: **Screens**, not racks
+
+On mobile, the primary constraint isn’t layout; it’s attention and space.
+
+So mobile becomes a **single active screen** at a time (full-height), plus:
+
+- A **bottom nav** for pinned screens
+- A **More** drawer/sheet for everything else
+- **Overlays** (bottom sheet / fullscreen modal) for transient flows
+
+We still preserve modularity by keeping the registration model the same:
+
+- Desktop: panels can be docked and arranged in racks
+- Mobile: the same panel ids become **screens** or **tools**
+
+## Terminology
+
+- **Screen**: a full-height mobile surface (Hives feed, Chat, People, Maps, Library, Instance, Moderation).
+- **Tool**: a smaller surface shown as an overlay (Profile, Composer, Plugin Rack).
+- **Pinned**: a screen shown in bottom nav.
+- **More**: the place you can find and open any available surface.
+
+## Information architecture
+
+### Bottom nav (pinned screens)
+
+Default (non-mod):
+- Hives
+- Chat
+- People
+- Maps (if installed) or Library
+- More
+
+Default (mod):
+- Hives
+- Chat
+- Moderation
+- People
+- More
+
+Rules:
+- Max **4 pinned** + **More**.
+- Long-press (or Edit mode) to reorder / replace pinned.
+- Pinned screens show optional badges (unread / mention).
+
+### More sheet
+
+“More” opens a sheet with:
+- A searchable list of **all available surfaces**
+- Grouping:
+ - Core
+ - Plugins
+ - Tools (overlays)
+ - Moderation (mods only)
+- Docked/hidden items (desktop hotbar equivalent) live here too on mobile.
+
+## Interaction model
+
+### Navigation
+
+- Tap a pinned icon → opens that screen.
+- Tap “More” → opens the surface list sheet.
+- Tap a surface in “More” → opens that screen (or tool overlay).
+
+### Back behavior
+
+Recommended behavior:
+- Hardware back / top-left back:
+ - If a tool overlay is open → close it.
+ - Else if the More sheet is open → close it.
+ - Else → go to previous screen in history (small stack), otherwise do nothing.
+
+### Chat thread selection
+
+Mobile should avoid spawning multiple chat instances by default.
+
+- Tapping “Chat” on a hive post:
+ - If Chat screen is open and empty → fill it.
+ - Else → switch Chat screen to the requested thread.
+- Advanced: optional “multi-chat” power mode can still exist, but mobile default should be **single chat context**.
+
+### Transient flows as overlays
+
+These should not “steal” the whole navigation permanently:
+
+- **Composer**: bottom sheet on phones; fullscreen on very small viewports.
+- **Profile**: fullscreen modal (with its own internal scroll).
+- **Plugin Rack**: fullscreen modal or tall bottom sheet depending on content.
+
+## Layout rules (responsive)
+
+We should not assume desktop-like columns.
+
+Mobile containers should:
+- Use `min-width: 0` everywhere
+- Avoid fixed widths; rely on `%`, `clamp()`, and container constraints
+- Prefer vertical stacking and bottom sheets
+
+## State model (MVP)
+
+Persist as JSON in `localStorage` (per user/device) under something like `bzl_mobile_layout_v1`:
+
+```json
+{
+ "version": 1,
+ "pinned": ["hives", "chat", "people", "maps"],
+ "active": "hives",
+ "history": ["hives", "chat"],
+ "tools": {
+ "composerOpen": false,
+ "profileOpen": false,
+ "pluginRackOpen": false
+ }
+}
+```
+
+Notes:
+- Missing panels should be ignored safely (plugin removed).
+- If a pinned panel isn’t available, substitute a reasonable fallback.
+- Moderation only appears if the user can moderate.
+
+## Mapping desktop panels → mobile behavior
+
+Recommended defaults:
+
+- Primary/core screens:
+ - `hives` → screen
+ - `chat` → screen
+ - `people` → screen
+ - `maps` → screen (if installed)
+ - `library` → screen
+ - `instance` → screen (maybe inside More)
+ - `moderation` → screen (mods only)
+
+- Tools (overlays):
+ - `profile` → tool overlay
+ - `composer` → tool overlay
+ - `pluginRack` → tool overlay
+
+## Plugin integration (draft)
+
+Plugins already register panels; mobile needs one extra hint so we don’t overwhelm “More” or pin tool-like widgets by default.
+
+Proposed addition to `ctx.ui.registerPanel()` (draft):
+
+```js
+ctx.ui.registerPanel({
+ id: "radio",
+ title: "Radio",
+ defaultRack: "main",
+ role: "utility",
+ mobileHint: "tool" // "screen" | "tool" | "hidden"
+});
+```
+
+Defaults:
+- `role: "primary"` → `mobileHint: "screen"`
+- everything else → `mobileHint: "tool"`
+
+## Phased implementation plan
+
+### Phase 1: Mobile shell + core screens
+
+- Add a mobile UI shell: `ScreenHost`, bottom nav, More sheet.
+- Implement navigation + persistence (`pinned`, `active`, basic history).
+- Hook up the core screens:
+ - Hives, Chat, People, Instance
+ - Maps/Library if installed
+ - Moderation for mods
+
+Acceptance:
+- Works on small phone widths without horizontal overflow.
+- Can switch between Hives/Chat/People quickly.
+
+### Phase 2: Tools as overlays
+
+- Composer as a bottom sheet / fullscreen modal (viewport-dependent).
+- Profile as fullscreen modal.
+- Plugin Rack as tool overlay (stacking widgets inside).
+
+Acceptance:
+- Tools open/close cleanly and don’t break navigation.
+
+### Phase 3: Polishing + badges + shortcuts
+
+- Unread / mention badges in bottom nav and More list.
+- Search in More.
+- “Edit pinned” mode.
+
+Acceptance:
+- Users can tailor bottom nav to their usage.
+
+## Open questions
+
+- Should mobile share “layout presets”, or is pinned nav personalization enough?
+- Should plugin widgets be allowed as pinned screens, or restricted to tools by default?
+- Do we want a “focus mode” (hide nav bar while scrolling, reveal on tap)?
+- How do we handle Maps + associated chat (global/local) on small screens (tab within chat vs inline on map)?
+
diff --git a/docs/STREAM_PACK.md b/docs/STREAM_PACK.md
@@ -0,0 +1,33 @@
+# Stream Pack (optional, dedicated servers)
+
+Bzl core is designed to run without any domain name. **Streaming** (game/screen share + voice) is different: it needs a real-time media server and **HTTPS**.
+
+This repo ships an **optional “Stream Pack”** that you can install on dedicated servers. It runs:
+- **LiveKit** (SFU) for scalable WebRTC (one streamer, many viewers)
+- **coturn** for NAT traversal reliability
+
+Core Bzl remains unchanged: if you don’t install Stream Pack, everything still works (just no streaming).
+
+## Quick start
+
+1. Decide a hostname for streaming (example): `stream.yourdomain.com`
+2. Create DNS A record to your server (set to **DNS-only**, not proxied).
+3. Generate the pack:
+
+```bash
+node scripts/stream-pack-init.js --domain=stream.yourdomain.com --email=you@yourdomain.com
+```
+
+4. Edit `stream_pack/.env` and set `TURN_EXTERNAL_IP` to your public server IP.
+5. Add `stream_pack/Caddyfile.snippet` to your Caddy config and reload.
+6. Open firewall ports listed in `stream_pack/README.md`.
+7. Start it:
+
+```bash
+cd stream_pack
+docker compose up -d
+```
+
+## Notes
+- The Stream Pack is infrastructure only. The actual “stream as a post” UX will be implemented as a plugin that mints LiveKit tokens from the Bzl server.
+
diff --git a/docs/UI_RACK_LAYOUT.md b/docs/UI_RACK_LAYOUT.md
@@ -381,3 +381,9 @@ When the rack layout exists:
- Should panel layout persist per-device or per-user (server-side)?
- How do we expose keyboard shortcuts for focusing panels?
- What is the best “default” panel set for first-time users?
+
+## Mobile (separate model)
+
+Rack layout is desktop-first. On mobile, we should treat panels as **screens + tools** (single active surface with a bottom nav + “More” sheet), while keeping the same panel registration model underneath.
+
+See `docs/MOBILE_UX.md`.
diff --git a/package.json b/package.json
@@ -18,7 +18,12 @@
"restore-data": "node scripts/restore-data.js",
"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:directory-publisher": "node scripts/build-directory-publisher-plugin.js",
+ "stream:init": "node scripts/stream-pack-init.js",
+ "stream:detect-ip": "node scripts/stream-pack-detect-ip.js",
+ "stream:up": "node scripts/stream-pack-up.js",
+ "stream:down": "node scripts/stream-pack-down.js",
+ "stream:status": "node scripts/stream-pack-status.js"
},
"dependencies": {
"adm-zip": "^0.5.16",
diff --git a/plugins_dev/directory-publisher/client.js b/plugins_dev/directory-publisher/client.js
@@ -1,5 +1,120 @@
window.BzlPluginHost.register("directory-publisher", (ctx) => {
ctx.devLog("info", "directory-publisher client loaded");
- // UI draft: config + publish button will be added once the directory UX is finalized.
-});
+ let mountEl = null;
+ let config = null;
+ let lastResult = null;
+
+ const el = (tag, props = {}, children = []) => {
+ const node = document.createElement(tag);
+ for (const [k, v] of Object.entries(props || {})) {
+ if (k === "className") node.className = String(v || "");
+ else if (k === "text") node.textContent = String(v ?? "");
+ else if (k.startsWith("on") && typeof v === "function") node.addEventListener(k.slice(2).toLowerCase(), v);
+ else if (v === false || v == null) continue;
+ else node.setAttribute(k, String(v));
+ }
+ for (const c of children) node.appendChild(c);
+ return node;
+ };
+
+ const safe = (v) => String(v ?? "");
+
+ function render() {
+ if (!mountEl) return;
+ mountEl.innerHTML = "";
+
+ const c = config && typeof config === "object" ? config : {};
+ const inst = c.instance && typeof c.instance === "object" ? c.instance : {};
+
+ const directoryUrl = el("input", { value: safe(c.directoryUrl), placeholder: "https://chat.bzl.one" });
+ const token = el("input", { value: safe(c.token), placeholder: "Directory token (shared secret)" });
+
+ const id = el("input", { value: safe(inst.id), placeholder: "instance id (e.g. temple)" });
+ const url = el("input", { value: safe(inst.url), placeholder: "https://your.instance" });
+ const name = el("input", { value: safe(inst.name), placeholder: "Display name" });
+ const description = el("input", { value: safe(inst.description), placeholder: "Short description" });
+ const bzlVersion = el("input", { value: safe(inst.bzlVersion), placeholder: "bzl version (optional)" });
+ const requiresReg = el("input", { type: "checkbox", checked: inst.requiresRegistrationCode ? "checked" : null });
+
+ const statusText =
+ lastResult && typeof lastResult === "object"
+ ? lastResult.ok
+ ? `Published (HTTP ${lastResult.status || 200})`
+ : `Publish failed: ${safe(lastResult.error || lastResult.body || `HTTP ${lastResult.status || 0}`)}`
+ : "";
+
+ const status = statusText ? el("div", { className: lastResult?.ok ? "good small" : "bad small", text: statusText, style: "margin-top:10px" }) : null;
+
+ const saveBtn = el("button", {
+ type: "button",
+ className: "primary",
+ text: "Save",
+ onclick: () => {
+ ctx.send("setConfig", {
+ config: {
+ directoryUrl: safe(directoryUrl.value).trim(),
+ token: safe(token.value).trim(),
+ instance: {
+ id: safe(id.value).trim(),
+ url: safe(url.value).trim(),
+ name: safe(name.value).trim(),
+ description: safe(description.value).trim(),
+ bzlVersion: safe(bzlVersion.value).trim(),
+ requiresRegistrationCode: Boolean(requiresReg.checked),
+ },
+ },
+ });
+ },
+ });
+
+ const publishBtn = el("button", { type: "button", className: "ghost", text: "Publish now", onclick: () => ctx.send("publishNow", {}) });
+
+ mountEl.appendChild(
+ el("div", { className: "panel", style: "padding:12px" }, [
+ el("div", { text: "Directory publisher", style: "font-weight:700; margin-bottom:8px" }),
+ el("div", { className: "muted small", text: "Announces this instance to a directory (owner-only)." }),
+ el("div", { className: "row", style: "gap:10px; margin-top:10px" }, [
+ el("label", { style: "flex:1" }, [el("span", { className: "muted small", text: "Directory URL" }), directoryUrl]),
+ ]),
+ el("div", { className: "row", style: "gap:10px; margin-top:10px" }, [el("label", { style: "flex:1" }, [el("span", { className: "muted small", text: "Token" }), token])] ),
+ el("div", { className: "muted small", text: "Instance metadata", style: "margin-top:14px" }),
+ el("div", { className: "row", style: "gap:10px; margin-top:8px" }, [
+ el("label", { style: "flex:1" }, [el("span", { className: "muted small", text: "Instance id" }), id]),
+ el("label", { style: "flex:1" }, [el("span", { className: "muted small", text: "Instance URL" }), url]),
+ ]),
+ el("div", { className: "row", style: "gap:10px; margin-top:10px" }, [
+ el("label", { style: "flex:1" }, [el("span", { className: "muted small", text: "Name" }), name]),
+ el("label", { style: "flex:1" }, [el("span", { className: "muted small", text: "Bzl version" }), bzlVersion]),
+ ]),
+ el("div", { className: "row", style: "gap:10px; margin-top:10px" }, [el("label", { style: "flex:1" }, [el("span", { className: "muted small", text: "Description" }), description])] ),
+ el("label", { className: "row small", style: "gap:10px; align-items:center; margin-top:10px" }, [requiresReg, el("span", { text: "Requires registration code" })]),
+ el("div", { className: "row", style: "gap:10px; margin-top:14px" }, [saveBtn, publishBtn]),
+ ...(status ? [status] : []),
+ ])
+ );
+ }
+
+ ctx.on("config", (msg) => {
+ config = msg?.config || null;
+ render();
+ });
+ ctx.on("configSaved", () => {
+ ctx.toast("Saved", "Directory publisher config saved.");
+ });
+ ctx.on("result", (msg) => {
+ lastResult = msg || null;
+ render();
+ });
+
+ ctx.ui.registerModTab({
+ id: "directory",
+ title: "Directory publish",
+ ownerOnly: true,
+ render(mount) {
+ mountEl = mount;
+ render();
+ ctx.send("getConfig", {});
+ },
+ });
+});
diff --git a/plugins_dev/directory-publisher/server.js b/plugins_dev/directory-publisher/server.js
@@ -1,5 +1,6 @@
const fs = require("fs");
const path = require("path");
+const http = require("http");
const https = require("https");
const CONFIG_PATH = path.join(__dirname, "config.json");
@@ -39,12 +40,14 @@ function postJson(targetUrl, token, payload) {
return;
}
+ const transport = u.protocol === "http:" ? http : https;
+ const port = u.port ? Number(u.port) : u.protocol === "http:" ? 80 : 443;
const body = Buffer.from(JSON.stringify(payload), "utf8");
- const req = https.request(
+ const req = transport.request(
{
method: "POST",
hostname: u.hostname,
- port: u.port || 443,
+ port,
path: u.pathname + u.search,
headers: {
"Content-Type": "application/json",
@@ -105,4 +108,3 @@ module.exports = function init(api) {
api.log("info", "directory-publisher loaded");
};
-
diff --git a/plugins_dev/directory-server/client.js b/plugins_dev/directory-server/client.js
@@ -1,4 +1,292 @@
window.BzlPluginHost.register("directory-server", (ctx) => {
ctx.devLog("info", "directory-server client loaded");
-});
+ const pluginId = ctx.id;
+ const apiBase = `/api/plugins/${encodeURIComponent(pluginId)}`;
+
+ let panelMount = null;
+ let modMount = null;
+ let lastList = [];
+ let lastConfig = null;
+ let lastEntries = [];
+
+ const el = (tag, props = {}, children = []) => {
+ const node = document.createElement(tag);
+ for (const [k, v] of Object.entries(props || {})) {
+ if (k === "className") node.className = String(v || "");
+ else if (k === "text") node.textContent = String(v ?? "");
+ else if (k === "html") node.innerHTML = String(v ?? "");
+ else if (k.startsWith("on") && typeof v === "function") node.addEventListener(k.slice(2).toLowerCase(), v);
+ else if (v === false || v == null) continue;
+ else node.setAttribute(k, String(v));
+ }
+ for (const c of children) node.appendChild(c);
+ return node;
+ };
+
+ const fmtTime = (t) => {
+ const n = Number(t || 0);
+ if (!n) return "-";
+ try {
+ return new Date(n).toLocaleString();
+ } catch {
+ return String(n);
+ }
+ };
+
+ const getHost = (rawUrl) => {
+ try {
+ return new URL(String(rawUrl || "")).hostname || "";
+ } catch {
+ return "";
+ }
+ };
+
+ async function loadPublicList() {
+ try {
+ const r = await fetch(`${apiBase}/list`, { cache: "no-store" });
+ const j = await r.json();
+ if (!j?.ok) throw new Error(j?.error || "Failed to load list");
+ lastList = Array.isArray(j.entries) ? j.entries : [];
+ } catch (e) {
+ ctx.devLog("warn", "directory list fetch failed", { error: e?.message || String(e) });
+ lastList = [];
+ }
+ renderDirectoryPanel();
+ }
+
+ function renderDirectoryPanel() {
+ if (!panelMount) return;
+ panelMount.innerHTML = "";
+
+ const top = el("div", { className: "row", style: "justify-content:space-between; align-items:center; gap:10px; margin-bottom:10px" }, [
+ el("div", { className: "row", style: "gap:10px; align-items:center" }, [
+ el("div", { text: "Directory", style: "font-weight:700" }),
+ el("div", { className: "muted small", text: `${lastList.length} instance${lastList.length === 1 ? "" : "s"}` }),
+ ]),
+ el("div", { className: "row", style: "gap:8px; align-items:center" }, [
+ el("button", {
+ type: "button",
+ className: "ghost",
+ text: "Refresh",
+ onclick: () => loadPublicList(),
+ }),
+ ]),
+ ]);
+
+ const list = el("div", { style: "display:flex; flex-direction:column; gap:10px" });
+ if (!lastList.length) {
+ list.appendChild(el("div", { className: "muted", text: "No directory entries yet." }));
+ } else {
+ for (const entry of lastList) {
+ const inst = entry?.instance || {};
+ const name = String(inst.name || inst.id || "Instance").slice(0, 80);
+ const url = String(inst.url || "");
+ const desc = String(inst.description || "").slice(0, 240);
+ const hives = Array.isArray(entry?.publicHives) ? entry.publicHives : [];
+
+ const card = el("div", { className: "panel", style: "padding:12px" });
+ card.appendChild(
+ el("div", { className: "row", style: "justify-content:space-between; gap:10px; align-items:flex-start" }, [
+ el("div", {}, [
+ el("div", { text: name, style: "font-weight:700; margin-bottom:2px" }),
+ el("div", { className: "muted small", text: url }),
+ ]),
+ el("a", { className: "ghost smallBtn", href: url || "#", target: "_blank", rel: "noreferrer", text: "Open" }),
+ ])
+ );
+ if (desc) card.appendChild(el("div", { className: "small", text: desc, style: "margin-top:8px" }));
+ card.appendChild(el("div", { className: "muted small", text: `Last seen: ${fmtTime(entry?.lastSeenAt)}`, style: "margin-top:8px" }));
+
+ if (hives.length) {
+ const ul = el("ul", { style: "margin:8px 0 0 18px" });
+ for (const h of hives.slice(0, 10)) {
+ const li = el("li", { className: "small" });
+ const a = el("a", { href: String(h?.url || "#"), target: "_blank", rel: "noreferrer", text: String(h?.title || "Hive") });
+ li.appendChild(a);
+ const hd = String(h?.description || "").trim();
+ if (hd) li.appendChild(el("span", { className: "muted", text: ` — ${hd}` }));
+ ul.appendChild(li);
+ }
+ card.appendChild(el("div", { className: "muted small", text: "Public hives:", style: "margin-top:10px" }));
+ card.appendChild(ul);
+ }
+
+ list.appendChild(card);
+ }
+ }
+
+ panelMount.appendChild(top);
+ panelMount.appendChild(list);
+ }
+
+ function renderMod() {
+ if (!modMount) return;
+ modMount.innerHTML = "";
+
+ const tokenSet = Boolean(lastConfig?.tokenSet);
+ const hiddenIds = Array.isArray(lastConfig?.hiddenIds) ? lastConfig.hiddenIds : [];
+ const blockedHosts = Array.isArray(lastConfig?.blockedHosts) ? lastConfig.blockedHosts : [];
+
+ const tokenInput = el("input", {
+ placeholder: tokenSet ? "Token is set (enter to replace)" : "Set directory token (shared secret)",
+ style: "flex:1",
+ });
+ const saveBtn = el("button", {
+ type: "button",
+ className: "primary",
+ text: "Save token",
+ onclick: () => {
+ const token = String(tokenInput.value || "").trim();
+ ctx.send("setToken", { token });
+ tokenInput.value = "";
+ },
+ });
+
+ modMount.appendChild(
+ el("div", { className: "panel", style: "padding:12px; margin-bottom:12px" }, [
+ el("div", { className: "row", style: "justify-content:space-between; align-items:center; gap:10px" }, [
+ el("div", {}, [el("div", { text: "Directory settings", style: "font-weight:700" }), el("div", { className: "muted small", text: tokenSet ? "Token: set" : "Token: not set" })]),
+ el("button", { type: "button", className: "ghost", text: "Refresh", onclick: () => ctx.send("getConfig", {}) }),
+ ]),
+ el("div", { className: "row", style: "gap:10px; margin-top:10px" }, [tokenInput, saveBtn]),
+ ])
+ );
+
+ const blockedWrap = el("div", { className: "panel", style: "padding:12px; margin-bottom:12px" });
+ blockedWrap.appendChild(el("div", { text: "Blocked hosts", style: "font-weight:700; margin-bottom:6px" }));
+ if (!blockedHosts.length) blockedWrap.appendChild(el("div", { className: "muted small", text: "None." }));
+ else {
+ const ul = el("ul", { style: "margin:0 0 0 18px" });
+ for (const host of blockedHosts.slice(0, 200)) {
+ const li = el("li", { className: "small" });
+ li.appendChild(el("span", { text: host }));
+ li.appendChild(
+ el("button", {
+ type: "button",
+ className: "ghost smallBtn",
+ text: "Unblock",
+ style: "margin-left:10px",
+ onclick: () => ctx.send("setBlockedHost", { host, blocked: false }),
+ })
+ );
+ ul.appendChild(li);
+ }
+ blockedWrap.appendChild(ul);
+ }
+ modMount.appendChild(blockedWrap);
+
+ const entriesWrap = el("div", { className: "panel", style: "padding:12px" });
+ entriesWrap.appendChild(
+ el("div", { className: "row", style: "justify-content:space-between; align-items:center; gap:10px; margin-bottom:8px" }, [
+ el("div", { text: "Entries", style: "font-weight:700" }),
+ el("button", { type: "button", className: "ghost", text: "Refresh", onclick: () => ctx.send("getEntries", {}) }),
+ ])
+ );
+
+ if (!lastEntries.length) {
+ entriesWrap.appendChild(el("div", { className: "muted small", text: "No entries yet." }));
+ } else {
+ const list = el("div", { style: "display:flex; flex-direction:column; gap:10px" });
+ for (const entry of lastEntries) {
+ const inst = entry?.instance || {};
+ const id = String(inst.id || "").trim().toLowerCase();
+ const name = String(inst.name || inst.id || "Instance").slice(0, 60);
+ const url = String(inst.url || "");
+ const host = getHost(url).toLowerCase();
+ const isHidden = Boolean(id && hiddenIds.includes(id));
+ const isBlocked = Boolean(host && blockedHosts.includes(host));
+
+ const row = el("div", { className: "row", style: "gap:10px; align-items:flex-start; justify-content:space-between" });
+ const left = el("div", { style: "flex:1; min-width:0" }, [
+ el("div", { text: name, style: "font-weight:700" }),
+ el("div", { className: "muted small", text: url }),
+ el("div", { className: "muted small", text: `Last seen: ${fmtTime(entry?.lastSeenAt)}` }),
+ ]);
+
+ const controls = el("div", { className: "row", style: "gap:8px; align-items:center; flex-wrap:wrap; justify-content:flex-end" });
+ const hideCb = el("input", {
+ type: "checkbox",
+ checked: isHidden ? "checked" : null,
+ onchange: (e) => ctx.send("setHidden", { id, hidden: Boolean(e?.target?.checked) }),
+ });
+ controls.appendChild(el("label", { className: "row small", style: "gap:8px; align-items:center" }, [hideCb, el("span", { text: "Hide" })]));
+ if (host) {
+ controls.appendChild(
+ el("button", {
+ type: "button",
+ className: isBlocked ? "ghost" : "ghost",
+ text: isBlocked ? "Blocked" : "Block host",
+ onclick: () => ctx.send("setBlockedHost", { host, blocked: true }),
+ disabled: isBlocked ? "disabled" : null,
+ })
+ );
+ }
+ controls.appendChild(
+ el("button", {
+ type: "button",
+ className: "ghost",
+ text: "Delete",
+ onclick: () => {
+ if (!confirm(`Delete entry \"${name}\"?`)) return;
+ ctx.send("deleteEntry", { id });
+ },
+ })
+ );
+
+ row.appendChild(left);
+ row.appendChild(controls);
+ list.appendChild(el("div", { style: "border-top:1px solid rgba(255,255,255,0.06); padding-top:10px" }, [row]));
+ }
+ entriesWrap.appendChild(list);
+ }
+
+ modMount.appendChild(entriesWrap);
+ }
+
+ ctx.on("updated", () => {
+ loadPublicList();
+ if (ctx.getRole() === "owner") ctx.send("getEntries", {});
+ });
+ ctx.on("config", (msg) => {
+ lastConfig = msg && typeof msg === "object" ? msg : null;
+ renderMod();
+ });
+ ctx.on("entries", (msg) => {
+ lastEntries = Array.isArray(msg?.entries) ? msg.entries : [];
+ renderMod();
+ });
+ ctx.on("configUpdated", () => {
+ if (ctx.getRole() === "owner") {
+ ctx.send("getConfig", {});
+ ctx.send("getEntries", {});
+ }
+ });
+
+ ctx.ui.registerPanel({
+ id: "directory",
+ title: "Directory",
+ icon: "📡",
+ defaultRack: "main",
+ role: "primary",
+ render(mount) {
+ panelMount = mount;
+ loadPublicList();
+ return () => {
+ if (panelMount === mount) panelMount = null;
+ };
+ },
+ });
+
+ ctx.ui.registerModTab({
+ id: "directory",
+ title: "Directory feed",
+ ownerOnly: true,
+ render(mount) {
+ modMount = mount;
+ renderMod();
+ ctx.send("getConfig", {});
+ ctx.send("getEntries", {});
+ },
+ });
+});
diff --git a/plugins_dev/directory-server/server.js b/plugins_dev/directory-server/server.js
@@ -65,10 +65,23 @@ function sanitizePublicHives(payload) {
}
module.exports = function init(api) {
- const config = readJson(CONFIG_PATH, { token: "" });
+ const config = readJson(CONFIG_PATH, { token: "", hiddenIds: [], blockedHosts: [] });
const state = readJson(STATE_PATH, { version: 1, entries: {} });
const entries = new Map(Object.entries(state.entries || {}));
+ const normalizeId = (s) => String(s || "").trim().toLowerCase();
+ const normalizeHost = (rawUrl) => {
+ try {
+ const u = new URL(String(rawUrl || ""));
+ return String(u.hostname || "").trim().toLowerCase();
+ } catch {
+ return "";
+ }
+ };
+
+ config.hiddenIds = Array.isArray(config.hiddenIds) ? config.hiddenIds.map(normalizeId).filter(Boolean) : [];
+ config.blockedHosts = Array.isArray(config.blockedHosts) ? config.blockedHosts.map((h) => String(h || "").trim().toLowerCase()).filter(Boolean) : [];
+
const persist = () => {
const out = { version: 1, entries: Object.fromEntries(entries) };
writeJson(STATE_PATH, out);
@@ -76,7 +89,13 @@ module.exports = function init(api) {
api.registerWs("getConfig", (ws) => {
if (ws?.user?.role !== "owner") return;
- api.sendToUsers([ws.user.username], { type: "plugin:directory-server:config", tokenSet: Boolean(config.token) });
+ api.sendToUsers([ws.user.username], {
+ type: "plugin:directory-server:config",
+ tokenSet: Boolean(config.token),
+ hiddenIds: config.hiddenIds.slice(0, 500),
+ blockedHosts: config.blockedHosts.slice(0, 500),
+ entryCount: entries.size
+ });
});
api.registerWs("setToken", (ws, msg) => {
@@ -87,9 +106,70 @@ module.exports = function init(api) {
api.broadcast({ type: "plugin:directory-server:configUpdated", tokenSet: Boolean(config.token) });
});
+ api.registerWs("getEntries", (ws) => {
+ if (ws?.user?.role !== "owner") return;
+ const list = Array.from(entries.values())
+ .map((e) => {
+ const host = normalizeHost(e?.instance?.url);
+ const id = normalizeId(e?.instance?.id);
+ return {
+ ...e,
+ host,
+ hidden: Boolean(id && config.hiddenIds.includes(id)),
+ blocked: Boolean(host && config.blockedHosts.includes(host))
+ };
+ })
+ .sort((a, b) => (b.lastSeenAt || 0) - (a.lastSeenAt || 0));
+ api.sendToUsers([ws.user.username], { type: "plugin:directory-server:entries", entries: list });
+ });
+
+ api.registerWs("setHidden", (ws, msg) => {
+ if (ws?.user?.role !== "owner") return;
+ const id = normalizeId(msg?.id);
+ const hidden = Boolean(msg?.hidden);
+ if (!id) return;
+ const set = new Set(config.hiddenIds);
+ if (hidden) set.add(id);
+ else set.delete(id);
+ config.hiddenIds = Array.from(set.values()).sort();
+ writeJson(CONFIG_PATH, config);
+ api.broadcast({ type: "plugin:directory-server:configUpdated", tokenSet: Boolean(config.token) });
+ });
+
+ api.registerWs("setBlockedHost", (ws, msg) => {
+ if (ws?.user?.role !== "owner") return;
+ const host = String(msg?.host || "").trim().toLowerCase();
+ const blocked = Boolean(msg?.blocked);
+ if (!host) return;
+ const set = new Set(config.blockedHosts);
+ if (blocked) set.add(host);
+ else set.delete(host);
+ config.blockedHosts = Array.from(set.values()).sort();
+ writeJson(CONFIG_PATH, config);
+ api.broadcast({ type: "plugin:directory-server:configUpdated", tokenSet: Boolean(config.token) });
+ });
+
+ api.registerWs("deleteEntry", (ws, msg) => {
+ if (ws?.user?.role !== "owner") return;
+ const id = normalizeId(msg?.id);
+ if (!id) return;
+ entries.delete(id);
+ persist();
+ api.broadcast({ type: "plugin:directory-server:updated", id, deleted: true });
+ });
+
api.registerHttp("GET", "/list", (_req, res, ctx) => {
+ const hidden = new Set(config.hiddenIds);
+ const blocked = new Set(config.blockedHosts);
const list = Array.from(entries.values())
.map((e) => e)
+ .filter((e) => {
+ const id = normalizeId(e?.instance?.id);
+ if (id && hidden.has(id)) return false;
+ const host = normalizeHost(e?.instance?.url);
+ if (host && blocked.has(host)) return false;
+ return true;
+ })
.sort((a, b) => (b.lastSeenAt || 0) - (a.lastSeenAt || 0));
ctx.sendJson(200, { ok: true, entries: list });
res.end();
@@ -120,4 +200,3 @@ module.exports = function init(api) {
api.log("info", "directory-server loaded", { http: ["/announce", "/list"] });
};
-
diff --git a/public/app.js b/public/app.js
@@ -4163,12 +4163,17 @@ function sendDevLog(level, scope, message, data) {
window.bzlDevLog = sendDevLog;
+// Plugin event handlers: pluginId -> eventName -> Set<fn(msg)>
+const pluginClientHandlers = new Map();
+// Moderation plugin tabs: fullTabId -> { title, ownerOnly, render(mount, api), pluginId }
+const modPluginTabs = new Map();
+
// Minimal plugin host (client-side). Plugins are trusted by the owner who installs them.
// Plugin scripts can call `window.BzlPluginHost.register("pluginId", (ctx) => { ... })`.
if (!window.BzlPluginHost) {
const pluginInits = new Map();
window.BzlPluginHost = {
- apiVersion: 2,
+ apiVersion: 3,
register(pluginId, initFn) {
const id = String(pluginId || "").trim().toLowerCase();
if (!/^[a-z0-9][a-z0-9_.-]{0,31}$/.test(id)) throw new Error("Invalid plugin id");
@@ -4181,7 +4186,59 @@ if (!window.BzlPluginHost) {
toast,
getUser: () => loggedInUser,
getRole: () => loggedInRole,
+ on(eventName, handler) {
+ const ev = String(eventName || "").trim();
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/.test(ev)) throw new Error("Invalid event name");
+ if (typeof handler !== "function") throw new Error("handler must be a function");
+ let byEvent = pluginClientHandlers.get(id);
+ if (!byEvent) {
+ byEvent = new Map();
+ pluginClientHandlers.set(id, byEvent);
+ }
+ let set = byEvent.get(ev);
+ if (!set) {
+ set = new Set();
+ byEvent.set(ev, set);
+ }
+ set.add(handler);
+ return () => {
+ try {
+ set.delete(handler);
+ } catch {
+ // ignore
+ }
+ };
+ },
ui: {
+ registerModTab(tabDef) {
+ const tabId = String(tabDef?.id || id).trim().toLowerCase();
+ if (!/^[a-z0-9][a-z0-9_.-]{0,31}$/.test(tabId)) throw new Error("Invalid tab id");
+ const title = typeof tabDef?.title === "string" ? tabDef.title.trim().slice(0, 22) : tabId;
+ const ownerOnly = Boolean(tabDef?.ownerOnly);
+ const render = tabDef?.render;
+ if (typeof render !== "function") throw new Error("render must be a function");
+
+ const fullId = `plugin:${id}:${tabId}`;
+ modPluginTabs.set(fullId, { title, ownerOnly, render, pluginId: id });
+
+ const tabsEl = modPanelEl?.querySelector?.(".modTabs");
+ if (tabsEl && !tabsEl.querySelector(`[data-modtab="${CSS.escape(fullId)}"]`)) {
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "ghost";
+ btn.textContent = title;
+ btn.setAttribute("data-modtab", fullId);
+ btn.dataset.ownerOnly = ownerOnly ? "1" : "0";
+ tabsEl.appendChild(btn);
+ }
+
+ // If the tab isn't visible for this user, don't allow it to become active.
+ if (ownerOnly && loggedInRole !== "owner" && modTab === fullId) {
+ modTab = "server";
+ renderModPanel();
+ }
+ return true;
+ },
registerPanel(panelDef) {
const panelId = String(panelDef?.id || id).trim().toLowerCase();
if (!/^[a-z0-9][a-z0-9_.-]{0,31}$/.test(panelId)) throw new Error("Invalid panel id");
@@ -5546,6 +5603,49 @@ function renderModPanel() {
const on = btn.getAttribute("data-modtab") === modTab;
btn.classList.toggle("primary", on);
btn.classList.toggle("ghost", !on);
+ // Owner-only plugin tabs should not show for non-owners.
+ const ownerOnly = btn.dataset.ownerOnly === "1";
+ btn.classList.toggle("hidden", Boolean(ownerOnly && loggedInRole !== "owner"));
+ }
+
+ // Plugin-provided moderation tabs (render into modBody).
+ if (modPluginTabs.has(modTab)) {
+ const def = modPluginTabs.get(modTab);
+ if (def?.ownerOnly && loggedInRole !== "owner") {
+ modTab = "server";
+ renderModPanel();
+ return;
+ }
+ modBodyEl.innerHTML = `
+ <div class="modCard">
+ <div class="modRowTop"><div><b>${escapeHtml(def?.title || "Plugin")}</b></div></div>
+ <div id="modPluginMount" class="modActions"></div>
+ </div>
+ `;
+ const mount = modBodyEl.querySelector("#modPluginMount");
+ if (mount) {
+ const api = {
+ toast,
+ send: (eventName, payload) => {
+ const ev = String(eventName || "").trim();
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/.test(ev)) return false;
+ const wsRef = window.__bzlWs;
+ if (!wsRef || wsRef.readyState !== WebSocket.OPEN) return false;
+ const msg = payload && typeof payload === "object" ? payload : {};
+ wsRef.send(JSON.stringify({ ...msg, type: `plugin:${def.pluginId}:${ev}` }));
+ return true;
+ },
+ getUser: () => loggedInUser,
+ getRole: () => loggedInRole,
+ };
+ try {
+ def.render(mount, api);
+ } catch (e) {
+ mount.textContent = "Failed to render plugin tab.";
+ console.warn(`Plugin tab render failed (${modTab}):`, e?.message || e);
+ }
+ }
+ return;
}
if (modTab === "server") {
@@ -8698,6 +8798,28 @@ function onWsMessage(evt) {
return;
}
+ // Generic plugin event dispatch: `plugin:<pluginId>:<eventName>`
+ // (Maps has some core-handled messages below; for other plugins, dispatch + stop.)
+ if (typeof msg.type === "string") {
+ const m = msg.type.match(/^plugin:([a-z0-9][a-z0-9_.-]{0,31}):([a-zA-Z0-9][a-zA-Z0-9_.-]{0,63})$/);
+ if (m) {
+ const pluginId = String(m[1] || "").toLowerCase();
+ const ev = String(m[2] || "");
+ const byEvent = pluginClientHandlers.get(pluginId);
+ const set = byEvent ? byEvent.get(ev) : null;
+ if (set && set.size) {
+ for (const fn of Array.from(set)) {
+ try {
+ fn(msg);
+ } catch (e) {
+ console.warn(`Plugin handler failed (${pluginId}:${ev}):`, e?.message || e);
+ }
+ }
+ }
+ if (pluginId !== "maps") return;
+ }
+ }
+
if (msg.type === "plugin:maps:joinOk") {
const map = msg.map && typeof msg.map === "object" ? msg.map : null;
const mapId = map && typeof map.id === "string" ? map.id.trim().toLowerCase() : "";
diff --git a/public/index.html b/public/index.html
@@ -534,6 +534,6 @@
</div>
<div id="dockHotbar" class="dockHotbar hidden" aria-label="Docked panels"></div>
- <script src="/app.js?v=120"></script>
+ <script src="/app.js?v=121"></script>
</body>
</html>
diff --git a/scripts/stream-pack-detect-ip.js b/scripts/stream-pack-detect-ip.js
@@ -0,0 +1,62 @@
+const https = require("https");
+
+function getJson(url, { timeoutMs = 2500 } = {}) {
+ return new Promise((resolve) => {
+ let settled = false;
+ const done = (v) => {
+ if (settled) return;
+ settled = true;
+ resolve(v);
+ };
+ try {
+ const u = new URL(url);
+ const req = https.request(
+ {
+ method: "GET",
+ protocol: u.protocol,
+ hostname: u.hostname,
+ port: u.port,
+ path: `${u.pathname || "/"}${u.search || ""}`,
+ headers: { Accept: "application/json", "Cache-Control": "no-store" }
+ },
+ (res) => {
+ let buf = "";
+ res.setEncoding("utf8");
+ res.on("data", (c) => (buf += c));
+ res.on("end", () => {
+ try {
+ done({ ok: true, status: res.statusCode || 0, json: JSON.parse(buf || "{}") });
+ } catch {
+ done({ ok: false, status: res.statusCode || 0 });
+ }
+ });
+ }
+ );
+ req.on("error", (e) => done({ ok: false, error: e?.message || String(e) }));
+ req.setTimeout(timeoutMs, () => {
+ try {
+ req.destroy(new Error("timeout"));
+ } catch {
+ // ignore
+ }
+ done({ ok: false, timeout: true });
+ });
+ req.end();
+ } catch (e) {
+ done({ ok: false, error: e?.message || String(e) });
+ }
+ });
+}
+
+async function main() {
+ const r = await getJson("https://api.ipify.org?format=json");
+ const ip = String(r?.json?.ip || "").trim();
+ if (!ip) {
+ console.error("[stream-pack] Failed to detect IP.");
+ process.exit(1);
+ }
+ console.log(ip);
+}
+
+main();
+
diff --git a/scripts/stream-pack-down.js b/scripts/stream-pack-down.js
@@ -0,0 +1,25 @@
+const { spawnSync } = require("child_process");
+const fs = require("fs");
+const path = require("path");
+
+const ROOT = path.join(__dirname, "..");
+const PACK_DIR = path.join(ROOT, "stream_pack");
+
+function run(cmd, args, opts = {}) {
+ return spawnSync(cmd, args, { stdio: "inherit", cwd: PACK_DIR, ...opts });
+}
+
+function main() {
+ if (!fs.existsSync(path.join(PACK_DIR, "docker-compose.yml"))) {
+ console.error("[stream-pack] Missing stream_pack/docker-compose.yml.");
+ process.exit(1);
+ }
+
+ let r = run("docker", ["compose", "down"]);
+ if (r.status === 0) return;
+ r = run("docker-compose", ["down"]);
+ process.exit(r.status || 1);
+}
+
+main();
+
diff --git a/scripts/stream-pack-init.js b/scripts/stream-pack-init.js
@@ -0,0 +1,317 @@
+const fs = require("fs");
+const path = require("path");
+const crypto = require("crypto");
+const https = require("https");
+
+const ROOT = path.join(__dirname, "..");
+const PACK_DIR = path.join(ROOT, "stream_pack");
+
+function log(msg) {
+ console.log(`[stream-pack] ${msg}`);
+}
+
+function fail(msg) {
+ console.error(`[stream-pack] ERROR: ${msg}`);
+ process.exit(1);
+}
+
+function parseArgs() {
+ const args = process.argv.slice(2);
+ const out = {};
+ for (const a of args) {
+ const m = String(a || "").match(/^--([^=]+)=(.*)$/);
+ if (m) out[m[1]] = m[2];
+ }
+ return out;
+}
+
+function randHex(bytes) {
+ return crypto.randomBytes(bytes).toString("hex");
+}
+
+function randToken(bytes) {
+ return crypto.randomBytes(bytes).toString("base64url");
+}
+
+function normalizeDomain(s) {
+ const v = String(s || "").trim().toLowerCase();
+ if (!v) return "";
+ if (!/^[a-z0-9.-]+$/.test(v)) return "";
+ if (!v.includes(".")) return "";
+ return v;
+}
+
+function getJson(url, { timeoutMs = 2500 } = {}) {
+ return new Promise((resolve) => {
+ let settled = false;
+ const done = (v) => {
+ if (settled) return;
+ settled = true;
+ resolve(v);
+ };
+ try {
+ const u = new URL(url);
+ const req = https.request(
+ {
+ method: "GET",
+ protocol: u.protocol,
+ hostname: u.hostname,
+ port: u.port,
+ path: `${u.pathname || "/"}${u.search || ""}`,
+ headers: { Accept: "application/json", "Cache-Control": "no-store" }
+ },
+ (res) => {
+ let buf = "";
+ res.setEncoding("utf8");
+ res.on("data", (c) => (buf += c));
+ res.on("end", () => {
+ try {
+ done({ ok: true, status: res.statusCode || 0, json: JSON.parse(buf || "{}") });
+ } catch {
+ done({ ok: true, status: res.statusCode || 0, json: {} });
+ }
+ });
+ }
+ );
+ req.on("error", (e) => done({ ok: false, error: e?.message || String(e) }));
+ req.setTimeout(timeoutMs, () => {
+ try {
+ req.destroy(new Error("timeout"));
+ } catch {
+ // ignore
+ }
+ done({ ok: false, timeout: true });
+ });
+ req.end();
+ } catch (e) {
+ done({ ok: false, error: e?.message || String(e) });
+ }
+ });
+}
+
+function writeFileIfMissing(filePath, content) {
+ if (fs.existsSync(filePath)) return false;
+ fs.writeFileSync(filePath, content, "utf8");
+ return true;
+}
+
+function writeFileAlways(filePath, content) {
+ fs.writeFileSync(filePath, content, "utf8");
+}
+
+function readEnvFile(envPath) {
+ try {
+ const raw = fs.readFileSync(envPath, "utf8");
+ const out = {};
+ for (const line of raw.split(/\r?\n/)) {
+ const s = line.trim();
+ if (!s || s.startsWith("#")) continue;
+ const m = s.match(/^([A-Z0-9_]+)=(.*)$/);
+ if (!m) continue;
+ let v = m[2] || "";
+ v = v.trim();
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
+ out[m[1]] = v;
+ }
+ return out;
+ } catch {
+ return {};
+ }
+}
+
+function main() {
+ const args = parseArgs();
+ const domain = normalizeDomain(args.domain);
+ const email = String(args.email || "").trim();
+
+ if (!domain) {
+ fail('Missing/invalid `--domain=...` (example: --domain=stream.example.com)');
+ }
+ if (!email || !email.includes("@")) {
+ fail('Missing/invalid `--email=...` (needed for HTTPS certs; example: --email=you@example.com)');
+ }
+
+ fs.mkdirSync(PACK_DIR, { recursive: true });
+
+ const envPath = path.join(PACK_DIR, ".env");
+ const existing = readEnvFile(envPath);
+
+ const API_KEY = existing.LIVEKIT_API_KEY || randToken(8);
+ const API_SECRET = existing.LIVEKIT_API_SECRET || randToken(24);
+ const TURN_USER = existing.TURN_USER || "bzl";
+ const TURN_PASS = existing.TURN_PASS || randToken(18);
+ const TURN_REALM = existing.TURN_REALM || domain;
+ const TURN_EXTERNAL_IP = existing.TURN_EXTERNAL_IP || "";
+ const RTC_UDP_START = existing.RTC_UDP_START || "50000";
+ const RTC_UDP_END = existing.RTC_UDP_END || "50100";
+ const TURN_RELAY_START = existing.TURN_RELAY_START || "49160";
+ const TURN_RELAY_END = existing.TURN_RELAY_END || "49200";
+
+ const envBody = `# Stream Pack (optional)
+#
+# This stack is separate from core Bzl. Core stays runnable without any domain.
+# Streaming requires HTTPS and a public server.
+
+STREAM_DOMAIN=${domain}
+STREAM_EMAIL=${email}
+
+# LiveKit auth (Bzl will mint JWTs with these)
+LIVEKIT_API_KEY=${API_KEY}
+LIVEKIT_API_SECRET=${API_SECRET}
+
+# TURN (coturn) credentials for LiveKit clients
+TURN_USER=${TURN_USER}
+TURN_PASS=${TURN_PASS}
+TURN_REALM=${TURN_REALM}
+TURN_EXTERNAL_IP=${TURN_EXTERNAL_IP}
+
+# WebRTC UDP port range exposed by livekit-server (keep small for firewall sanity)
+RTC_UDP_START=${RTC_UDP_START}
+RTC_UDP_END=${RTC_UDP_END}
+
+# TURN relay UDP ports (coturn will allocate from this range)
+TURN_RELAY_START=${TURN_RELAY_START}
+TURN_RELAY_END=${TURN_RELAY_END}
+`;
+
+ if (!fs.existsSync(envPath)) {
+ writeFileAlways(envPath, envBody);
+ log(`Wrote ${path.relative(ROOT, envPath)}`);
+ } else {
+ // Keep user edits; only ensure required keys exist.
+ const merged = { ...existing };
+ const ensure = (k, v) => {
+ if (!merged[k]) merged[k] = v;
+ };
+ ensure("STREAM_DOMAIN", domain);
+ ensure("STREAM_EMAIL", email);
+ ensure("LIVEKIT_API_KEY", API_KEY);
+ ensure("LIVEKIT_API_SECRET", API_SECRET);
+ ensure("TURN_USER", TURN_USER);
+ ensure("TURN_PASS", TURN_PASS);
+ ensure("TURN_REALM", TURN_REALM);
+ ensure("TURN_EXTERNAL_IP", TURN_EXTERNAL_IP);
+ ensure("RTC_UDP_START", RTC_UDP_START);
+ ensure("RTC_UDP_END", RTC_UDP_END);
+ ensure("TURN_RELAY_START", TURN_RELAY_START);
+ ensure("TURN_RELAY_END", TURN_RELAY_END);
+
+ const lines = [
+ "# Stream Pack (optional)",
+ "#",
+ "# This stack is separate from core Bzl. Core stays runnable without any domain.",
+ "# Streaming requires HTTPS and a public server.",
+ "",
+ `STREAM_DOMAIN=${merged.STREAM_DOMAIN}`,
+ `STREAM_EMAIL=${merged.STREAM_EMAIL}`,
+ "",
+ "# LiveKit auth (Bzl will mint JWTs with these)",
+ `LIVEKIT_API_KEY=${merged.LIVEKIT_API_KEY}`,
+ `LIVEKIT_API_SECRET=${merged.LIVEKIT_API_SECRET}`,
+ "",
+ "# TURN (coturn) credentials for LiveKit clients",
+ `TURN_USER=${merged.TURN_USER}`,
+ `TURN_PASS=${merged.TURN_PASS}`,
+ `TURN_REALM=${merged.TURN_REALM}`,
+ `TURN_EXTERNAL_IP=${merged.TURN_EXTERNAL_IP || ""}`,
+ "",
+ "# WebRTC UDP port range exposed by livekit-server (keep small for firewall sanity)",
+ `RTC_UDP_START=${merged.RTC_UDP_START}`,
+ `RTC_UDP_END=${merged.RTC_UDP_END}`,
+ "",
+ "# TURN relay UDP ports (coturn will allocate from this range)",
+ `TURN_RELAY_START=${merged.TURN_RELAY_START}`,
+ `TURN_RELAY_END=${merged.TURN_RELAY_END}`,
+ ""
+ ];
+ writeFileAlways(envPath, lines.join("\n"));
+ log(`Updated ${path.relative(ROOT, envPath)}`);
+ }
+
+ const livekitYamlPath = path.join(PACK_DIR, "livekit.yaml");
+ const livekitYaml = `port: 7880
+log_level: info
+
+rtc:
+ tcp_port: 7881
+ port_range_start: ${RTC_UDP_START}
+ port_range_end: ${RTC_UDP_END}
+ use_external_ip: true
+ turn_servers:
+ - host: ${domain}
+ port: 3478
+ protocol: udp
+ username: ${TURN_USER}
+ credential: ${TURN_PASS}
+
+keys:
+ ${API_KEY}: ${API_SECRET}
+`;
+ writeFileAlways(livekitYamlPath, livekitYaml);
+ log(`Wrote ${path.relative(ROOT, livekitYamlPath)}`);
+
+ const composePath = path.join(PACK_DIR, "docker-compose.yml");
+ const composeBody = `services:
+ livekit:
+ image: livekit/livekit-server:latest
+ container_name: bzl_livekit
+ restart: unless-stopped
+ command: --config /etc/livekit.yaml
+ volumes:
+ - ./livekit.yaml:/etc/livekit.yaml:ro
+ ports:
+ - "127.0.0.1:7880:7880"
+ - "7881:7881"
+ - "${RTC_UDP_START}-${RTC_UDP_END}:${RTC_UDP_START}-${RTC_UDP_END}/udp"
+
+ turn:
+ image: coturn/coturn:latest
+ container_name: bzl_turn
+ restart: unless-stopped
+ network_mode: host
+ command:
+ - -n
+ - --log-file=stdout
+ - --realm=${TURN_REALM}
+ - --fingerprint
+ - --lt-cred-mech
+ - --no-tls
+ - --no-dtls
+ - --no-cli
+ - --no-multicast-peers
+ - --no-loopback-peers
+ - --listening-port=3478
+ - --min-port=${TURN_RELAY_START}
+ - --max-port=${TURN_RELAY_END}
+ - --user=${TURN_USER}:${TURN_PASS}
+ - --external-ip=${TURN_EXTERNAL_IP}
+`;
+ writeFileAlways(composePath, composeBody);
+ log(`Wrote ${path.relative(ROOT, composePath)}`);
+
+ const caddySnippetPath = path.join(PACK_DIR, "Caddyfile.snippet");
+ const caddySnippet = `${domain} {
+ encode zstd gzip
+ reverse_proxy 127.0.0.1:7880
+}
+`;
+ writeFileAlways(caddySnippetPath, caddySnippet);
+ log(`Wrote ${path.relative(ROOT, caddySnippetPath)}`);
+
+ const readmePath = path.join(PACK_DIR, "README.md");
+ writeFileAlways(
+ readmePath,
+ `# Stream Pack (LiveKit + TURN)\n\nThis folder is generated by \`node scripts/stream-pack-init.js\`.\n\n## What this is\n- Optional infrastructure for the future Bzl streaming plugin.\n- Keeps core Bzl launchable without any domain name.\n- Uses LiveKit (SFU) + coturn for NAT traversal.\n\n## Requirements\n- Dedicated server with Docker + Docker Compose\n- HTTPS reverse proxy (Caddy recommended)\n- DNS A record: \`${domain}\` → your server\n - Important: set this record to **DNS-only** (no Cloudflare proxy), otherwise UDP will break.\n\n## 1) Set TURN external IP\nEdit \`.env\` and set:\n\n\`\`\nTURN_EXTERNAL_IP=<your_server_public_ipv4>\n\`\`\n\n## 2) Reverse proxy (HTTPS)\nAdd \`Caddyfile.snippet\` to your Caddy config, then reload Caddy.\n\n## 3) Open firewall ports\nMinimum ports:\n- TCP 80/443 (Caddy)\n- TCP 7881 (LiveKit TCP fallback)\n- UDP \`${RTC_UDP_START}-${RTC_UDP_END}\` (LiveKit media)\n- UDP 3478 (TURN)\n- UDP \`${TURN_RELAY_START}-${TURN_RELAY_END}\` (TURN relays)\n\n## 4) Start services\nFrom this folder:\n\n\`\`\ncd stream_pack\ndocker compose up -d\n\`\`\n\n## Notes\n- LiveKit signaling is kept on localhost:7880 and exposed via Caddy.\n- TURN uses host networking (best reliability).\n`
+ );
+ log(`Wrote ${path.relative(ROOT, readmePath)}`);
+
+ log("Done.");
+ log("Next: edit stream_pack/.env (TURN_EXTERNAL_IP), add the Caddy snippet, open firewall ports, then run `docker compose up -d`.");
+
+ if (!existing.TURN_EXTERNAL_IP) {
+ log("Optional: auto-detect public IP:");
+ log(" node scripts/stream-pack-detect-ip.js");
+ }
+}
+
+main();
diff --git a/scripts/stream-pack-status.js b/scripts/stream-pack-status.js
@@ -0,0 +1,25 @@
+const { spawnSync } = require("child_process");
+const fs = require("fs");
+const path = require("path");
+
+const ROOT = path.join(__dirname, "..");
+const PACK_DIR = path.join(ROOT, "stream_pack");
+
+function run(cmd, args, opts = {}) {
+ return spawnSync(cmd, args, { stdio: "inherit", cwd: PACK_DIR, ...opts });
+}
+
+function main() {
+ if (!fs.existsSync(path.join(PACK_DIR, "docker-compose.yml"))) {
+ console.error("[stream-pack] Missing stream_pack/docker-compose.yml.");
+ process.exit(1);
+ }
+
+ let r = run("docker", ["compose", "ps"]);
+ if (r.status === 0) return;
+ r = run("docker-compose", ["ps"]);
+ process.exit(r.status || 1);
+}
+
+main();
+
diff --git a/scripts/stream-pack-up.js b/scripts/stream-pack-up.js
@@ -0,0 +1,28 @@
+const { spawnSync } = require("child_process");
+const fs = require("fs");
+const path = require("path");
+
+const ROOT = path.join(__dirname, "..");
+const PACK_DIR = path.join(ROOT, "stream_pack");
+
+function run(cmd, args, opts = {}) {
+ return spawnSync(cmd, args, { stdio: "inherit", cwd: PACK_DIR, ...opts });
+}
+
+function main() {
+ if (!fs.existsSync(path.join(PACK_DIR, "docker-compose.yml"))) {
+ console.error("[stream-pack] Missing stream_pack/docker-compose.yml. Run `node scripts/stream-pack-init.js ...` first.");
+ process.exit(1);
+ }
+
+ // Prefer modern docker compose
+ let r = run("docker", ["compose", "up", "-d"]);
+ if (r.status === 0) return;
+
+ // Fallback to legacy docker-compose
+ r = run("docker-compose", ["up", "-d"]);
+ process.exit(r.status || 1);
+}
+
+main();
+