Skip to content

Repository files navigation

tipoffwatch.com

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.

Stack

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

Running locally

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 fixtures

Without Docker, point DATABASE_URL and REDIS_URL at any Postgres and Redis and run bun run dev.

Layout

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.

How reminders scale

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:

  1. Scan (every 30s) finds events crossing a reminder threshold and enqueues one job per (event, offset) — job id fo:<event>:<offset>, so a scan that runs twice produces the same job rather than a second fan-out.
  2. Fan-out pages that event's followers with a keyset cursor on user_id and enqueues one job per page of 500. Two million followers become four thousand jobs, and paging cost stays flat instead of degrading with OFFSET.
  3. Batch claims and sends. The claim is an insert into reminder_deliveries whose 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.

Fixture ingestion

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.

Deploying

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.

Radio (SiriusXM)

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:

  • SIRIUSXM0 turns the rail off. Defaults to on for the tipoffwatch brand and off for any other BRAND.
  • SIRIUSXM_PROXY_URL — the residential exit for every SiriusXM call. Falls back to SPORTS_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:pass lines 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 — a DEVICE_GRANT cookie 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.

About

Sports calendar and reminder PWA. Follow any team in the world; get told before they play.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages