Sports calendar and reminder PWA. Follow any team in the world; get a web notification and an email an hour before kickoff, and again one minute out. Free for users.
One Bun process, one Railway service, two managed datastores.
| Layer | Choice | Why |
|---|---|---|
| Runtime | Bun 1.3 | Web and workers share one runtime, so one container runs both |
| Web | Hono + hono/jsx SSR |
Server-rendered; every control is a plain form and works with JS off |
| Data | Postgres | Write-heavy at viral scale: follows, fan-out claims, delivery receipts |
| Queue | BullMQ on Redis | Reminder fan-out, fixture sync, retries and backoff |
| Auth | Magic link + passkey | No passwords, so nothing to reset, rotate or leak |
| Fixtures | ESPN public JSON | 354 leagues / 17 sports, free and keyless |
cp .env.example .env # fill in what you need; nothing is required to boot
docker compose up # app + postgres + redis
bun run sync # seed the catalogue and the first fortnight of fixturesWithout Docker, point DATABASE_URL and REDIS_URL at any Postgres and Redis
and run bun run dev.
apps/web Hono server, JSX views, PWA assets. Also boots the workers.
apps/worker Same workers, standalone, for when one instance stops being enough.
packages/config Reads the environment. Nothing else touches process.env.
packages/db Schema, forward-only migrations, and every query the app runs.
packages/sports Provider adapters. ESPN today; the interface takes others.
packages/queue BullMQ queues, schedules and the fan-out workers.
packages/notify Web push (VAPID) and email (Resend).
packages/auth Magic link, passkeys, sessions.
packages/payments CoinPay checkout, webhook verification, entitlements.
packages/playlists A reader's own M3U line: import, probe, proxy, share.
packages/radio A reader's own SiriusXM: email+code sign-in, lineups, HLS proxy.
This is the part built for going viral, so it is worth stating plainly.
A naive implementation enqueues one job per follower when a game approaches. A World Cup final with two million followers would enqueue two million jobs at once and the queue becomes the outage.
Instead there are three tiers:
- Scan (every 30s) finds events crossing a reminder threshold and enqueues
one job per
(event, offset)— job idfo:<event>:<offset>, so a scan that runs twice produces the same job rather than a second fan-out. - Fan-out pages that event's followers with a keyset cursor on
user_idand enqueues one job per page of 500. Two million followers become four thousand jobs, and paging cost stays flat instead of degrading withOFFSET. - Batch claims and sends. The claim is an insert into
reminder_deliverieswhose primary key is(event, user, offset, channel); a retried or duplicated job gets an empty set back and sends nothing.
Claiming happens before sending, so the worst case is a dropped notification
rather than a duplicate one — the right way round for something that buzzes a
phone at midnight. Reminders more than REMINDER_MAX_LATENESS_SECONDS past due
are dropped rather than delivered late.
ESPN publishes an unauthenticated JSON API. It is undocumented and carries no SLA, which is exactly why every response is normalised and persisted immediately: the calendar serves from our own tables, so an upstream outage degrades freshness instead of blanking the site.
A whole date range comes back in one request, so a 14-day horizon costs one call per league — a full sweep of 354 leagues is ~354 requests. That is why this runs free where a live-scores vendor would charge $129/mo: schedules are cheap, live scores are what you pay for.
Responses cap at ~100 events, so fetchSchedule splits the window and re-fetches
when it hits the cap — a truncated response is otherwise indistinguishable from a
quiet fortnight and would silently drop half a busy league's season.
One Railway service from the Dockerfile, plus managed Postgres and Redis.
ROLES decides what an instance runs (web, worker, or both — the default).
Migrations apply themselves on boot behind an advisory lock, so a deploy needs no
manual step.
Never hardcode a port: Railway injects PORT, and a fixed -p leaves the edge
proxy forwarding to a closed socket while the container reports healthy.
Secrets belong on the service and in the logicsrc vault, not in a committed
.env.
The in-page player used to hold every account to ONE open stream and evict the
older one when a second started — so two fixture pages in two tabs could never
both play. That number is a fact about the reader's provider line, not about
the site: a line sold with one connection suspends the account on the second,
and a line sold with four does not. It now comes from the line. At import and
on every refresh the provider's Xtream panel (player_api.php, derived from the
playlist address) is asked for max_connections, the answer is stored on
user_playlists.panel_connections, and the reader can lower it (never raise it)
under Streams at once in settings. The proxy enforces the smallest of the
panel's word, the reader's choice and STREAM_PROXY_MAX_PER_USER, which is now
a site-wide ceiling of four rather than the allowance itself. A list whose
provider will not say is still held to one unless the reader says otherwise.
/multiview?c=<id>,<id>,… plays up to four of the reader's own channels in a
grid, each through the same /my/channels/:id/stream.ts route as the Play
button, muted to start with one audible at a time. Pop out moves the grid
into a Document Picture-in-Picture window — the only always-on-top surface a
browser offers that can hold more than one video; element-level PiP is one per
browser — and falls back to a plain popup elsewhere. The Multiview button
beside a channel on a fixture or team page adds it to the grid.
A reader connects their own SiriusXM subscription in settings — the email on
the account and the code SiriusXM emails them, exactly media-streamer's flow;
only the resulting session is kept — and the sports and news lineups play on /radio and on a fixture's page. The
session is sealed with the playlist key and every byte is fetched by the server
as that reader; the browser never sees a SiriusXM address or a bearer. The player
is @profullstack/player with its audio bar, bundled to vendor-player.js and
fetched on the first press of Play.
For the leagues SiriusXM carries by team (NFL, NBA, MLB, NHL, WNBA, the college
football and basketball conferences, MLS), a fixture's page and a team's page
draw an On SiriusXM section that looks up each side's own feed as soon as
the page is up: the team name is parsed (packages/radio/src/teams.js),
SiriusXM is searched for it on the reader's session, and only channels that
actually name the team are kept. Lookups are cached across readers for ten
minutes, so a busy fixture costs one search per side, not one per view. Team
feeds appear close to kickoff and vanish after; the section says which.
Knobs, all read at request time:
SIRIUSXM—0turns the rail off. Defaults to on for the tipoffwatch brand and off for any otherBRAND.SIRIUSXM_PROXY_URL— the residential exit for every SiriusXM call. Falls back toSPORTS_PROXY_URL. Not optional in production: SiriusXM answers a datacenter address with 403 and pins a session to the IP that authenticated it.SIRIUSXM_PROXIES— a pool of single-IP proxies (host:port:user:passlines or full URLs, comma or newline separated). Each reader is hashed to one and keeps it, so login, refresh and playback all leave through the same address. Set this when a rotating endpoint starts breaking streams mid-segment.SIRIUSXM_DEVICE_GRANT— aDEVICE_GRANTcookie value pasted from a browser session, for the rare case SiriusXM refuses to start a sign-in without one. The sign-in is tried without it first.
The pending-code state between "send code" and "verify" lives in the web process's memory for ten minutes, which is right for one web replica and would need Redis for more.