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 9 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.
+      },
+    },
+  ],
+};