Browse Source

Add pm2/systemd deploy setup, mirroring chess-io

Production runs this as a long-lived node process behind Apache at
davidwindham.com/radio, same shape as /chess and /ask. Mirrors the chess-io
convention so the two deploy the same way.

  - ecosystem.config.cjs   pm2 config. Fork x1 (in-memory chat state; scale
                           needs REDIS_URL + a shared adapter, not just more
                           workers). Operational env inline; the one secret
                           (LASTFM_API_KEY) comes from a gitignored .env via
                           --env-file-if-exists. DAW_ORIGIN deliberately omitted.
  - deploy/apache-radio.conf.example   the ProxyPass block for the existing
                           davidwindham.com vhost. Option A (2.4.47+,
                           upgrade=websocket) and Option B (older, mod_rewrite).
  - deploy/radio.service.example       systemd alternative to pm2.
  - deploy/README.md       first-time setup + redeploy runbook.

The one difference from chess: this app has a build step. app/ is gitignored and
compiled from src/ by esbuild, so prod must `npm install && npm run build`
(full install -- the build needs the devDeps) before starting, and on every
pull. Called out in all three deploy files.

Auth is intentionally absent and the README says so plainly.
windhamdavid 10 hours ago
parent
commit
e47a39184d
4 changed files with 321 additions and 0 deletions
  1. 111 0
      deploy/README.md
  2. 86 0
      deploy/apache-radio.conf.example
  3. 57 0
      deploy/radio.service.example
  4. 67 0
      ecosystem.config.cjs

+ 111 - 0
deploy/README.md

