Use a deno runtime to read the bs+ bsr queue file (Database.json)

This commit is contained in:
pleb 2026-04-11 16:13:45 -07:00
parent 4b4c74f31b
commit 4b0cf034bb
13 changed files with 432 additions and 34 deletions

4
.gitignore vendored
View File

@ -1 +1,3 @@
fonts/rajdhani-fontfacekit/ fonts/rajdhani-fontfacekit/
ChatRequest.json
chat-request-database.path

View File

@ -1,16 +1,21 @@
# AGENTS.md # AGENTS.md
Static OBS/browser overlay that reads Beat Saber Plus Song Overlay events over WebSocket (`ws://localhost:2947/socket`). OBS/browser overlay that reads Beat Saber Plus Song Overlay events over WebSocket (`ws://localhost:2947/socket`). Serve the folder with **`deno task serve`** (see [`serve.ts`](serve.ts)) so the request-queue JSON loads from the same origin; configure the BS+ database path via `CHAT_REQUEST_DATABASE` or `chat-request-database.path`.
## Preference: HTTP only, no `file://`
Do **not** add or maintain code paths for opening the overlay as **`file://`**. The client assumes **`http:` / `https:`** for fetching JSON (cache-busted `fetch`). Do not reintroduce XHR/`file:` fallbacks or docs that suggest local file URLs—one supported way: serve with Deno (or another HTTP server) per [`README.md`](README.md).
## Files of interest ## Files of interest
- [`index.html`](index.html) — Page shell: markup for map info, time bar, score, settings dialog; pulls `index.css` and `index.js`. - [`index.html`](index.html) — Page shell: markup for map info, time bar, score, settings dialog; pulls `index.css` and `index.js`.
- [`index.js`](index.js) — Connects to BS+ WebSocket, parses JSON events (`gameState`, `mapInfo`, `pause`, `resume`, `score`), updates DOM; optional BeatSaver API fetch for custom maps; reads/writes settings from URL hash. - [`index.js`](index.js) — Connects to BS+ WebSocket, parses JSON events (`gameState`, `mapInfo`, `pause`, `resume`, `score`), updates DOM; optional BeatSaver API fetch for custom maps; reads/writes settings from URL hash.
- [`index.css`](index.css) — Layout, theming, visibility toggles driven by body classes and CSS variables from settings. - [`index.css`](index.css) — Layout, theming, visibility toggles driven by body classes and CSS variables from settings.
- [`serve.ts`](serve.ts) — Deno static server + optional mapping of `ChatRequest.json` / `database.json` to the real BS+ `Database.json` path.
- [`types.d.ts`](types.d.ts) — JSDoc typedefs for BS+ payloads and events (editor/IDE hints only). - [`types.d.ts`](types.d.ts) — JSDoc typedefs for BS+ payloads and events (editor/IDE hints only).
- [`jsconfig.json`](jsconfig.json) — JS project roots/path so editors resolve modules and `types.d.ts`. - [`jsconfig.json`](jsconfig.json) — JS project roots/path so editors resolve modules and `types.d.ts`.
- [`images/`](images/) — Cover fallback (`unknown.svg`), characteristic icons under `images/characteristic/` (filenames match BS+ characteristic strings). - [`images/`](images/) — Cover fallback (`unknown.svg`), characteristic icons under `images/characteristic/` (filenames match BS+ characteristic strings).
- [`README.md`](README.md) — User-facing usage (hosted URL, OBS, local `file://`, BS+ module). - [`README.md`](README.md) — User-facing usage (Deno, OBS URL, BS+ module).
- [`dprint.json`](dprint.json) — Formatter config for the repo. - [`dprint.json`](dprint.json) — Formatter config for the repo.
- [`.editorconfig`](.editorconfig) — Basic indent/charset rules for editors. - [`.editorconfig`](.editorconfig) — Basic indent/charset rules for editors.

View File

