Watches district.in showtimes and sends a max-priority phone notification the moment tickets for a given movie, date, format and cinema go on sale.
4.2 KiB
Design notes
Why this is built the way it is, and what broke along the way. Useful if you're forking it, or writing something similar against a different site.
The API
District's movie pages are server-rendered Next.js, but showtimes load client-side from:
GET /gw/consumer/movies/v5/movie
?version=3&site_id=1&channel=mweb&child_site_id=1&platform=district
&movieCode=<frmtid>&city_key=<city>&content_id=<id>
&date=YYYY-MM-DD&latitude=<lat>&longitude=<lng>&cinemaOrderLogic=3
Findings that shaped the design:
x-guest-tokenis generated client-side. It looks like a session token but isn't server-issued — the browser builds<epoch_ms>_<18 digits>_<11 random chars>per request, and any value of that shape is accepted. No login, no cookies.- Latitude and longitude are mandatory. Without them the API returns
400 Mandatory search params are missing, even though results are city-wide. 204vs200is the signal. A date that isn't open for booking returns204 No Content; an open date returns the full session list. That status flip is the entire trigger, which keeps the check to a single cheap request.showTimeis UTC without a zone suffix. Local time is applied viaTZ_OFFSET_MINUTES.meta.showDatesonly exists in a200body. Because the target date returns an empty204while closed, the daily heartbeat makes one extra call for today purely to report how far ahead bookings currently run. That probe is best-effort and never counts as a failure.
Alert state machine
decide() in src/monitor.js is pure: state in, { baseState, alerts } out. That makes every
edge case testable without network or clock mocking, and it's where the interesting rules live.
- Publish before persist. Each alert carries its own
statePatch, merged only after that alert's push succeeds. A failed notification retries on the next tick instead of being silently recorded as delivered. - Dedupe by session id, not a boolean. Shows added after the first alert still notify you.
dateOpenAlertedrides an alert patch, never the base state. Otherwise a failed push followed by sessions disappearing could swallow the only notification you'd ever get.- Open-but-empty counts as closed, defending against a
200with no sessions. - Failures are capped at the threshold, so a long outage stops writing state entirely.
- State is written only when it actually changed. Originally this ran on Cloudflare KV with a 1,000 writes/day free limit against 1,440 daily ticks, which forced the discipline; it's still the right behaviour on a filesystem.
The guiding rule throughout: degrade toward duplicate notifications, never toward silence. Every failure path — unreadable state, failed push, failed write — errs that way.
What broke in production
Both failures were about where the traffic came from, not the code.
- Akamai started returning
403to the cloud host after ~2 days. The first version ran as a Cloudflare Worker on a 1-minute cron. It worked, alerted correctly in a fire drill, then hard- blocked — while the same requests from a home connection kept working. Datacenter IP ranges get flagged; residential ones didn't. The monitor's own BROKEN alert is what surfaced this, which is the strongest argument for having it. - ntfy.sh returned
429to that same class of IP. Push delivery failed from cloud egress even with an account token, because rate limits are per visitor IP and shared cloud IPs are already spent. Self-hosting ntfy fixed it immediately.
The fix for both: run it from a residential connection, or route only the site checks through a
SOCKS5 proxy (PROXY_URL) and use your own ntfy server. Notifications deliberately stay direct
so the alerting path has no dependency on the proxy.
Cloudflare Workers cannot do this. fetch() there has no proxy option, and the raw-TCP
escape hatch (connect() from cloudflare:sockets) can't help: startTls() validates the
certificate against the host you dialed, with no SNI override, so you can't form a TLS session
for a third-party origin through a proxy. That's why the project ended up as a plain Node
process in Docker rather than a serverless function.