@@ -0,0 +1,111 @@
+# Deploying daveo-radio
+
+The app lives at **davidwindham.com/radio** — a long-running Node process that
+Apache proxies to, same shape as `/chess` and `/ask`. It is *not* served from the
+web docroot.
+
+## The one thing that's different from chess
+
+**This app has a build step.** Express serves `app/`, which is compiled from
+`src/` by esbuild and is **gitignored** — it is not in the repo. So production
+must build it, and building needs the devDependencies (esbuild, bootstrap,
+handlebars). That means a full `npm install`, not `npm ci --omit=dev`.
+
+Every deploy is therefore: **pull → install → build → restart.**
+
+## First-time setup
+
+1. **Check out the repo** on the server (e.g. `/var/www/radio`).
+
+   The default branch tracks `master`. If you deployed the `modernize` branch,
+   check that out instead.
+
+2. **Build.**
+   ```sh
+   npm install          # full — the build needs the devDeps
+   npm run build        # src/ -> app/
+   ```
+
+3. **Configure secrets.** All operational config (PORT, BASE_PATH, TRUST_PROXY,
+   stream URLs) is set by the process manager below. The only secret is the
+   Last.fm API key. Put it in a gitignored `.env` in the repo root:
+   ```sh
+   echo 'LASTFM_API_KEY=your-key-here' > .env
+   ```
+   Unset is fine too — the sidebar lists just stay empty and the app still runs.
+
+4. **Start it — pick one process manager:**
+
+   **pm2** (matches chess-io):
+   ```sh
+   pm2 start ecosystem.config.cjs
+   pm2 logs radio --lines 5     # confirm  basePath":"/radio"
+   pm2 save                     # remember across reboots
+   pm2 startup                  # prints a sudo command to enable on boot
+   ```
+
+   **or systemd:**
+   ```sh
+   sudo cp deploy/radio.service.example /etc/systemd/system/radio.service
+   # edit User/Group/WorkingDirectory in it first
+   sudo systemctl daemon-reload
+   sudo systemctl enable --now radio
+   ```
+
+5. **Add the Apache proxy.** Paste the block from
+   `deploy/apache-radio.conf.example` into the **existing** davidwindham.com
+   vhost (both `:80` and `:443`) — not a new vhost. Check the Apache version
+   first (`apachectl -v`); the file has Option A for 2.4.47+ and Option B for
+   older. Then:
+   ```sh
+   apachectl configtest && sudo systemctl reload apache2   # or: apachectl -k graceful
+   ```
+
+6. **Verify:**
+   ```sh
+   curl -s https://davidwindham.com/radio/health           # {"ok":true,...}
+   curl -s https://davidwindham.com/radio/api/status        # {"online":...}
+   ```
+   Then load `https://davidwindham.com/radio/` in a browser and confirm the
+   chat connects (network tab: a `101 Switching Protocols` on
+   `/radio/socket.io/…` means the websocket upgraded; HTTP 200 long-polls mean
+   the Apache upgrade rule isn't matching).
+
+## Redeploying new code
+
+```sh
+git pull && npm install && npm run build && pm2 restart radio
+#                                            (or: sudo systemctl restart radio)
+```
+
+Optional, to slim `node_modules` after building — the devDeps are only needed
+at build time, since their output is baked into `app/`:
+```sh
+npm prune --omit=dev     # run AFTER npm run build, never before
+```
+
+## Prerequisites on the server
+
+- **Node 20+** (22.9+ if you use the `--env-file-if-exists` line in
+  `ecosystem.config.cjs`; on older Node, put `LASTFM_API_KEY` in the pm2 env
+  block or a systemd drop-in instead).
+- **Apache** with `proxy proxy_http rewrite` (`proxy_wstunnel` too if you're on
+  Option B).
+- **Icecast** reachable at `stream.davidawindham.com` over **https** — the
+  player and the `/api/status` proxy both need it, and http is blocked as mixed
+  content on the TLS page.
+- **Redis is NOT required.** Leave `REDIS_URL` unset and the app runs
+  single-process, keeping chat state in memory. Only set it (and only then raise
+  pm2 `instances`) if you ever need to scale to multiple processes.
+
+## Notes
+
+- **No auth, by design.** The page, the chat socket, and the Icecast stream are
+  all reachable by anyone with the URL. This is a deliberate choice for a
+  personal page — noted here so it's not a surprise.
+- **`DAW_ORIGIN` must stay unset in production.** It's a local-dev-only shim that
+  proxies `/embed` (the shared site chrome) to the main site when there's no
+  Apache in front of node. In production Apache serves `/embed` from the docroot.
+- The **Last.fm key** currently in git history (`e12ea1d0…`) is worth rotating —
+  it's read-only public data and now server-side only, so low stakes, but the
+  rotation is free.

+ 86 - 0
deploy/apache-radio.conf.example

@@ -0,0 +1,86 @@
+# daveo-radio — reverse proxy for davidwindham.com/radio
+# ============================================================================
+#
+# The radio app is a long-running Node process (see radio.service.example /
+# ecosystem.config.cjs). It is NOT served from the web docroot like the
+# PHP/WordPress parts of the site — Apache proxies /radio through to node on
+# 127.0.0.1:3000.
+#
+# Add the block below to the EXISTING davidwindham.com <VirtualHost> (the same
+# one that serves WordPress). Do NOT create a new vhost for it — /radio has to
+# live under the main hostname so it is same-origin with the rest of the site;
+# that is what lets the client open its websocket with no hardcoded host, keeps
+# it clear of mixed-content blocking on https, and lets /embed/chrome.js (the
+# shared header/footer) resolve from the docroot alongside it.
+#
+# The WordPress "Radio" page moved to /online-radio, so /radio is free. WP still
+# issues a permanent old-slug 301 /radio -> /online-radio; Apache answers /radio
+# first once this proxy is in place, so it never fires — but a browser that hit
+# /radio before the proxy existed will have cached that 301. Hard-reload if so.
+#
+# ---------------------------------------------------------------------------
+# Which option? Check your Apache version first:
+#     apachectl -v      (or: apache2 -v  /  httpd -v)
+# ---------------------------------------------------------------------------
+#
+# >= 2.4.47  ->  Option A. One line carries both HTTP and the websocket.
+# <  2.4.47  ->  Option B. ProxyPass for HTTP + a mod_rewrite rule for the
+#                websocket upgrade.
+#
+# Use ONE option, not both. Modules needed either way:
+#     a2enmod proxy proxy_http rewrite
+# Option A additionally: nothing. Option B additionally: proxy_wstunnel.
+#
+# The node app must be running with BASE_PATH=/radio and TRUST_PROXY=1 — see
+# ecosystem.config.cjs / radio.service.example.
+
+# ===========================================================================
+# OPTION A — Apache 2.4.47+  (preferred)
+# ===========================================================================
+# Paste inside <VirtualHost *:443> for davidwindham.com.
+
+    ProxyPreserveHost On
+
+    # radio: node app mounted at /radio. Prefix kept on both sides;
+    # BASE_PATH=/radio tells the app to expect it. upgrade=websocket carries
+    # socket.io at /radio/socket.io (needs 2.4.47+).
+    ProxyPass         /radio  http://127.0.0.1:3000/radio  upgrade=websocket
+    ProxyPassReverse  /radio  http://127.0.0.1:3000/radio
+
+
+# ===========================================================================
+# OPTION B — Apache < 2.4.47  (mod_rewrite fallback)
+# ===========================================================================
+# Use this INSTEAD of Option A if your Apache predates upgrade=websocket
+# (e.g. Ubuntu 20.04 ships 2.4.41, Debian 10 ships 2.4.38). Without it,
+# socket.io still works but silently degrades to HTTP long-polling.
+#
+# The RewriteRule must appear before the ProxyPass. The two RewriteConds gate it
+# to genuine websocket-upgrade requests only; everything else — page loads,
+# static assets, socket.io's HTTP long-polling — falls through to ProxyPass.
+
+    ProxyPreserveHost On
+
+    RewriteEngine On
+    RewriteCond %{HTTP:Upgrade}    =websocket [NC]
+    RewriteCond %{HTTP:Connection} upgrade    [NC]
+    RewriteRule ^/radio/(.*) ws://127.0.0.1:3000/radio/$1 [P,L]
+
+    ProxyPass         /radio  http://127.0.0.1:3000/radio
+    ProxyPassReverse  /radio  http://127.0.0.1:3000/radio
+
+# ---------------------------------------------------------------------------
+# Notes
+# ---------------------------------------------------------------------------
+#
+# * /embed/chrome.js (the shared header/footer) is served by Apache from the
+#   WordPress docroot, NOT by this app. Nothing to configure here for it — the
+#   page loads it root-relative and it already resolves alongside /radio.
+#   DAW_ORIGIN is a LOCAL-DEV-ONLY shim and must stay UNSET in production.
+#
+# * No trailing-slash rule is needed in Apache: the app itself 301s /radio to
+#   /radio/ so relative asset URLs resolve.
+#
+# * The Icecast stream is a separate origin (stream.davidawindham.com) reached
+#   directly by the browser's audio element and by this app's /api/status
+#   proxy. Nothing to add here for it — just make sure the stream is https.

+ 57 - 0
deploy/radio.service.example

@@ -0,0 +1,57 @@
+# systemd unit for the daveo-radio node app — /etc/systemd/system/radio.service
+# ============================================================================
+#
+# An alternative to pm2 (ecosystem.config.cjs). Use ONE of the two, not both.
+#
+# Runs the app as a long-lived service behind Apache (see
+# apache-radio.conf.example). Adjust User/Group and WorkingDirectory to match
+# where the repo is checked out on the server.
+#
+# BUILD FIRST — this app serves the built bundle in app/, which is gitignored,
+# and systemd does NOT build. Before enabling the service, and after every pull:
+#
+#     npm install && npm run build      # devDeps needed to build; app/ is output
+#
+# Then:
+#     sudo cp deploy/radio.service.example /etc/systemd/system/radio.service
+#     sudo systemctl daemon-reload
+#     sudo systemctl enable --now radio
+#     systemctl status radio
+#     journalctl -u radio -f             # logs (startup + connection events)
+#
+# On deploy of new code:
+#     git pull && npm install && npm run build && sudo systemctl restart radio
+
+[Unit]
+Description=daveo-radio (Icecast player + socket.io chat, node)
+After=network.target
+
+[Service]
+Type=simple
+User=www-data
+Group=www-data
+WorkingDirectory=/var/www/radio
+ExecStart=/usr/bin/node app.js
+Restart=on-failure
+RestartSec=2
+
+# Config. Mirrors .env.example. DAW_ORIGIN is intentionally absent — it is a
+# local-dev-only shim for the shared chrome and must NOT be set in production.
+Environment=NODE_ENV=production
+Environment=PORT=3000
+Environment=BASE_PATH=/radio
+Environment=TRUST_PROXY=1
+Environment=STREAM_URL=https://stream.davidawindham.com/stream
+Environment=STREAM_STATUS_URL=https://stream.davidawindham.com/status-json.xsl
+Environment=STREAM_MOUNT=/stream
+Environment=LASTFM_USER=windhamdavid
+# The Last.fm key is the one secret. Keep it out of this world-readable unit:
+# put it in a drop-in that root owns, chmod 600 --
+#   sudo systemctl edit radio
+# and add:
+#   [Service]
+#   Environment=LASTFM_API_KEY=xxxxxxxx
+# Unset => the sidebar lists stay empty; the app still runs.
+
+[Install]
+WantedBy=multi-user.target

+ 67 - 0
ecosystem.config.cjs

@@ -0,0 +1,67 @@
+// pm2 process config for daveo-radio.
+//
+// CommonJS (.cjs) on purpose: package.json has "type": "module", so a plain
+// ecosystem.config.js would be parsed as ESM and `module.exports` would throw.
+// pm2 reads .cjs fine.
+//
+// IMPORTANT — build first. Unlike chess-io (which renders templates at
+// runtime), this app serves the built bundle in app/, which is gitignored.
+// pm2 does NOT build. So on the server, before starting or after any pull:
+//
+//     npm install          # full install -- the build needs the devDeps
+//                          # (esbuild, bootstrap, handlebars)
+//     npm run build        # src/ -> app/
+//     pm2 start ecosystem.config.cjs      # first time
+//     pm2 logs radio --lines 5            # confirm  basePath":"/radio"
+//     pm2 save                            # remember across restarts
+//     pm2 startup                         # prints a sudo command to enable on boot
+//
+// Redeploy of new code:
+//     git pull && npm install && npm run build && pm2 restart radio
+//
+module.exports = {
+  apps: [
+    {
+      name: 'radio',
+      script: 'app.js',
+      cwd: __dirname,
+
+      // Secrets / extra config come from a gitignored .env on the server
+      // (LASTFM_API_KEY, and REDIS_URL if you enable it). Node reads it into
+      // process.env; the inline env below still wins for anything set in both,
+      // so the operational config here can't be overridden by a stale .env.
+      // --env-file-if-exists needs Node 22.9+. On older Node, drop this line
+      // and put LASTFM_API_KEY in the env block below instead.
+      node_args: '--env-file-if-exists=.env',
+
+      // Single process, fork mode -- NOT cluster. Per-connection chat state
+      // (nicknames, presence) lives in memory in one process via socket.data;
+      // multiple workers would each hold a different slice and socket.io would
+      // need a shared adapter + sticky sessions. If you ever DO want to scale
+      // out, set REDIS_URL (enables the socket.io Redis adapter) and raise
+      // instances -- not before.
+      instances: 1,
+      exec_mode: 'fork',
+
+      env: {
+        NODE_ENV: 'production',
+        PORT: 3000,
+        BASE_PATH: '/radio',
+        TRUST_PROXY: 1,
+
+        // Icecast. Must be https on a TLS page (mixed content is blocked).
+        STREAM_URL: 'https://stream.davidawindham.com/stream',
+        STREAM_STATUS_URL: 'https://stream.davidawindham.com/status-json.xsl',
+        STREAM_MOUNT: '/stream',
+
+        // LASTFM_API_KEY: set it in .env on the server (see node_args above).
+        // Unset => the sidebar lists just stay empty; the app still runs.
+        LASTFM_USER: 'windhamdavid',
+
+        // DAW_ORIGIN is intentionally omitted -- it is a local-dev-only shim for
+        // the shared chrome and must stay UNSET in production, where Apache
+        // serves /embed from the WordPress docroot.
+      },
+    },
+  ],
+};