Dx Music League
I really enjoyed MusicLeague.com but then the addition of ads and cost led me to make my own.

I really enjoyed MusicLeague.com but then the addition of ads and cost led me to make my own.

DX Music League is a self-hosted clone of Music League: players join a league, each round has a theme, everyone submits a song, players vote by spreading a pool of points across the submissions, and scores accumulate on a leaderboard across rounds. It's live in beta with real users, running entirely on AWS serverless infrastructure — and the whole thing also runs 100% offline on your laptop with zero configuration.
This post walks through every piece of the codebase: how the frontend, backend, and infrastructure fit together, and the handful of architectural decisions that everything else hangs off.
Almost everything interesting about this codebase follows from a single pattern, applied three times on the frontend and once on the backend: define a small interface (a "port"), write a mock implementation and a real implementation, and select between them with environment variables.
| Seam | Port | Mock impl | Real impl | Selected by |
|---|---|---|---|---|
| Auth | AuthBackend (src/auth/types.ts) | MockAuth (localStorage) | CognitoAuth (Cognito SRP) | VITE_COGNITO_USER_POOL_ID + VITE_COGNITO_CLIENT_ID set |
| Data | DataClient (src/data/client.ts) | MockClient (in-memory) | ApiClient (REST + JWT) | VITE_API_URL set |
| Music | MusicProvider (src/music/types.ts) | MockMusicProvider | SpotifyMusicProvider, YouTubeMusicProvider | per-league musicProvider field |
| Storage (backend) | Repository (backend/src/data/repository.ts) | MemoryRepository | DynamoRepository | which HTTP adapter boots it |
The consequence: npm run dev with no .env gives you a fully working app — you sign in as a seeded user ("Curator Max"), browse seeded leagues, submit songs from a demo catalog, vote, and reveal results — without touching AWS. Set three env vars and the exact same code talks to Cognito, API Gateway, and DynamoDB.
dxmusicleague/
├── src/ # Frontend (Vite + React 19 + TS)
│ ├── main.tsx, App.tsx # Bootstrap + routes + auth guards
│ ├── auth/ # AuthBackend port, Cognito + mock impls
│ ├── data/ # DataClient port, API + mock impls
│ ├── music/ # MusicProvider port + provider registry
│ ├── domain/types.ts # Core model (League, Round, Ballot, ...)
│ ├── pages/ # One folder-flat file per screen
│ ├── components/ # AppLayout, Tutorial, Avatar, TrackArt
│ ├── lib/ # useAsync, time helpers
│ └── styles/global.css # Design tokens ("Sonic Syndicate" theme)
├── backend/
│ ├── infra/ # CDK app + stack
│ └── src/
│ ├── http/ # routes.ts + lambda.ts + local-server.ts
│ ├── handlers/ # Service layer (leagues, rounds, voting, ...)
│ ├── domain/ # types, errors, rules.ts (the referee)
│ └── data/ # Repository port, memory + dynamo impls
├── docs/ # Design docs (data model, YT Music PoC)
├── poc/ytmusic_search.mjs # Standalone YouTube Music search spike
├── vite-plugin-ytmusic.ts # Dev-only YT Music search proxy
└── scripts/deploy-frontend.shsrc/main.tsx imports polyfills.ts first — it shims a Node-style Buffer global that the Cognito SDK expects (without it, the app blanks with "Buffer is not defined"). vite.config.ts handles the companion problem with define: { global: 'globalThis' }.
src/App.tsx sets up BrowserRouter → AuthProvider → Routes with two guards:
RequireAuth — redirects to /signin, carrying state={{ from: location.pathname + location.search }} so an invite link (/leagues/join?code=DXL-XXXXXX) survives the sign-in round trip.RedirectIfAuthed — keeps signed-in users off the auth screens.All authenticated routes nest inside AppLayout (sidebar + outlet). The route table covers: dashboard (/), leagues list/create/join, per-league hub (/leagues/:leagueId) plus submit, vote, reveal, settings, and preview sub-pages, cross-league /rounds, /leaderboard, /profile, and /help.
src/auth/)The port:
export interface AuthBackend {
readonly requiresConfirmation: boolean;
currentUser(): Promise<AuthUser | null>;
idToken(): Promise<string | null>;
signUp(input: SignUpInput): Promise<SignUpResult>;
confirmSignUp(email: string, code: string): Promise<void>;
resendCode(email: string): Promise<void>;
forgotPassword(email: string): Promise<void>;
confirmForgotPassword(email: string, code: string, newPassword: string): Promise<void>;
signIn(email: string, password: string): Promise<AuthUser>;
signOut(): Promise<void>;
updateDisplayName(displayName: string): Promise<AuthUser>;
}CognitoAuth wraps amazon-cognito-identity-js — pure browser-side SRP, no Amplify. It normalizes SDK errors into an AuthError carrying a .code that pages branch on; the important one is UserNotConfirmedException, which the sign-in page catches and turns into a redirect to /confirm with a freshly re-sent code, instead of dead-ending the user.
MockAuth persists a fake session in localStorage, signs you in as seed user u-me ("Curator Max" — the same identity the mock data store treats as "me"), and has one clever test affordance: any email containing the string "unconfirmed" simulates Cognito's needs-verification flow, so the whole confirm-email UX is exercisable offline.
AuthContext.tsx wraps whichever backend config.ts selected in a React context, restoring the session on mount so there's no signed-out flash.
src/data/)DataClient is the frontend's entire view of the backend — about 25 methods covering league CRUD/join/leave/kick, public discovery and previews, rounds, submissions, ballots, and results. Pages import a single data singleton from src/data/index.ts:
export const isApiMode = Boolean(import.meta.env.VITE_API_URL);
export const data: DataClient = isApiMode ? new ApiClient() : new MockClient();ApiClient is thin: fetch against VITE_API_URL with Authorization: Bearer ${await auth.idToken()} on every request, JSON parsing, and an ApiRequestError(status, message) on failure. MockClient wraps mock.ts, an in-memory store seeded with 7 leagues and 10 rounds spanning every status — including two submissions deliberately tied on points to exercise the tie-break rule in the UI.
src/music/)The port every screen uses to touch a music catalog:
export interface MusicProvider {
readonly info: MusicProviderInfo;
searchTracks(query: string, opts?: SearchOptions): Promise<Track[]>;
getTrack(providerTrackId: string): Promise<Track | null>;
createPlaylist(name: string, tracks: Track[], description?: string): Promise<Playlist>;
}Nothing in the UI or domain references Spotify directly. A league stores a musicProvider id; screens call getProvider(league.musicProvider) and render provider.info.name. Tracks are normalized into a provider-agnostic Track shape with id = "${provider}:${providerTrackId}".
Three registered providers:
MockMusicProvider — a 10-track in-memory demo catalog with client-side substring search.SpotifyMusicProvider — never talks to Spotify from the browser. Everything goes through the backend proxy (GET /spotify/search, etc.) with the Cognito bearer token attached; players never authenticate with Spotify at all.YouTubeMusicProvider — same proxy pattern, targeting /youtube-music/* endpoints. In dev, a bundled Vite plugin serves those endpoints locally (more in the last section).One deliberate gate: the create-league picker calls listProviderOptions(), which is hardcoded to return only Spotify for the beta. Existing leagues on other providers keep working through the registry; new leagues can't pick them until the option is added back — a one-line change.
TIMED_ROUNDS_ENABLED = false beta gate), visibility, and player cap for public leagues.?code= in the URL prefills and auto-joins once.?round= lets you revisit any past round.No CSS framework. Each page has a co-located .css file, and styles/global.css defines the design tokens — a dark purple "Sonic Syndicate" theme with gradient accents. At ≤820px the sidebar disappears in favor of a fixed bottom tab bar (--mobilenav-h includes env(safe-area-inset-bottom) for the iPhone home indicator), and small inputs bump to 16px to defeat iOS Safari's focus auto-zoom.
The backend is a REST API built on AWS CDK, but designed so the exact same handler code runs locally against an in-memory store. It's Node ESM TypeScript run with --experimental-strip-types — no build step for dev or tests.
local dev ───────▶ routes.ts (shared) ◀─────── API Gateway + Lambda
http/local-server.ts handlers/*.ts http/lambda.ts
(Node http, domain/rules.ts (Cognito-authorized)
x-dev-user auth) │ Repository port (data/repository.ts)
┌──────────┴──────────┐
MemoryRepository DynamoRepositorysrc/http/)routes.ts exports a flat, transport-agnostic route table:
export interface Route {
method: string;
pattern: string; // ":name" segments, e.g. "/leagues/:leagueId"
handler: (req: RouteRequest) => Promise<unknown>;
}
export interface RouteRequest {
caller: string; // Cognito sub, or dev stub locally
params: Record<string, string>;
query: Record<string, string>;
body: unknown;
}matchRoute does simple segment matching (literals registered before :param routes so /leagues/browse wins over /leagues/:leagueId). Two adapters consume the same table:
lambda.ts (production) — reads the caller from event.requestContext.authorizer.claims.sub (API Gateway's Cognito authorizer has already validated the JWT), maps thrown ApiErrors to status codes, adds CORS headers.local-server.ts (dev) — a plain Node http server on 127.0.0.1:8787 over MemoryRepository, with the caller stubbed from an x-dev-user header (default u-me).Adding an endpoint means adding one table entry; both transports pick it up.
The known gotcha: route entries do not pass req.body through to handlers. Each one rebuilds the handler input from an explicit field list:
const body = asRecord(req.body);
return leagues.createLeague(deps, req.caller, {
name: asString(body.name),
musicProvider: body.musicProvider as never,
visibility: body.visibility as never,
maxMembers: typeof body.maxMembers === "number" ? body.maxMembers : undefined,
// ...
});Any field not named here is silently dropped before the handler ever sees it. That's a nice input-sanitization property, but it means adding a request field is a two-place change: the handler's input type and the route entry. Forgetting the route half produced a real bug once (a new settings field that saved as a no-op).
src/handlers/)Every handler has the shape (deps: Deps, caller: string, ...args) => Promise<result> where Deps = { repo: Repository; users: UserDirectory }, and throws typed ApiErrors (400/401/403/404/409) from domain/errors.ts.
leagues.ts — the biggest one. Create (with validation: round count 1–20, public leagues need a 2–50 player cap, timed leagues need phase lengths), join by invite code, claim a spot in a public league, leave/kick, invite regeneration, settings, cascade delete. League detail hydrates rounds, runs auto-advance before reading standings (so a just-revealed round's points are banked), computes live submission/voting progress panels, and derives an activity feed from submission/ballot timestamps. Privacy rules are explicit and tested: spectators get inviteCode: "", and progress/activity leak identities only — never tracks or vote allocations. League ids and DXL-XXXXXX invite codes come from crypto randomness with an unambiguous alphabet (no I/L/O/0/1) — deliberately not a counter, which would reset on Lambda cold start and collide.
rounds.ts — owner-only lifecycle. Phase transitions are gated by a fixed map, so no skipping and no going backward:
const ALLOWED_NEXT: Record<RoundStatus, RoundStatus[]> = {
draft: ["submitting"], submitting: ["previewing"], previewing: ["voting"],
voting: [], revealed: [], complete: [],
};Reveal isn't reachable via a raw PATCH at all — it has its own endpoint with its own rules. Closing submissions triggers best-effort playlist creation (a Spotify hiccup never blocks the round).
submissions.ts — submit/replace/remove with the songs-per-player allowance. With allowance 1, re-submitting replaces your pick but keeps a stable submission id, so a ballot referencing it can't be pulled out from under a voter. Duplicate rejection: within a round, no two picks may be the same song and no artist may appear twice — checked against everyone's picks. The votable list returns everyone's submissions except your own, stripped to { id, track } — fully anonymous.
voting.ts / results.ts — cast ballot (delegating all rule enforcement to domain/rules.ts), ballot pre-fill (getMyBallot), owner reveal, and results. finalizeReveal tallies, ranks, and banks each submitter's net points into standings — shared by manual reveal and timed auto-advance so both settle identically.
progression.ts — timed rounds without a scheduler. autoAdvanceRound runs on reads and after writes, cascading a round through every phase whose deadline has passed or where everyone has finished (everyone submitted / everyone voted). Finishing early re-bases the next deadline from now; a league nobody opened for a week catches up in a single read.
providers.ts — the Spotify proxy. Search uses the Client Credentials flow with an app-level token cached until ~60s before expiry, limit clamped to 10 (the February 2026 API change). Playlist creation on reveal uses a separate host-account refresh token. Credentials live in Secrets Manager and never leave the Lambda.
src/domain/rules.ts)Pure functions, no I/O — "the server is the referee":
validateBallot — every allocation targets a real submission; integer points ≥ 0; per-song max (maxPointsPerSong); no self-votes; and the pool must be spent exactly:
if (total !== settings.votePoolSize) {
throw badRequest(`You must spend exactly ${settings.votePoolSize} points (you spent ${total}).`);
}Anti-votes are optional (0 up to downvotePoolSize, itself capped at 2) and can't target your own song.
tallyBallots — per-song net points, pointsFor, pointsAgainst, and distinct-voter counts (only positive votes count as a "voter").
rankSubmissions — the fixed tie-break: points desc → distinct voters desc → title A→Z.
b.points - a.points || b.distinctVoters - a.distinctVoters || a.title.localeCompare(b.title)src/data/)Handlers depend only on the Repository port (~25 methods grouped by entity) plus a separate UserDirectory { getDisplayName } — kept off the repository so the game loop doesn't depend on a user store.
MemoryRepository backs local dev and all tests: plain Maps with structuredClone on every read and write so callers can never mutate the store, seeded with the same fixtures as the frontend mock.
DynamoRepository is a single-table design on a table named MusicLeague:
| Entity | PK | SK |
|---|---|---|
| League meta | LEAGUE#<id> | META |
| Membership | LEAGUE#<id> | MEMBER#<userId> (+ GSI1PK=USER#<id> for "my leagues") |
| Round | LEAGUE#<id> | ROUND#<index4> |
| Submission | ROUND#<roundId> | SUB#<userId>#<submissionId> |
| Ballot | ROUND#<roundId> | BALLOT#<voterId> |
| Standing | LEAGUE#<id> | STANDING#<userId> |
| Invite code | INVITE#<CODE> | META |
Details worth noticing:
"<leagueId>~<index4>" — decodable back into the exact PK/SK, so getRound needs no lookup table.BALLOT#<voterId> sort key; re-voting overwrites atomically.removeMember deletes only the membership row — the standing row survives, which is what makes leave/rejoin (and kick/re-invite) keep a player's points.addStandingPoints uses if_not_exists(points, 0) + delta for create-at-zero semantics.SUB#<userId>, before the multi-song feature) still read and delete correctly.infra/stack.ts)One CDK stack provisions everything:
RemovalPolicy.RETAIN.preventUserExistenceErrors.music-league/spotify secret created empty; real credentials are set out-of-band so they never land in source or the CloudFormation template.NodejsFunction (Node 22, 256 MB, 15s) bundling http/lambda.ts with esbuild; the local-server path tree-shakes away./index.html as 200) so React Router deep links work.npm test runs Node's built-in test runner (node --experimental-strip-types --test) — no Jest, no Vitest, zero test dependencies. Around 46 tests across five files cover the rules engine (ballot validation, tie-breaks, anti-vote tallying), league discovery and privacy (spectators never see invite codes; progress panels never leak picks), the submission allowance and duplicate guards, reveal and standings durability, and timed-progression cascades. Everything runs against MemoryRepository — pure, fast, no AWS.
One caveat baked into the workflow: --experimental-strip-types strips types, it doesn't check them. A type-broken test can pass at runtime. Type checking is its own step: npm run typecheck (tsc --noEmit). Run both.
scripts/deploy-frontend.sh (npm run deploy) encodes a caching lesson learned the hard way:
dist/ to S3 with --cache-control "public,max-age=31536000,immutable" — excluding index.html. Hashed assets can cache forever.index.html separately with --cache-control "no-cache" — it's the pointer to the current hashed bundle, and caching it once left users stuck on a stale app until a hard refresh.The backend deploys with npx cdk deploy from backend/; the same handlers that ran against the memory store in dev now run on Lambda against DynamoDB, with the caller identity coming from Cognito instead of a dev header.
The provider seam was proven before the backend even existed. poc/ytmusic_search.mjs is a standalone spike showing that the unofficial ytmusic-api library can search YouTube Music with no authentication at all, and vite-plugin-ytmusic.ts runs that library inside the Vite dev server, serving /youtube-music/search and /youtube-music/tracks/:id at the same paths a production proxy would — so the YouTubeMusicProvider works end-to-end in npm run dev with zero configuration.
What's deliberately not there yet: playlist creation (ytmusic-api is read-only; the dev proxy returns 501), the production backend routes, and the create-league picker entry. The full story of what exists, why it's gated, and what it takes to finish is the subject of its own post: Adding a New Music Provider: YouTube Music.
The codebase is small enough to read in an afternoon, but the seams make it behave like a much bigger system: every feature was developed against mocks, tested against the memory store, and deployed unchanged against AWS. If you take one thing from this tour, take the pattern — a port, two implementations, and an environment variable is most of what "works offline, deploys serverless" actually requires.