JP
HomeBlogProjectsResumeAbout

© 2026 JP. All rights reserved.

Back to projects

Dx Music League

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

Dx Music League

Tech Stack

Date

July 12, 2026

View SourceLive Demo

DX Music League — A Tour of the Source Code

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.

The one big idea: ports and seams

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.

SeamPortMock implReal implSelected by
AuthAuthBackend (src/auth/types.ts)MockAuth (localStorage)CognitoAuth (Cognito SRP)VITE_COGNITO_USER_POOL_ID + VITE_COGNITO_CLIENT_ID set
DataDataClient (src/data/client.ts)MockClient (in-memory)ApiClient (REST + JWT)VITE_API_URL set
MusicMusicProvider (src/music/types.ts)MockMusicProviderSpotifyMusicProvider, YouTubeMusicProviderper-league musicProvider field
Storage (backend)Repository (backend/src/data/repository.ts)MemoryRepositoryDynamoRepositorywhich 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.

Repository layout

plaintext
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.sh

Frontend

Bootstrap and routing

src/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.

The auth seam (src/auth/)

The port:

ts
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.

The data seam (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:

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.

The music seam (src/music/)

The port every screen uses to touch a music catalog:

ts
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.

Pages, briefly

  • Dashboard — your leagues as cards (status pill, avatar stack, completion bar) plus a "Trending Leagues" strip of open public leagues.
  • Leagues — Your Leagues, Discover (open public leagues you can claim a spot in), and Leagues in Progress (spectate anything running).
  • Create League — name, music service, rounds (1–20), manual vs. timed progression (timed currently behind a TIMED_ROUNDS_ENABLED = false beta gate), visibility, and player cap for public leagues.
  • Join League — invite-code entry; ?code= in the URL prefills and auto-joins once.
  • Round Overview — the league hub. Owner round controls drive the lifecycle (create round → open submissions → reveal songs → open voting → reveal results), plus the invite panel, a round stepper linking finished rounds to their reveals, participation panels (who's submitted/voted — names only, never picks), and the standings sidebar.
  • Submit Song — debounced provider search, track card with preview playback, optional comment; handles the 1–5 songs-per-player allowance with remove buttons and a locked form when slots are full.
  • Vote — spread the point pool across anonymized submissions with steppers clamped to both the remaining pool and the per-song cap; optional anti-votes (💔); per-song comments. Pre-fills from your cast ballot so editing never wipes what you wrote.
  • Reveal — winner card with the +for/−against breakdown, the submitter's note, voter comments, the full ranked list, the round playlist link, and the leaderboard. ?round= lets you revisit any past round.
  • Leaderboard / Profile / Help — per-league standings tabs; editable display name plus cross-league stats; a wiki-style help center with scroll-spy TOC and a replayable six-step guided tour (auto-shown on first sign-in, tracked per user in localStorage).

Styling and mobile

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.


Backend

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.

plaintext
  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          DynamoRepository

The HTTP layer (src/http/)

routes.ts exports a flat, transport-agnostic route table:

ts
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:

ts
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).

The service layer (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:

    ts
    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.

The rules engine (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:

    ts
    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.

    ts
    b.points - a.points || b.distinctVoters - a.distinctVoters || a.title.localeCompare(b.title)

The data layer (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:

EntityPKSK
League metaLEAGUE#<id>META
MembershipLEAGUE#<id>MEMBER#<userId> (+ GSI1PK=USER#<id> for "my leagues")
RoundLEAGUE#<id>ROUND#<index4>
SubmissionROUND#<roundId>SUB#<userId>#<submissionId>
BallotROUND#<roundId>BALLOT#<voterId>
StandingLEAGUE#<id>STANDING#<userId>
Invite codeINVITE#<CODE>META

Details worth noticing:

  • Round ids are "<leagueId>~<index4>" — decodable back into the exact PK/SK, so getRound needs no lookup table.
  • One ballot per voter is schema-enforced by the 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.
  • Public-league discovery is a filtered Scan, explicitly annotated as fine at current scale with a note to promote it to a GSI if leagues grow.
  • Legacy fallbacks everywhere: older single-key submission rows (SUB#<userId>, before the multi-song feature) still read and delete correctly.

Infrastructure (infra/stack.ts)

One CDK stack provisions everything:

  • DynamoDB — the single table, on-demand billing, point-in-time recovery, RemovalPolicy.RETAIN.
  • Cognito — self-signup user pool, email sign-in with auto-verification, SRP-only web client with preventUserExistenceErrors.
  • Secrets Manager — a music-league/spotify secret created empty; real credentials are set out-of-band so they never land in source or the CloudFormation template.
  • Lambda — one NodejsFunction (Node 22, 256 MB, 15s) bundling http/lambda.ts with esbuild; the local-server path tree-shakes away.
  • API Gateway — a REST API with a Cognito authorizer and a single catch-all proxy resource. The Lambda owns all routing; API Gateway just authenticates and forwards.
  • S3 + CloudFront — a private bucket behind Origin Access Control, with the SPA fallback (403/404 → /index.html as 200) so React Router deep links work.

Testing

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.


Deployment

scripts/deploy-frontend.sh (npm run deploy) encodes a caching lesson learned the hard way:

  1. Build with the production env baked in.
  2. Sync dist/ to S3 with --cache-control "public,max-age=31536000,immutable" — excluding index.html. Hashed assets can cache forever.
  3. Upload 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.
  4. Invalidate CloudFront.

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 YouTube Music corner

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.


Closing thought

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.