@ -7,8 +7,25 @@ Requires [BeatSaberPlus](https://github.com/hardcpp/BeatSaberPlus)
![](images/screenshots/preview.png) ![](images/screenshots/preview.png)
## Setup
Install [Deno](https://docs.deno.com/runtime/getting_started/installation/) and serve the overlay over HTTP (see below).
The server must know where Beat Saber Plus stores **`ChatRequest/Database.json`**. It then serves that file as `ChatRequest.json` and `database.json` (same data) over HTTP—no symlink.
**Easiest:** copy `chat-request-database.path.example` to **`chat-request-database.path`** in this repo and put **one line**: the absolute path to `Database.json` (gitignored, so it stays on your machine).
**Or** set the environment variable (overrides the path file):
```powershell
$env:CHAT_REQUEST_DATABASE = "C:\Users\pleb\BSManager\BSInstances\1.40.8\UserData\BeatSaberPlus\ChatRequest\Database.json"
deno task serve
```
Then open **`http://127.0.0.1:8080/index.html`** (use the same host and port the terminal prints). Set `PORT` if needed. In OBS, use that URL for the browser source.
If neither the path file nor `CHAT_REQUEST_DATABASE` is set, the overlay only finds a queue if you place a `ChatRequest.json` copy in the repo folder.
### Usage ### Usage
Clone the repo to use the overlay locally. Clone the repo and run `deno task serve` as above.
Note: in OBS browser source, use URL `file:///C:/path-to-overlay.../index.html` instead of "Local file" so that URL parameters work.

View File

@ -0,0 +1 @@
C:\path\to\UserData\BeatSaberPlus\ChatRequest\Database.json

6
deno.json Normal file
View File

@ -0,0 +1,6 @@
{
"tasks": {
"serve": "deno run --allow-net --allow-read --allow-env serve.ts",
"dev": "deno run --watch --allow-net --allow-read --allow-env serve.ts"
}
}

66
deno.lock generated Normal file
View File

@ -0,0 +1,66 @@
{
"version": "5",
"specifiers": {
"jsr:@std/cli@^1.0.28": "1.0.28",
"jsr:@std/encoding@^1.0.10": "1.0.10",
"jsr:@std/fmt@^1.0.9": "1.0.9",
"jsr:@std/fs@^1.0.23": "1.0.23",
"jsr:@std/html@^1.0.5": "1.0.5",
"jsr:@std/http@*": "1.0.25",
"jsr:@std/internal@^1.0.12": "1.0.12",
"jsr:@std/media-types@^1.1.0": "1.1.0",
"jsr:@std/net@^1.0.6": "1.0.6",
"jsr:@std/path@*": "1.1.4",
"jsr:@std/path@^1.1.4": "1.1.4",
"jsr:@std/streams@^1.0.17": "1.0.17"
},
"jsr": {
"@std/cli@1.0.28": {
"integrity": "74ef9b976db59ca6b23a5283469c9072be6276853807a83ec6c7ce412135c70a"
},
"@std/encoding@1.0.10": {
"integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1"
},
"@std/fmt@1.0.9": {
"integrity": "2487343e8899fb2be5d0e3d35013e54477ada198854e52dd05ed0422eddcabe0"
},
"@std/fs@1.0.23": {
"integrity": "3ecbae4ce4fee03b180fa710caff36bb5adb66631c46a6460aaad49515565a37"
},
"@std/html@1.0.5": {
"integrity": "4e2d693f474cae8c16a920fa5e15a3b72267b94b84667f11a50c6dd1cb18d35e"
},
"@std/http@1.0.25": {
"integrity": "577b4252290af1097132812b339fffdd55fb0f4aeb98ff11bdbf67998aa17193",
"dependencies": [
"jsr:@std/cli",
"jsr:@std/encoding",
"jsr:@std/fmt",
"jsr:@std/fs",
"jsr:@std/html",
"jsr:@std/media-types",
"jsr:@std/net",
"jsr:@std/path@^1.1.4",
"jsr:@std/streams"
]
},
"@std/internal@1.0.12": {
"integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
},
"@std/media-types@1.1.0": {
"integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4"
},
"@std/net@1.0.6": {
"integrity": "110735f93e95bb9feb95790a8b1d1bf69ec0dc74f3f97a00a76ea5efea25500c"
},
"@std/path@1.1.4": {
"integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5",
"dependencies": [
"jsr:@std/internal"
]
},
"@std/streams@1.0.17": {
"integrity": "7859f3d9deed83cf4b41f19223d4a67661b3d3819e9fc117698f493bf5992140"
}
}
}

View File

@ -2,15 +2,14 @@
## Static web app with typed JavaScript ## Static web app with typed JavaScript
The overlay loads inside OBS from a URL or `file://` path, with no install step for streamers. The overlay is plain HTML/CSS/JS with no bundler: run it locally with **`deno task serve`** or host the files on any static server. OBS uses an **http(s) URL** to the page.
Consequences: Ease of use Consequences: Ease of use
- Adding or updating the overlay is **copy or point OBS at `index.html`** (hosted or local); nothing to build before use. - Adding or updating the overlay is **copy files or point OBS at a URL**; nothing to build before use beyond optional Deno for local serving and the chat-request database path.
- We avoid a toolchain that would complicate “drop this folder in” or quick Netlify-style hosting.
- Type safety is **optional IDE assistance**, not enforced at publish time (there is no `tsc` in CI by default). - Type safety is **optional IDE assistance**, not enforced at publish time (there is no `tsc` in CI by default).
This static, dependency-free shape exists **because** it stays easy to wire up as an OBS Browser Source while keeping the codebase maintainable through JSDoc and `types.d.ts`. This dependency-free client shape stays easy to wire as an OBS Browser Source while keeping the codebase maintainable through JSDoc and `types.d.ts`.
## BeatSaberPlus ## BeatSaberPlus

View File

@ -1,39 +1,37 @@
# Testing in a browser # Testing in a browser
The overlay is a static page. You can exercise the UI and wiring without OBS by opening it in any Chromium-based browser (Chrome, Edge) or Firefox. Run **`deno task serve`** from the repo (see [README](../README.md)), then open the overlay in Chromium (Chrome, Edge) or Firefox.
## Open the page ## Open the page
Use a **`file://` URL** (same idea as OBS): absolute path to `index.html`, forward slashes. This clone: Use the URL the server prints, for example:
`file:///C:/Users/example/ops/BeatSaber-Overlay/index.html?scale=1.5` `http://127.0.0.1:8080/index.html?scale=1.5`
Paste that into the address bar, or drag `index.html` into a browser window. Settings live in the **URL fragment** (after `#`). Put query parameters **before** the hash if you use both: `index.html?debug=1#…`
Settings are stored in the **URL fragment** (after `#`). Using a full `file://` URL (not only picking “Open file” in a way that strips the hash) keeps hash-based settings working, same idea as in the [README](../README.md). ## Preview the song overlay (no Beat Saber)
## Debug mode (no Beat Saber) **Click anywhere** on the page (outside the settings dialog) to toggle **preview** mode. The song overlay appears with the built-in placeholder labels so you can check layout, toggles, scale, and fade without a game connection.
Without the game, the overlay normally hides outside **Playing** (and during BeatSaver loading). Add a **query parameter** so it stays visible for layout or WebSocket checks: ## Request list simulation
- Enable: `?debug=1` Enable **Debug** in the settings dialog (or add **`?debug=1`** to the URL). The song requests list then uses the **`history`** array from the JSON instead of **`queue`**, so you can see how entries look with the same shape as real data (`key`, `rqn`, `npr`, etc.). The header title is unchanged.
- Example: `file:///C:/Users/example/ops/BeatSaber-Overlay/index.html?debug=1`
Put `?debug` **before** the `#` hash if you use both: `index.html?debug=1#…` The Deno server exposes Beat Saber Plus data as **`ChatRequest.json`** and **`database.json`** (same file). With debug, the page tries **`ChatRequest.json`** first, then **`database.json`**. To load a different filename, add **`?requests=yourfile.json`**.
## What you should see ## What you should see
- The overlay layout with placeholder labels until live data arrives.
- **Developer tools → Console:** log lines such as `Connecting to ws://localhost:2947/socket` on load. If [Beat Saber Plus](https://github.com/hardcpp/BeatSaberPlus) is **not** running with the Song Overlay module listening on that port, the socket will close and the script **retries every 10 seconds**—that is expected. - **Developer tools → Console:** log lines such as `Connecting to ws://localhost:2947/socket` on load. If [Beat Saber Plus](https://github.com/hardcpp/BeatSaberPlus) is **not** running with the Song Overlay module listening on that port, the socket will close and the script **retries every 10 seconds**—that is expected.
- **With Beat Saber running** and BS+ Song Overlay enabled: you should see `Connection open.` when the WebSocket succeeds, and map info, time, and score update while you play. - **With Beat Saber running** and BS+ Song Overlay enabled: you should see `Connection open.` when the WebSocket succeeds, and map info, time, and score update while you play.
## Quick UI checks without the game ## Quick UI checks without the game
- Click the page (outside the settings dialog) to toggle the **preview** / settings visibility. - **Click** the page (outside the settings dialog) to toggle **preview** and open the settings strip.
- Change checkboxes and values in the dialog; the **URL hash** should update and layout should reflect toggles and scale. - Change checkboxes and values in the dialog; the **URL hash** should update and layout should reflect toggles and scale.
## Live data path ## Live data path
End-to-end testing needs the same pieces as streaming: **Beat Saber**, **Beat Saber Plus** with the **Song Overlay** module active, so `ws://localhost:2947/socket` accepts connections. The browser page only connects to that local WebSocket; it does not start the server. End-to-end testing needs the same pieces as streaming: **Beat Saber**, **Beat Saber Plus** with the **Song Overlay** module active, so `ws://localhost:2947/socket` accepts connections. The browser page only connects to that local WebSocket; it does not start the game server.
For custom maps, the overlay may request **BeatSaver** over HTTPS; use **Network** in devtools if those requests fail (offline, blocked, or API errors). For custom maps, the overlay may request **BeatSaver** over HTTPS; use **Network** in devtools if those requests fail (offline, blocked, or API errors).

View File

@ -36,15 +36,106 @@ body {
background: rgba(0, 0, 0, 0); background: rgba(0, 0, 0, 0);
color: white; color: white;
filter: drop-shadow(0 0 0.1rem black) drop-shadow(0 0 0.1rem black); filter: drop-shadow(0 0 0.1rem black) drop-shadow(0 0 0.1rem black);
}
#overlayStack {
position: relative;
flex: 1 1 auto;
min-height: 0;
width: 100%;
display: flex;
flex-direction: column;
}
#songOverlay {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
min-height: 0;
opacity: 1;
transition-property: opacity; transition-property: opacity;
transition-timing-function: ease-out; transition-timing-function: ease-out;
transition-duration: calc(var(--fade, 300) * 1ms); transition-duration: calc(var(--fade, 300) * 1ms);
} }
/* Production: hide when not Playing (or loading map meta). Debug: ?debug=1 (see index.html). */ #requestOverlay {
html:not(.debug) body.loading, position: absolute;
html:not(.debug) body:not([data-game-state="Playing"], .preview) { left: 0;
right: 0;
top: 0;
max-height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 0.45rem;
white-space: normal;
opacity: 1;
transition-property: opacity;
transition-timing-function: ease-out;
transition-duration: calc(var(--fade, 300) * 1ms);
}
/* Song panel: hide when not Playing (or loading map meta). Use body.preview to show placeholders (click). */
body.loading #songOverlay,
body:not([data-game-state="Playing"], .preview) #songOverlay {
opacity: 0; opacity: 0;
pointer-events: none;
}
/* Request queue: show when idle (Menu); hide while Playing, preview, or loading map meta */
body[data-game-state="Playing"] #requestOverlay,
body.preview #requestOverlay,
body.loading #requestOverlay {
opacity: 0;
pointer-events: none;
}
#requestHeader {
font-size: 2.2rem;
font-weight: 700;
line-height: 1.1;
}
#requestList {
margin: 0;
padding-left: 2.2rem;
font-size: 1.5rem;
line-height: 1.25;
}
#requestList:empty {
display: none;
}
#requestList .request-item + .request-item {
margin-top: 0.35rem;
}
#requestEmpty {
font-size: 1.45rem;
opacity: 0.88;
}
#requestOverlay.has-items #requestEmpty {
display: none;
}
.request-item {
display: list-item;
}
.request-title {
font-weight: 700;
}
.request-meta {
opacity: 0.92;
font-size: 1.45rem;
}
.request-meta::before {
content: "· ";
} }
span { span {
@ -212,7 +303,7 @@ body:not(.bsr) #bsrKey {
display: none; display: none;
} }
body.right > .row { body.right #songOverlay > .row {
flex-direction: row-reverse; flex-direction: row-reverse;
} }

