pub-trivia.app is two deployed things: a Next.js app, and a standalone WebSocket server that owns question timers. They are separate TypeScript projects with separate build roots, deployed to different platforms, and the WS server cannot import from the Next.js package.
They also have to agree on this:
export const QUESTION_REVEAL_SECONDS = 3
export const CLOSE_GRACE_SECONDS = 1
The app renders a countdown from those numbers. The WS server arms a setTimeout for reveal + limit + grace and closes the question when it fires. Disagree by one second and the server keeps accepting answers after the host's clock reads zero, or it closes while the players' screens still say one. That is not a subtle bug in a room of people playing for a bar tab.
What we did first, and why it rotted
The original answer was a mirror directory. ws-server/src/game/ held copies of scoring.ts, validation.ts, errors.ts, constants.ts and the database schema, kept in sync by hand and by a comment asking the next person to remember.
When we went back and diffed them, three had drifted. The errors copy had lost three codes. The schema copy was missing a column. The scoring copy was fine, but only because nothing in the WS server had ever imported it.
That last one is the most instructive. A mirrored file that nobody imports gives you all of the maintenance cost and none of the guarantee. It looks like safety in a code review. It is a file that can say anything.
What we do now
The mirror is two constants, and the file leads with why:
// ─── INTENTIONAL DUPLICATE ───────────────────────────────────────────────
// Copy of the two timing constants in utils/game/constants.ts. The ws-server
// is a standalone process with its own build root, so it cannot import from
// the Next.js package.
//
// Deliberately limited to the constants this server actually uses. The wider
// mirror (scoring, validation, errors, schema) was imported by nothing here
// and has been deleted: it made the duplication look safer than it was while
// quietly drifting. __tests__/shared-constants.test.ts in the Next.js package
// parses this file and asserts the values below still match, so the remaining
// mirror cannot drift without a test failing.
// ─────────────────────────────────────────────────────────────────────────
scoring.ts was deleted outright, and the surviving copy in the app says so, so nobody restores it on the theory that it once existed for a reason:
This file used to be hand-mirrored into the WS server. That copy was imported by nothing there, so the guarantee the mirror appeared to give was vacuous while the maintenance cost was real. Scoring happens only here.
The test parses the other file as text
This is the part I want to recommend, because the reflex is to be squeamish about it.
const WS_CONSTANTS_PATH = join(process.cwd(), 'ws-server/src/game/constants.ts')
function readWsConstant(name: string): number {
const source = readFileSync(WS_CONSTANTS_PATH, 'utf8')
const match = source.match(new RegExp(`export const ${name}\\s*=\\s*(-?\\d+(?:\\.\\d+)?)`))
if (!match) throw new Error(`${name} not found in ${WS_CONSTANTS_PATH}`)
return Number(match[1])
}
it('agrees with the app on the close grace period', () => {
// The WS auto-close timer is reveal + limit + grace. If this drifts, the
// server keeps accepting answers after the host's clock reads zero, or
// closes while it still reads one.
expect(readWsConstant('CLOSE_GRACE_SECONDS')).toBe(CLOSE_GRACE_SECONDS)
})
Yes, it is a regex over source code. Here is why that is the right call rather than a smell:
Importing it is not free. ws-server/src is a separate TS project using ESM .js import specifiers. Pulling it into the app's Vitest run means building it first, or teaching the test runner about a second module resolution scheme. That is real configuration, and configuration that breaks on unrelated upgrades.
The failure mode is loud. If the regex stops matching, the helper throws with the file path in the message. It cannot silently pass. That is the property that makes a hacky test acceptable: not "it is elegant", but "it cannot fail open".
The blast radius is two lines. This technique does not scale, and it does not have to. It covers exactly the two values that must agree, and the moment someone wants a third, the cost of doing it properly is worth paying.
The rule I would extract
If you have a duplicate you cannot remove, you get to choose between three states, and only two of them are stable.
- Duplicate with a comment asking for care. This is a wish, not a mechanism. It drifts.
- Duplicate that nothing reads. Pure cost. Delete it.
- Duplicate with an executable assertion that it matches. This is fine, and it is fine at any level of hackiness the assertion needs, as long as it fails loudly.
Getting to state 3 is usually much cheaper than people assume. The reason it is rare is not difficulty, it is that reading another package's source from a test feels like cheating. It is not cheating. It is the only thing standing between you and a leaderboard that closes a second early.
A related habit
While you are there, write the consequence in the test, not just the value. Ours says what happens if the constant drifts: answers accepted after zero, or closed at one. Six months from now, someone will see that test fail while doing something unrelated, and their first instinct will be to update the expected number. The comment is what stops them.
The feature all of this protects
pub-trivia.app/features/question-timer is what the two constants add up to from the outside: a countdown that is the same on the host's big screen and on forty phones, and a question that closes even if the host's laptop goes to sleep. The free tier will run you a session with no card if you want to watch the two processes agree.
Top comments (0)