Skip to content

fix(collab): ship the relay image's runtime dependencies - #1867

Merged
giswqs merged 2 commits into
mainfrom
fix/issue-1866-collab-image-runtime-deps
Aug 12, 2026
Merged

fix(collab): ship the relay image's runtime dependencies#1867
giswqs merged 2 commits into
mainfrom
fix/issue-1866-collab-image-runtime-deps

Conversation

@giswqs

@giswqs giswqs commented Aug 12, 2026

Copy link
Copy Markdown
Member

Fixes #1866.

What was wrong

The v2.5.0 collab-node image builds clean and then exits at startup with
ERR_MODULE_NOT_FOUND: Cannot find package 'ws'.

The runtime stage copied only /app/node_modules, but that is not where npm puts
this worker's ws. The root lockfile carries two versions of it:

├─┬ geolibre-collab-node -> ./workers/collab-node
│ └── ws@8.21.3
└─┬ geolibre-desktop -> ./apps/geolibre-desktop
  │ └── ws@8.21.0      <- hoisted to the root

so npm ci --workspace geolibre-collab-node --workspace @geolibre/collab-core
installs the relay's copy at the nested workers/collab-node/node_modules/ws,
which nothing copied into the runtime stage. And because the selected workspaces
exclude whatever pulls 8.21.0, the root node_modules/ws is not there either, so
nothing resolves. The same stage left node_modules/@geolibre/collab-core
dangling, since packages/collab-core (its symlink target) was never copied.

Nothing in CI caught it: publish-container.yml only builds the web
Dockerfile, so the relay image was never built outside a user's docker compose up.

The fix

Assemble the runtime tree under /runtime in the build stage and copy that:

  • the nested workers/collab-node/node_modules when it exists, guarded with
    if [ -d ... ] because it disappears the moment a dependency bump lets npm
    hoist ws to the root. The image is then correct under either layout rather
    than depending on a hoisting coincidence.
  • packages/collab-core, so the workspace symlink resolves instead of dangling.
  • after a prod-only reinstall, so esbuild and the rest of the dev tree stay
    behind. The copied tree drops from the full dev node_modules to 276 KB.

Then import the staged bundle once at build time. That is side-effect-free
(server.js only listens when it is process.argv[1]), uses the real ESM
resolver from the path the file will actually sit at, and makes a future layout
regression fail the build instead of the container.

Guard against a repeat

Added a collab-image CI job that builds the relay image, runs it, and drives a
new workers/collab-node/scripts/smoke.mjs against it: GET /health,
POST /sessions, and a WebSocket join round-trip. A docker build alone would
not have caught this; only starting the container does. The script needs nothing
beyond Node (it uses the global WebSocket), so it also works against any
deployed relay, and the README documents it.

Verification

No container runtime was available here, so I replayed the Dockerfile's stages on
the filesystem against the real lockfile, which reproduces the report exactly:

before after
node_modules/ws after the build stage's npm ci absent absent (nested, as npm intends)
ws resolvable from dist/server.js in the runtime tree no yes
node_modules/@geolibre/collab-core dangling resolves
node workers/collab-node/dist/server.js ERR_MODULE_NOT_FOUND listens

Against the fixed tree: GET /health returns
{"ok":true,"service":"geolibre-collab"}, the image's HEALTHCHECK command
exits 0, POST /sessions allocates a session, and a real WebSocket client joins
and gets {"type":"welcome",...,"role":"host"} back. I also simulated a future
lockfile where ws hoists to the root and the nested directory is gone: the
guard skips cleanly and the relay still serves.

npm run typecheck -w geolibre-collab-node and npm run test -w geolibre-collab-node (7/7) pass; pre-commit is clean on the changed files.

Summary by CodeRabbit

  • Bug Fixes

    • Improved collaboration relay container packaging to include and resolve all required runtime components.
    • Added deployment validation for service health, session creation, and WebSocket connectivity.
  • Documentation

    • Added instructions for checking a collaboration relay deployment, running the smoke test, and interpreting failures.
  • Tests

    • Added automated validation to confirm the collaboration relay starts successfully and responds correctly in CI.

