The repository deploys two things. A Next.js app, and a WebSocket server that lives in ws-server/, is a pnpm workspace package with its own package.json, builds with plain tsc to dist/, and runs somewhere else entirely.
Two build roots, two compilers, two sets of options. What I had not noticed is that the first compiler also reads the second one's source.
The glob
{
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
}
**/*.ts from the repository root. ws-server/src/*.ts matches it. There is nothing in the exclude list to stop it:
$ npx tsc --noEmit -p tsconfig.json --listFiles | wc -l
2363
$ npx tsc --noEmit -p tsconfig.json --listFiles | grep -c "ws-server/src"
15
Fifteen files, which is the whole of the WebSocket server:
$ cd ws-server && npx tsc --noEmit --listFiles | grep -v node_modules | wc -l
15
So every file of the second service is in both programs. Not through a project reference, not deliberately, just a glob that starts one directory above where the first project actually lives.
Ten differences and five agreements
Side by side, with the Next.js project's compilerOptions first:
| Option | Next.js app | ws-server |
|---|---|---|
target |
ES2017 | ES2022 |
module |
esnext | NodeNext |
moduleResolution |
bundler | NodeNext |
lib |
dom, dom.iterable, esnext | ES2022 |
jsx |
react-jsx | not set |
allowJs |
true | not set |
incremental |
true | not set |
paths |
@/* to the root |
not set |
types |
unrestricted | ["node"] |
| emit | noEmit |
outDir, rootDir, declaration, sourceMap
|
And the five they agree on: strict, esModuleInterop, skipLibCheck, resolveJsonModule, isolatedModules. The agreements are the ones that change whether code compiles at all; the differences are mostly about what the code is allowed to assume about where it runs.
lib is the one with teeth, and it is easy to test. I dropped a one-line file into ws-server/src:
export const probe = typeof document
$ npx tsc --noEmit -p tsconfig.json | grep -c __scratch_check
0
$ npx tsc --noEmit -p ws-server/tsconfig.json
ws-server/src/__scratch_check.ts(1,29): error TS2584: Cannot find name 'document'.
Do you need to change your target library? Try changing the 'lib' compiler option to include 'dom'.
Clean under the app's config, rejected by the server's own. Which is the right way round in the sense that the stricter checker is the one that gates the deploy of that code, and the wrong way round in the sense that the type check most likely to be run casually, the project-wide one, is the one that will happily tell a Node.js file that document exists. The app's config also hands those files the @/* path alias, which the WebSocket server's own compiler and its runtime both know nothing about.
Thirty-five imports of files that do not exist
The second service is ESM, so every relative import inside it names a .js file:
$ grep -rho "from '\.\{1,2\}/[^']*'" ws-server/src | wc -l
35
$ grep -rho "from '\.\{1,2\}/[^']*\.js'" ws-server/src | wc -l
35
Thirty-five of 35. There is no .js next to any of them; they are all .ts. NodeNext requires the extension that will exist after compilation, and moduleResolution: bundler resolves the same specifier by substituting the .ts it can see. Two resolvers, two different reasons, same strings, and the fact that the root type check passes at all is the proof that the second reason works.
Which is why a test parses instead of importing
The two processes share two timing constants, because the server's auto-close timer has to agree with what the app's clock shows. The server cannot import them: it is a separate build root and cannot reach into the Next.js package. So it keeps a copy, with the copy marked as one:
// ─── 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. __tests__/shared-constants.test.ts in the Next.js
// package parses this file and asserts the values still match.
And the test does exactly that, with a regular expression over the file rather than an import:
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])
}
The test file's own comment explains the choice: pulling ws-server/src into the test run would mean building it first, because of exactly the ESM specifiers above. It also records why the mirror is two constants and not more. An earlier, wider copy of the shared code had drifted in three separate places without anything noticing, so what is left is deliberately tiny and asserted rather than left to a comment asking the next person to remember.
Parsing source with a regex is the kind of thing that gets flagged in review, and in this one case I think it is the right call. The alternative is a build step in front of a two-assertion test, and the regex cannot silently pass: if the shape of the file changes, it throws with the path in the message rather than reporting a match against nothing.
The other thing in that include list
The glob also pulls in .next/types/**/*.ts, which is generated. Today, on my machine:
$ npx tsc --noEmit -p tsconfig.json
.next/types/validator.ts(926,39): error TS2307: Cannot find module '../../app/api/pricing/route.js'
.next/types/validator.ts(989,39): error TS2307: Cannot find module '../../app/webhook/stripe/route.js'
Two errors, and the whole project is otherwise clean. Both of them name route files that were deleted weeks ago, in a generated validator that still remembers them. next build rewrites that directory, so this is not a build failure, it is a stale artifact. But it does mean a plain tsc --noEmit has two results depending on how recently you built, and anybody who runs it to check their own change gets handed two errors that have nothing to do with it. There is no typecheck script in package.json, which is probably why nobody has tripped over this before: the type check here is next build, and next build regenerates the thing that fails.
What I would do about it
The tidy answer is project references: mark ws-server as composite, reference it from the root, and let each project own its own files. That is a real afternoon of work for a two-package repository and it would make the root tsc faster by about fifteen files out of 2,363, which is to say by nothing.
The cheap answer is adding ws-server to the root exclude, which stops the second service's source being checked under rules that do not describe where it runs.
The honest answer is that the accident does free work. A change to a type both halves share gets checked twice rather than once, and the check that runs more often is the app's, so the overlap is more likely to catch it first. So the plan is to keep the coverage and write it down, as a comment in the tsconfig saying that the glob reaches into the other package on purpose. A deliberate overlap you can explain is fine. An overlap nobody knows about is a surprise waiting for whoever adds a third package.
The thing the second process actually does is run the clock and push state to every phone in the room, which you can see on the live leaderboard page and the question timer page. If you want to watch two deployments cooperate, the free tier at pub-trivia.app needs no card, and the first time a phone joins a session it is the second process that answers.
Top comments (0)