Just a litle page on the intrawebs where I can broadcast and chat with friends.
Note: the password prompt is not authentication — it gates a modal and nothing else. The socket connection and the Icecast stream are both reachable without it. See "Auth" below before putting this anywhere public.
Needs Node 20+.
npm install
cp .env.example .env # then edit
npm run build # src/ -> app/
npm start
npm run dev rebuilds on change and reloads the server.
Config is all environment variables (see .env.example) — there are no
config.js / config-dev.js files anymore.
Nothing renders until npm run build has run: Express serves app/, which is
generated from src/ and is not in git.
Set BASE_PATH=/radio and TRUST_PROXY=1, then point nginx at it. proxy_pass
deliberately has no trailing path, so the /radio prefix is passed through
intact — the app expects to see it.
location = /radio { return 301 /radio/; }
location /radio/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; # websockets
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
Leave BASE_PATH empty to serve at a domain root instead; the client works out
where it's mounted at runtime, so the same build covers both.
The header and footer come from the main site's shared web components — the same
ones /rtc uses:
<daw-header></daw-header>
<daw-footer></daw-footer>
<script src="/embed/chrome.js"></script>
chrome.js renders into a shadow root, so the site's styles stay sealed off from
this page's Bootstrap 3 and the two can't collide. Nothing is copied into this
repo — the chrome is maintained in the main site and can't drift out of sync here.
That script tag is root-relative on purpose: in production Apache serves /embed
from the docroot alongside /radio. Locally there's no Apache in front, so set
DAW_ORIGIN=http://daw.stu and the app proxies /embed to it. Leave
DAW_ORIGIN unset in production.
| Route | Purpose |
|---|---|
/ |
the page |
/health |
ok, room count, socket count |
/api/status |
now-playing + listeners, proxied from Icecast |
/api/lastfm |
sidebar data, proxied so the API key stays server-side |
/api/broadcast |
POST {msg} — sends to every room |
/other |
the modal's password check (not auth) |
All are relative to BASE_PATH.
The player streams from $STREAM_URL and gets now-playing from $STREAM_STATUS_URL.
Both must be https — the page is served over TLS, so an http:// stream is
blocked as mixed content.
Status is read from Icecast's stock status-json.xsl and proxied through
/api/status rather than being fetched from the browser. The old code called a
hand-customized status2.xsl over JSONP; Icecast upgrades overwrite those XSL
files (see the 2021 note below — it 404s today). The stock endpoint survives
upgrades, and proxying keeps the page same-origin.
Optional. Unset REDIS_URL and it runs single-process, keeping per-connection
state in memory. Set it and socket.io uses the Redis adapter so rooms and
presence work across multiple processes.
There isn't any, despite appearances. ROOM_PASSWORD is checked client-side via
the modal's data-remote hook; passing it just closes the modal. /api/broadcast
is unauthenticated, the socket handshake is unauthenticated, and the Icecast
stream can be opened directly.
Doing this properly means a server-verified session that covers the socket handshake and the stream, not only the page.