- TypeScript 86.3%
- CSS 9.5%
- Dockerfile 2.2%
- HTML 2%
| client | ||
| server | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| Caddyfile.example | ||
| compose.yaml | ||
| Dockerfile | ||
| README.md | ||
Squawk Audio Widget — third-party integration template
A self-contained, client-agnostic example of embedding Newsquawk Audio
live streaming into a third-party site. This directory is a template: copy it
per client, fill in .env, and deploy. The worked examples throughout use the
example.com deployment (client api-customer-example, origins example.com).
The widget itself is a TypeScript/Vite project (client/) with no runtime
dependencies; the token endpoint is a small zero-dependency Node script
(server/src/token-server.ts).
squawk_audio_widget/
├── client/ Vite + TypeScript frontend
│ ├── index.html demo page (Vite entry)
│ ├── src/
│ │ ├── widget.ts the widget: typed SquawkClient + mount()
│ │ ├── main.ts demo bootstrap
│ │ └── widget.css / demo.css
│ └── dist/ build output (served by token-server)
├── server/ Bun + TypeScript token endpoint
│ └── src/token-server.ts
├── Dockerfile multi-stage: bun build client → bun runtime
└── compose.yaml optional Caddy TLS profile
1. Architecture — and why server-side minting is required
Browser (client site)
│
│ 1. GET /token ──► Token endpoint (same origin)
│ ◄─ {"access_token", ...} holds the Keycloak CLIENT SECRET
│ (client_credentials grant,
│ 24 h token, cached server-side)
│ 2. WSS audio-v2.newsquawk.com/api/ws?token=<JWT>
│ subscribe → stream_start → binary WebM/Opus → stream_end
│
audio-v2.newsquawk.com validates the JWT (RS256, issuer, audience=audio-client)
and maps the Keycloak role `subscriber` → app role `listen`.
The Keycloak client secret must never reach the browser. Anyone who has it can mint unlimited tokens. So the token is always minted server-side:
- Option A (recommended): run the included token server next to the widget. It mints a token via the service account, caches it, and serves it to the widget from the same origin.
- Option B: if the client already has a backend, port
getToken()fromserver/src/token-server.ts(≈20 lines) into it and expose e.g.GET /api/audio/tokenauthenticated however they already authenticate their users.
Per-client checklist (once per new client)
| Step | Where | Notes |
|---|---|---|
1. Keycloak confidential client api-customer-<client> |
Keycloak realm | service-account only, no login flows |
2. Audience mapper → audio-client |
the client | required by the API's audience check |
3. Assign subscriber client role (on react-frontend-client) to the service account |
Keycloak | maps to app role listen |
4. Add the client's origins to SQUAWK_ALLOWED_ORIGINS |
audio-v2:~/audio/conf/env_file |
takes effect at restart |
5. Copy this template, fill .env |
deployment box | client id/secret, shared key, frame-ancestors |
| 6. Smoke test | — | /token, /api/auth/me, WS subscribe |
What is already provisioned for the current client:
| Item | Value |
|---|---|
| Allowed browser origins (CORS, staged) | example.com, www.example.com, localhost dev |
Allowed framing hosts (CSP frame-ancestors) |
same as CORS origins — set via .env |
| Keycloak client | api-customer-example (confidential, service-account only) |
| Audience mapper | adds audio-client to tokens (required by the API) |
| Role | subscriber client role on react-frontend-client → listen |
| Verified live | token mint, /api/auth/me, /api/auth/verify?roles=listen, WS subscribe |
2. Quick start (local)
A ready-to-use .env (chmod 600) with the provisioned service account already
exists in this directory; the server loads it automatically (Bun reads .env).
Container demo (what the tunnel forwards to):
cd ~/projects/squawk_audio_widget
docker compose up -d --build
# → http://127.0.0.1:5173 (PORT is pinned in .env for the tunnel demo)
Widget development (HMR): stop the container first (it owns port 5173), then run Vite directly with the token server alongside:
docker compose down
bun run server/src/token-server.ts & # token endpoint on 8787
cd client && bun install && bun run dev
# → http://localhost:5173 (Vite proxies /token to :8787)
Open the page, press ▶. Pressing play is the user gesture that unlocks browser autoplay; the widget then plays the live channel as soon as a broadcast starts.
Testing without the token server: open
http://127.0.0.1:8787/?token=<JWT>— the widget uses the literal token instead of fetching/token. Handy for debugging; never for production.
Widget configuration
Via mount options (SquawkWidget.mount('#el', {...})) or URL parameters:
| Mount option | URL param | Meaning |
|---|---|---|
api_url |
?apiUrl= |
Audio API origin (default https://audio-v2.newsquawk.com) |
token_url |
?tokenUrl= |
Token endpoint (default ./token, same origin) |
token |
?token= |
Literal token (testing only) |
shared_key |
?key= |
Sent as X-Widget-Key to the token endpoint (demo convenience — a shared key is not real authentication) |
default_channel |
?channel= |
Preselected keyword, e.g. asset:forex |
max_buffer_ms |
— | Target buffer-ahead in ms (default 1200) |
The channel dropdown resolves in priority order:
SQUAWK_CHANNELSfrom the server's.env(curated list served at/channels) — use this to offer only the channels a client is licensed for- the full public channel list from the audio API
- built-in defaults (works even if CORS is not yet active for your origin)
While connected, the widget records each completed broadcast and shows a ⟲ Last squawk button to replay it (capped at 32 MB — hours of Opus speech; older recordings are replaced by newer ones).
3. Deploying the widget host
The widget host serves static files and the token endpoint on one origin
(e.g. audio.<client>.com, or a Newsquawk-controlled domain like
web.virtualfunction.net).
Option A — Docker Compose (recommended)
cp .env.example .env # fill in CLIENT_SECRET, SHARED_KEY, CHANNELS and
chmod 600 .env # FRAME_ANCESTORS for this client
docker compose up -d --build
That's the whole deployment: the compose file runs the token server as an
unprivileged container, publishes ${PORT:-8787}, applies
restart: unless-stopped, and healthchecks the server. Configuration comes
from a read-only mount of .env — secrets are never baked into the image
(.dockerignore enforces that). Real environment variables still override
.env, and PORT in .env changes both the container and published port.
Widget tweaks under client/src need a rebuild:
docker compose up -d --build (instant — no dependencies to install).
By default the widget port is published on loopback only — handy for local testing and for keeping TLS the single public entry point.
Production TLS — built-in Caddy profile
The compose file ships an optional Caddy service (compose profile tls) that
terminates TLS with automatic ACME certificates:
cp Caddyfile.example Caddyfile # set the widget hostname
docker compose --profile tls up -d --build
# Caddyfile.example — set your widget hostname; certs are issued and renewed
# automatically (DNS must point at this host, ports 80/443 reachable).
audio.<client>.com {
reverse_proxy squawk-audio:8787
}
Caddy proxies to the widget service over the internal compose network, so the only publicly published ports are 80/443.
Option B — bare systemd + node
# /etc/systemd/system/squawk-audio.service
[Unit]
Description=Squawk Audio widget + token server
After=network-online.target
[Service]
User=www-data
WorkingDirectory=/opt/squawk_audio_widget
ExecStart=/usr/bin/bun run server/src/token-server.ts
Restart=always
[Install]
WantedBy=multi-user.target
Option C — your own static host + backend
Serve client/dist with any static server and implement /token (or
?tokenUrl= any other path) in your existing backend using the getToken()
logic from server/src/token-server.ts.
4. Embedding on the client site
Two supported patterns. Examples use client-example.com as the client site
and audio.virtualfunction.net as the widget host; substitute per client.
Pattern 1 — iframe (no changes to the client's app code)
The widget page sends
Content-Security-Policy: frame-ancestors … from SQUAWK_FRAME_ANCESTORS
(see .env) — set it to the client's site origins — so they may embed it:
<iframe
src="https://audio.virtualfunction.net/?channel=asset:forex"
style="border:0; width:420px; height:190px"
title="Live squawk audio"
allow="autoplay"></iframe>
The client's own CSP must allow it:
frame-src https://audio.virtualfunction.net;
The iframe is cross-origin, so all browser calls to the audio API happen from the widget host — that origin is what needs CORS (section 5).
Pattern 2 — module embed (inside the client's own Vite/TS app)
Copy client/src/widget.ts (+ widget.css) into the client's codebase and
mount it from their app:
import { SquawkWidget } from './widget';
import './widget.css';
SquawkWidget.mount('#squawk-audio', {
token_url: '/api/audio/token', // their backend's token endpoint
});
The widget is a dependency-free typed module, so this works in any bundler. Everything is same-origin here; the client's CSP only needs
connect-src 'self' wss://audio-v2.newsquawk.com;
(and no changes at all if connect-src is not restricted).
5. CORS on the audio API
Browser fetches to audio-v2.newsquawk.com (the channels list) require an
Access-Control-Allow-Origin match. WebSocket connections are not
CORS-gated. Add every origin the widget will be served/embedded from to
SQUAWK_ALLOWED_ORIGINS in audio-v2:~/audio/conf/env_file — it takes
effect at restart, so batch new clients together.
Current staged value:
SQUAWK_ALLOWED_ORIGINS='https://audio-v2.newsquawk.com,*.newsquawk.com,http://localhost:3000,http://localhost:3001,https://example.com,https://www.example.com,http://localhost:8787'
Restart in a quiet window (drops live listeners and any in-progress broadcast; clients reconnect automatically):
ssh system@audio-v2.newsquawk.com 'cd ~/audio && docker compose restart audio-server'
Verify afterwards:
curl -s -D - -o /dev/null -H 'Origin: https://example.com' \
https://audio-v2.newsquawk.com/api/settings/channels | grep -i access-control
6. CSP cheat sheet
| Site | Directive | Value |
|---|---|---|
| Widget host | frame-ancestors |
'self' + the client's site origins — via SQUAWK_FRAME_ANCESTORS in .env |
| Widget host | connect-src |
'self' wss://audio-v2.newsquawk.com |
| Widget host | script-src / style-src |
'self' — the widget needs no inline script/style |
| Client site (iframe pattern) | frame-src |
the widget host origin |
| Client site (script pattern) | connect-src |
wss://audio-v2.newsquawk.com (plus 'self' for the token endpoint) |
7. Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
WS closes with code 4003 |
token missing/invalid/expired or no listen role |
widget auto-refetches the token and reconnects; if it persists, check the Keycloak role assignment |
Token fetch failed: HTTP 401 |
X-Widget-Key mismatch |
align SQUAWK_SHARED_KEY and the page's fetch header |
| Channels dropdown falls back to defaults | CORS not active for this origin yet (restart pending) or API down | section 5; playback still works |
| "Press play to start audio" | browser blocked autoplay (no user gesture yet) | expected — the first ▶ click unlocks audio |
| Audio plays but late/skips | network jitter or long backgrounding | handled internally (fast-drain + reset seeks); if persistent, raise maxBufferMs |
Silence, LIVE never lights |
nobody is broadcasting on that channel | subscribe to a busier channel; check https://audio-v2.newsquawk.com/api/health |
| Iframe refuses to load | frame-ancestors missing the client origin |
update SQUAWK_FRAME_ANCESTORS and restart the token server |
8. Operational notes
- Token lifetime is 24 h (realm default).
server/src/token-server.tscaches and re-mints 2 min before expiry; the widget re-fetches a token on every reconnect, so server restarts/deployments never strand a listener. - Secret rotation: set a new secret on the Keycloak client
(
PUT /auth/admin/realms/Newsquawk/clients/{id}with{"secret": "..."}), update.env, restart the token server. Old tokens stay valid until their 24 h expiry. - Kill switch: disabling the client's Keycloak client stops all new tokens within a minute (after the cached one lapses).
- Rate limits on the audio API apply per client IP (auth endpoints 60/min) — the widget's design (token cached server-side, one WS connection per listener) stays far below them.