The v2.5.0 collab-node image exited at startup with ERR_MODULE_NOT_FOUND for
`ws`. The runtime stage copied only /app/node_modules, but that is not where npm
puts this worker's `ws`: the root lockfile carries two versions (8.21.0, hoisted
for geolibre-desktop's transitive deps, and ^8.21.3 for the relay), so
`npm ci --workspace geolibre-collab-node` installs the relay's copy at the nested
workers/collab-node/node_modules, which nothing copied. The same stage left
node_modules/@geolibre/collab-core dangling, since packages/collab-core was never
copied either.

Assemble the runtime tree under /runtime in the build stage instead, and copy
that: the nested node_modules when it exists (it disappears the moment a
dependency bump lets npm hoist `ws` to the root, so the copy is guarded),
packages/collab-core so the workspace symlink resolves, and a prod-only
reinstall so esbuild and the rest of the dev tree stay behind. The staged bundle
is then imported once at build time -- side-effect-free, since server.js only
listens when it is process.argv[1] -- so a future layout regression fails the
build rather than the container.

publish-container.yml only ever built the web Dockerfile, which is how this
shipped. Add a CI job that builds the relay image, runs it, and drives
scripts/smoke.mjs against it: health, session creation, and a WebSocket join
round-trip. A build alone would not have caught this, only starting the
container does.

Fixes #1866
Copilot AI lite review requested due to automatic review settings August 12, 2026 20:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c4dcdfe9-b4ae-4dcd-8295-e764b586d6e5

📥 Commits

Reviewing files that changed from the base of the PR and between cf6034f and 9f244d8.

📒 Files selected for processing (3)
  • workers/collab-node/Dockerfile
  • workers/collab-node/README.md
  • workers/collab-node/scripts/smoke.mjs

📝 Walkthrough

Walkthrough

The collaboration relay Docker image now stages production dependencies and workspace packages under /runtime. A Node smoke test validates health, session creation, and WebSocket joining. CI builds the image, runs the test, and prints container logs. The README documents the deployment check.

Changes

Collaboration relay runtime

Layer / File(s) Summary
Runtime dependency staging
workers/collab-node/Dockerfile
The build stage creates a production-only /runtime tree with dependencies, workspace packages, the worker manifest, and bundle. It validates module resolution before copying the tree into the final image.
Relay smoke-test flow
workers/collab-node/scripts/smoke.mjs
The smoke test polls /health, creates a session through POST /sessions, and validates a WebSocket welcome frame with the host role.
CI and deployment verification
.github/workflows/ci.yml, workers/collab-node/README.md
CI builds the image, runs the smoke test, and prints container logs. The README documents the deployment check.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant GitHubActions
  participant CollabImage as collab-node container
  participant SmokeScript as smoke.mjs
  GitHubActions->>CollabImage: Build and start image
  GitHubActions->>SmokeScript: Run smoke test
  SmokeScript->>CollabImage: Poll GET /health
  CollabImage-->>SmokeScript: Return body.ok
  SmokeScript->>CollabImage: POST /sessions
  CollabImage-->>SmokeScript: Return sessionId and hostToken
  SmokeScript->>CollabImage: Join WebSocket with host credentials
  CollabImage-->>SmokeScript: Return welcome frame with host role
  GitHubActions->>CollabImage: Print container logs
Loading

Poem

A rabbit checked the relay bright,
Health was green and tokens right.
Through WebSockets, ears held high,
The host joined without a cry.
Docker logs sang, “PASS” tonight!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the primary change: shipping the relay image's runtime dependencies.
Linked Issues check ✅ Passed The changes address issue #1866 by staging workspace dependencies, resolving collab-core, and validating image startup and connectivity.
Out of Scope Changes check ✅ Passed The CI job, smoke test, Dockerfile changes, and documentation directly support the runtime dependency fix in issue #1866.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-1866-collab-image-runtime-deps

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed: dependency version conflict. Check your lock file or package.json.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

# so the workspace symlink under node_modules resolves rather than dangling. The
# reinstall drops esbuild and the other dev dependencies now that the bundle is
# built, which is also what keeps the copied tree small.
RUN npm ci --omit=dev --workspace geolibre-collab-node --workspace @geolibre/collab-core \

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor performance nit: this reinstalls with a fresh npm ci --omit=dev, which re-resolves and re-fetches the whole tree a second time (on top of the dev-inclusive npm ci at line 6). Since the lockfile and workspace selection are identical, npm prune --omit=dev after the build would remove the devDependencies-only packages from the already-installed tree without a second network round-trip, and should produce the same pruned layout. Not a correctness issue — the current approach is just slower to build than necessary. (Low confidence this is worth the churn vs. the simplicity of a second clean npm ci.)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Measured this rather than guessing, and it does not hold up — leaving the second npm ci --omit=dev in place.