View File

@ -4,12 +4,10 @@
<meta charset="utf-8"> <meta charset="utf-8">
<title>BS Overlay</title> <title>BS Overlay</title>
<link rel="stylesheet" href="index.css"> <link rel="stylesheet" href="index.css">
<script>
if (new URLSearchParams(location.search).get("debug") === "1")
document.documentElement.classList.add("debug");
</script>
</head> </head>
<body> <body data-game-state="Menu">
<div id="overlayStack">
<div id="songOverlay">
<div class="row"> <div class="row">
<img id="coverImg" src="images/unknown.svg"> <img id="coverImg" src="images/unknown.svg">
<div id="mapInfo"> <div id="mapInfo">
@ -39,6 +37,13 @@
<span id="accuracy">96.9</span> <span id="accuracy">96.9</span>
<span id="mistakes">7</span> <span id="mistakes">7</span>
</div> </div>
</div>
</div>
<div id="requestOverlay" aria-live="polite">
<div id="requestHeader">Song requests</div>
<ol id="requestList"></ol>
<div id="requestEmpty">No pending requests</div>
</div>
</div> </div>
<svg width="0" height="0" style="position: absolute"> <svg width="0" height="0" style="position: absolute">
<filter id="gamma" color-interpolation-filters="sRGB"> <filter id="gamma" color-interpolation-filters="sRGB">
@ -64,9 +69,10 @@
</select></label> </select></label>
<label>Scale (%): <input id="scaleInput" type="number" min="10" max="1000" step="5"></label> <label>Scale (%): <input id="scaleInput" type="number" min="10" max="1000" step="5"></label>
<label>Fade (ms): <input id="fadeInput" type="number" min="0" max="5000" step="10"></label> <label>Fade (ms): <input id="fadeInput" type="number" min="0" max="5000" step="10"></label>
<label>Debug: <input id="debugInput" type="checkbox"></label>
<br> <br>
<strong>About</strong> <strong>About</strong>
<a href="https://github.com/ibillingsley/BeatSaber-Overlay" target="_blank">Source code</a> <a href="https://github.com/ibillingsley/BeatSaber-Overlay" target="_blank">This was forked from Iza's overlay</a>
</dialog> </dialog>
<script src="index.js"></script> <script src="index.js"></script>
</body> </body>

