DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Railway builds our WebSocket server from its own directory, so it cannot see the workspace it belongs to

PubTrivia ships as two deploys from one repository. The Next.js app goes to Vercel. A long-lived Node process that holds the WebSocket connections and the game timers goes to Railway. The repository is a pnpm workspace with exactly one member:

packages:
  - 'ws-server'

overrides:
  '@types/react': 19.2.16
  '@types/react-dom': 19.2.3

allowBuilds:
  '@sentry/cli': true
  core-js: true
  esbuild: true
  sharp: true
  unrs-resolver: true
Enter fullscreen mode Exit fullscreen mode

The root is the app, ws-server/ is the package. Locally that is pleasant: one pnpm install, one lockfile, one node_modules, and the two halves cannot drift apart on a shared dependency version.

Then you point Railway at the ws-server directory and the build fails with pnpm: not found.

The one sentence that explains all of it

Railway builds from the directory you give it, and that directory is not the workspace root.

pnpm-lock.yaml is one level up. pnpm-workspace.yaml is one level up. From inside ws-server/, neither of those files exists. Everything that went wrong went wrong because of that, and once I had written the sentence down, the three fixes were obvious in retrospect and not at all obvious beforehand.

Railpack, Railway's builder, inspects the directory and decides which package manager you are using. With no lockfile in sight it chose npm, then ran a build command that started with pnpm install, and reported the only error it could.

Fix one: say which package manager, in the package

{
  "name": "ws-server",
  "private": true,
  "type": "module",
  "packageManager": "pnpm@11.18.0",
  "engines": {
    "node": ">=24"
  }
}
Enter fullscreen mode Exit fullscreen mode

packageManager is the Corepack field, and build platforms read it as a declaration rather than a hint. Setting it inside ws-server/package.json, not just the root one, is the whole point: this is the file the builder can actually see.

Pin the exact version. pnpm@11 would let the builder resolve a different patch than the one that produced the lockfile.

Fix two: a Node version that the pinned pnpm will run on

engines.node moved to >=24, and there are two independent reasons, which is the only kind of version bump I trust.

pnpm 11 declares engines: { node: '>=22.13' }, so Node 20 cannot run the package manager we just pinned. And Node 20 is past end of life, so the lower bound was going to move anyway. Picking 24 rather than 22.13 means the floor is a release line that is still getting security fixes rather than one that is weeks from the same problem.

Fix three: the devDependency that only worked at the root

This is the one I would not have predicted.

ws-server used to carry tsx so that pnpm dev could run TypeScript directly. tsx depends on esbuild, and esbuild has a postinstall script that fetches and places a platform binary. Since pnpm 10, dependency build scripts do not run unless the package is on an explicit allow list, which in pnpm 11 is the allowBuilds key above. That default is a deliberate supply chain decision and a good one.

Our allowBuilds list includes esbuild. Our allowBuilds list is in pnpm-workspace.yaml. Which is one level up, and therefore does not exist as far as this build is concerned.

So tsx came out of ws-server/package.json entirely:

"scripts": {
    "build": "tsc",
    "start": "node dist/index.js",
    "dev": "tsx --env-file=../.env.local src/index.ts"
}
Enter fullscreen mode Exit fullscreen mode

Note that dev still invokes tsx. It resolves from the root install, because local development happens in a workspace where the root's dev dependencies are on the path, and the ../.env.local in that same line is already an admission that this script only runs from a full checkout. Production never runs it. Production runs tsc and then node dist/index.js, and tsc is the one TypeScript tool in the tree with no native binary to fetch.

A deployed package depending on a tool it does not declare would be a real problem. A development script doing it, in a package that is only ever developed inside its workspace, is a trade I will take to keep a postinstall script out of the deploy.

The build command got shorter

{
  "build": { "buildCommand": "pnpm build" },
  "deploy": {
    "startCommand": "node dist/index.js",
    "healthcheckPath": "/health",
    "healthcheckTimeout": 30,
    "restartPolicyType": "ON_FAILURE",
    "restartPolicyMaxRetries": 10
  }
}
Enter fullscreen mode Exit fullscreen mode

It used to be pnpm install && pnpm build. Railpack already installs, so the install was happening twice, and the second one was the one that could not find pnpm. The fix for a build step that fails is sometimes deleting the build step.

The compile target is worth one note, because it is why the server's own source looks odd:

import { handleHealth } from './healthHandler.js';
import { handleNotify } from './notifyHandler.js';
Enter fullscreen mode Exit fullscreen mode

Those are .ts files, imported with a .js extension, from TypeScript. That is "module": "NodeNext" doing what it is supposed to: the specifier you write is the specifier that ends up in the emitted JavaScript, and Node resolves real files with real extensions. It reads like a mistake and it is the correct thing.

What the health check actually answers

The deploy config points at /health, which is a plain node:http handler and not a framework route:

export function handleHealth(_req: IncomingMessage, res: ServerResponse): void {
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({ status: 'ok', ...connectionStats(), redis: redisEnabled }));
}
Enter fullscreen mode Exit fullscreen mode

Liveness, plus a count of how many phones are currently connected and whether Redis is attached. The counts are there for a specific selfish reason: on a quiz night I want one URL that tells me a room is actually connected, without opening a dashboard. redis is a boolean rather than a name, because a health endpoint is public and should not describe your infrastructure to strangers.

It returns 200 whenever the process can respond, which is the right contract for a liveness probe. It is not a readiness check and does not pretend to be. If Redis is down the server still serves the room it is holding, so failing the health check would have Railway restart a process that is doing its job.

restartPolicyMaxRetries: 10 is the number I am least sure about. Ten restarts of a process that fails to boot is ten chances for a transient dependency to come back, and also ten cycles before the platform gives up and leaves it down. For a service whose outage window is "a pub is mid-quiz", I would rather it kept trying.

None of this is interesting from the floor, which is the point. A host launches a question, the scores move on the big screen, and nobody in the room knows that the process behind it is a workspace package that cannot see its own workspace.

Top comments (0)