The literal suggestion, unscoped npm prune --omit=dev, is a no-op here: node_modules stays at 41M with esbuild and @esbuild still installed, so it would ship the build toolchain into the runtime image. It needs the same workspace filters to do anything: npm prune --omit=dev --workspace geolibre-collab-node --workspace @geolibre/collab-core.

With those filters it works, but it is not faster. Both take 1s on an already-populated tree:

time resulting root node_modules
npm ci --omit=dev --workspace … (current) 1s 4.0K, clean
npm prune --omit=dev --workspace … 1s 4.0K, plus empty @esbuild/, @types/, @typescript/, .bin/ shells

There is no second network round-trip to save: the first npm ci has already populated the npm cache in that layer, so the reinstall is cache-served. The whole Collab relay image CI job, docker build and container smoke test included, runs in 21s.

So it is the same speed, and npm ci leaves a cleaner tree to copy and cannot silently degrade to a no-op if the workspace filters drift. Leaving the thread open in case you disagree.

Comment thread workers/collab-node/Dockerfile Outdated
@github-actions

Copy link
Copy Markdown
Contributor

Code review

Bugs

  • None found. Traced the full runtime-tree assembly (nested ws copy guard, packages/collab-core copy, symlink resolution across the /runtime re-root, the build-time import() sanity check) and the final-stage COPY --from=build /runtime ./ against the CMD/HEALTHCHECK paths — all consistent. Also verified scripts/smoke.mjs against the actual server responses (/health, POST /sessions returning sessionId/hostToken/mode, and the WebSocket welcome frame shape with role/participants) — the script's assumptions match server.ts.

Security

  • None found. persist-credentials: false on checkout, no secrets in the new CI job, no registry push, script only talks to 127.0.0.1 inside the CI job.

Performance

  • Low confidence: the runtime-tree stage runs a full second npm ci --omit=dev after the dev-inclusive install already used for the build, re-fetching/re-resolving the whole tree. npm prune --omit=dev on the already-installed tree would likely produce the same pruned layout without the extra network round-trip. (Dockerfile:23)

Quality

  • Medium confidence: packages/collab-core is copied into /runtime and its node_modules symlink is checked with test -e, framed as fixing a "dangling symlink" problem. But workers/collab-node/package.json's build script only externalizes ws (esbuild ... --external:ws), so @geolibre/collab-core gets inlined into dist/server.js at build time like any other first-party import — the running container likely never resolves that package at runtime, unlike ws. If so, the copy/check is harmless but not actually load-bearing for the fix, and the comment's framing overstates its role. (Dockerfile:19-20, and the corresponding test -e check further down)

CLAUDE.md

  • No violations noted — this PR doesn't touch any of the drift-prone mirrored constants or catalogs called out there, and follows the existing Dockerfile/CI conventions (comment style explaining why, --chown on COPY rather than a later chown, persist-credentials: false).

@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

🔍 Cloudflare PR preview