123
index.js
View File

@ -172,6 +172,7 @@ const settings = {
time: true, time: true,
score: true, score: true,
bsr: false, bsr: false,
debug: false,
right: false, right: false,
bottom: true, bottom: true,
scale: 1, scale: 1,
@ -206,12 +207,13 @@ document.head.appendChild(style);
// Settings UI // Settings UI
for (const key of ["cover", "mapInfo", "time", "score", "bsr"]) { for (const key of ["cover", "mapInfo", "time", "score", "bsr", "debug"]) {
const input = document.getElementById(`${key}Input`); const input = document.getElementById(`${key}Input`);
input.checked = settings[key]; input.checked = settings[key];
input.oninput = () => { input.oninput = () => {
settings[key] = input.checked; settings[key] = input.checked;
saveSettings(); saveSettings();
if (key === "debug") loadRequestQueue();
}; };
} }
@ -238,3 +240,122 @@ fade.oninput = () => {
document.documentElement.onclick = e => document.body.classList.toggle("preview"); document.documentElement.onclick = e => document.body.classList.toggle("preview");
document.getElementById("settings").onclick = e => e.stopPropagation(); document.getElementById("settings").onclick = e => e.stopPropagation();
// Song request queue (JSON from same origin as page; poll). See docs/testing.md for ?requests=
const MAX_REQUESTS = 10;
const REQUEST_POLL_MS = 5000;
const requestListEl = document.getElementById("requestList");
const requestOverlayEl = document.getElementById("requestOverlay");
const requestEmptyEl = document.getElementById("requestEmpty");
const requestTitleCache = new Map();
function useRequestHistorySim() {
return settings.debug || new URLSearchParams(location.search).get("debug") === "1";
}
/** @returns {string[]} */
function requestJsonFilenames() {
const explicit = new URLSearchParams(location.search).get("requests");
if (explicit) return [explicit];
if (useRequestHistorySim()) return ["ChatRequest.json", "database.json"];
return ["ChatRequest.json"];
}
/** @param {string} fileName */
function loadJsonNextToPage(fileName) {
const base = new URL(fileName, location.href);
if (base.protocol !== "http:" && base.protocol !== "https:") {
throw new Error("not-http");
}
const busted = new URL(base.href);
busted.searchParams.set("t", String(Date.now()));
return fetch(busted.href, { cache: "no-store" }).then(res => {
if (!res.ok) throw new Error(String(res.status));
return res.json();
});
}
async function loadRequestPayload() {
let lastErr;
for (const name of requestJsonFilenames()) {
try {
return await loadJsonNextToPage(name);
} catch (e) {
lastErr = e;
}
}
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
}
/** @param {ChatRequestEntry} item */
function requesterLine(item) {
const parts = [item.npr, item.rqn].filter(Boolean);
return parts.length ? parts.join(" ") : item.rqn || "";
}
/** @param {string} key @param {HTMLElement} titleEl */
async function enrichRequestTitle(key, titleEl) {
if (requestTitleCache.has(key)) {
titleEl.textContent = requestTitleCache.get(key);
return;
}
try {
const response = await fetch(`https://api.beatsaver.com/maps/id/${encodeURIComponent(key)}`);
if (!response.ok) return;
const map = await response.json();
const name = map.metadata?.songName ?? map.name;
if (name && typeof name === "string") {
requestTitleCache.set(key, name);
titleEl.textContent = name;
}
} catch {
// keep !bsr placeholder
}
}
/** @param {ChatRequestEntry[]} items */
function renderRequestList(items) {
requestListEl.replaceChildren();
requestOverlayEl.classList.toggle("has-items", items.length > 0);
for (const item of items) {
const li = document.createElement("li");
li.className = "request-item";
const titleEl = document.createElement("span");
titleEl.className = "request-title";
titleEl.textContent = `!bsr ${item.key}`;
li.appendChild(titleEl);
const who = requesterLine(item);
if (who) {
const meta = document.createElement("span");
meta.className = "request-meta";
meta.textContent = who;
li.appendChild(meta);
}
requestListEl.appendChild(li);
enrichRequestTitle(item.key, titleEl);
}
}
async function loadRequestQueue() {
try {
/** @type {ChatRequestPayload} */
const data = await loadRequestPayload();
if (requestEmptyEl) {
requestEmptyEl.textContent = "No pending requests";
requestOverlayEl.classList.remove("request-load-failed");
}
const raw = useRequestHistorySim() ? data.history ?? [] : data.queue ?? [];
const items = raw.slice(0, MAX_REQUESTS);
renderRequestList(items);
} catch {
if (requestEmptyEl) {
requestEmptyEl.textContent = "whupsy, database file missing";
requestOverlayEl.classList.add("request-load-failed");
}
renderRequestList([]);
}
}
loadRequestQueue();
window.setInterval(loadRequestQueue, REQUEST_POLL_MS);

72
serve.ts Normal file
View File

@ -0,0 +1,72 @@
import { join } from "jsr:@std/path";
import { serveDir } from "jsr:@std/http/file-server";
const root = import.meta.dirname ?? ".";
const port = Number(Deno.env.get("PORT") ?? "8080");
function trimPathLine(line: string): string {
let s = line.trim();
if (!s || s.startsWith("#")) return "";
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
s = s.slice(1, -1);
}
return s.trim();
}
/** Optional one-line file next to this script: absolute path to `ChatRequest/Database.json`. */
function readOptionalPathFile(): string | undefined {
try {
const pathFile = join(root, "chat-request-database.path");
const raw = Deno.readTextFileSync(pathFile);
for (const line of raw.split(/\r?\n/)) {
const p = trimPathLine(line);
if (p) return p;
}
} catch {
// missing or unreadable
}
return undefined;
}
const chatRequestDatabase =
Deno.env.get("CHAT_REQUEST_DATABASE")?.trim() || readOptionalPathFile();
function isChatRequestFilename(pathname: string): boolean {
const base = pathname.split("/").pop() ?? "";
return base === "ChatRequest.json" || base === "database.json";
}
Deno.serve({ port, hostname: "127.0.0.1" }, async (req) => {
const url = new URL(req.url);
if (req.method === "GET" && chatRequestDatabase && isChatRequestFilename(url.pathname)) {
try {
let text = await Deno.readTextFile(chatRequestDatabase);
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
return new Response(text, {
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
},
});
} catch (e) {
if (e instanceof Deno.errors.NotFound) {
console.error(`CHAT_REQUEST_DATABASE not found: ${chatRequestDatabase}`);
return new Response(null, { status: 404 });
}
const msg = e instanceof Error ? e.message : String(e);
console.error(`CHAT_REQUEST_DATABASE read error (${chatRequestDatabase}): ${msg}`);
return new Response(`Failed to read CHAT_REQUEST_DATABASE: ${msg}\n`, { status: 500 });
}
}
return serveDir(req, { fsRoot: root, showDirListing: false });
});
console.log(`Overlay: http://127.0.0.1:${port}/index.html`);
if (chatRequestDatabase) {
console.log(`Chat request database file: ${chatRequestDatabase}`);
} else {
console.warn(
"No database path: set CHAT_REQUEST_DATABASE or create chat-request-database.path (see README). " +
"Otherwise /ChatRequest.json is only served from this folder if the file exists.",
);
}

14
types.d.ts vendored
View File

@ -64,6 +64,20 @@ type BeatSaberPlusEvent = HandshakeEvent | GameStateEvent | ResumeEvent | PauseE
type MapInfo = MapInfoChangedEvent["mapInfoChanged"]; type MapInfo = MapInfoChangedEvent["mapInfoChanged"];
type Score = ScoreEvent["scoreEvent"]; type Score = ScoreEvent["scoreEvent"];
/** Chat request / queue JSON (e.g. ChatRequest.json beside the overlay) */
interface ChatRequestEntry {
key: string;
rqt: number;
rqn: string;
npr: string;
msg: string;
}
interface ChatRequestPayload {
queue: ChatRequestEntry[];
history: ChatRequestEntry[];
}
interface Document { interface Document {
// Assume non-null // Assume non-null
getElementById(elementId: `${string}Img`): HTMLImageElement; getElementById(elementId: `${string}Img`): HTMLImageElement;