Item Value
Site https://ba073e32.geolibre-preview.pages.dev
Demo app https://ba073e32.geolibre-preview.pages.dev/demo/
Commit 9f244d8

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@workers/collab-node/README.md`:
- Around line 36-38: Update the build-validation description in the README to
state that the Dockerfile’s staged server.js import catches missing ws
dependencies and broken `@geolibre/collab-core` targets during docker build, while
the smoke test validates actual container startup and HTTP/WebSocket behavior.

In `@workers/collab-node/scripts/smoke.mjs`:
- Around line 29-44: Update waitForHealth so each fetch/response.json probe uses
an AbortSignal timeout calculated from the remaining time until deadline, capped
to that remaining startup duration rather than a fixed timeout. Preserve the
existing retry, error-capture, and final fail behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 9baa176b-cf55-4e58-a93d-db08cfbc8be7

📥 Commits

Reviewing files that changed from the base of the PR and between eeea2d5 and cf6034f.

📒 Files selected for processing (4)
  • .github/workflows/ci.yml
  • workers/collab-node/Dockerfile
  • workers/collab-node/README.md
  • workers/collab-node/scripts/smoke.mjs

Comment thread workers/collab-node/README.md Outdated
Comment thread workers/collab-node/scripts/smoke.mjs
@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

🔍 GitHub Pages PR preview

Item Value
Site Deploy failed. See the job log.
Demo app Unavailable
Commit 9f244d8

- Correct the Dockerfile's framing of packages/collab-core: esbuild inlines
  @geolibre/collab-core, so the running server never imports it and it was not
  what broke the image. It is copied only so the symlink npm leaves in
  node_modules points at something, and the build-time `test` on it asserts the
  tree was copied whole rather than guarding startup.
- Fix the README's stale claim that the failure is invisible to `docker build`:
  the Dockerfile now imports the staged bundle there, so the smoke test covers
  what only a running container shows (the process stays up and serves HTTP and
  a WebSocket upgrade).
- Bound the smoke test's health probes with AbortSignal.timeout, capped by the
  remaining startup budget. A container that accepts the connection and never
  answers otherwise falls back on undici's minutes-long timeouts and stalls the
  job past the budget instead of failing at it. Verified against a black-hole
  listener: fails at the budget rather than hanging. POST /sessions is bounded
  the same way.
@github-actions

Copy link
Copy Markdown
Contributor

Code review

I reviewed the Dockerfile restructuring, the new collab-image CI job, the README addition, and the new smoke.mjs script against the bug described in the PR (missing ws / dangling @geolibre/collab-core symlink in the relay's runtime image).

Verification performed:

  • Confirmed ws is --external in the esbuild build script (workers/collab-node/package.json), so it must be resolved from node_modules at runtime — matching the PR's root-cause analysis.
  • Confirmed packages/collab-core has no runtime dependencies of its own (only a devDependency), so npm ci --omit=dev doesn't strip anything it needs.
  • Confirmed relative npm-workspace symlinks (node_modules/@geolibre/collab-core../../packages/collab-core) survive cp -a into /runtime correctly, since both the symlink and its target directory are copied together preserving the relative path.
  • Confirmed the build-stage node --input-type=module -e "await import(...)" check is genuinely side-effect-free: process.argv[1] is unset under -e, so isMain is false and server.js never calls .listen().
  • Checked .dockerignore — the existing !workers/collab-node re-inclusion plus re-narrowing of workers/collab-node/node_modules/dist still correctly keeps a stray host-built tree out of the build context.
  • Read through smoke.mjs end-to-end (timeout handling, AbortSignal.timeout bounding, WebSocket join flow, fail()/process.exit control flow) — didn't find a correctness issue; process.exit() inside the JSON-parse catch block does halt execution before the following message.type dereference, so there's no use of an undefined message.
  • Checked the new collab-image CI job's YAML placement/formatting against sibling jobs — consistent, and it runs independently (no ordering issues).

Bugs: None found.

Security: None found — the smoke script's baseUrl comes from a CI-controlled argv, not untrusted input, and no secrets are introduced.

Performance: Minor, not worth blocking — the runtime-assembly stage does a second full npm ci (reinstall) rather than pruning the dev-installed tree in place; this is explained/intentional in the PR body (keeps the copied tree small and avoids stale dev artifacts) and is a one-time image-build cost, so I'm not flagging it as an issue. (Low confidence this is even worth changing.)

Quality: The Dockerfile/CI/script comments are unusually thorough and accurately describe the actual behavior verified above. No naming or readability concerns.

CLAUDE.md: No violations — this doesn't touch any of the mirrored-constant or catalog-generation conventions called out there, and it's a bug fix + regression-test addition, consistent with repo conventions (branch + PR, no direct main commits implied by the PR flow).

No inline comments posted — I didn't find defects meeting the bar for a specific line-level finding.

@giswqs
giswqs merged commit 95ef6eb into main Aug 12, 2026
38 checks passed
@giswqs
giswqs deleted the fix/issue-1866-collab-image-runtime-deps branch August 12, 2026 21:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: v2.5.0 collab-node runtime image missing workspace deps (ws absent, @geolibre/collab-core symlink dangling)

2 participants