Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,40 @@ jobs:
exit 1
fi

collab-image:
name: Collab relay image
runs-on: ubuntu-22.04
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
persist-credentials: false

- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: "22"

# publish-container.yml only builds the web Dockerfile, so nothing in CI
# ever built the relay image and v2.5.0 shipped one that exited at startup
# on module resolution (GeoLibre#1866). A build alone would not have caught
# it either -- the missing dependency only surfaces when the container
# runs, so this job starts it and talks to it.
- name: Build the collaboration relay image
run: docker build -f workers/collab-node/Dockerfile -t geolibre-collab:ci .

- name: Start the relay container
run: docker run -d --name collab -p 8787:8787 geolibre-collab:ci

- name: Smoke-test the running relay
run: node workers/collab-node/scripts/smoke.mjs http://127.0.0.1:8787

# The container logs carry the module-resolution error that a failed smoke
# test only reports as "never answered GET /health".
- name: Show the container logs
if: ${{ always() }}
run: docker logs collab

checks:
name: Build and test
runs-on: ubuntu-22.04
Expand Down
37 changes: 34 additions & 3 deletions workers/collab-node/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,45 @@ COPY packages/collab-core packages/collab-core
COPY workers/collab-node workers/collab-node
RUN npm run build -w geolibre-collab-node

# Assemble the runtime tree under /runtime so the final stage is one COPY of a
# layout that is known to resolve. esbuild bundles everything except `ws`, and
# `ws` does not always land in /app/node_modules: the root lockfile carries two
# versions of it (8.21.0 hoisted for the app's transitive deps, ^8.21.3 for this
# worker), so npm installs this workspace's copy at the nested
# workers/collab-node/node_modules. Copying only /app/node_modules dropped it and
# the container exited at startup with ERR_MODULE_NOT_FOUND (GeoLibre#1866), so
# take the nested tree too, guarded because it disappears whenever a dependency
# bump lets npm hoist `ws` to the root instead. packages/collab-core comes along
# for a weaker reason: esbuild inlines it, so the running server never imports it
# and it is not what broke the image. It is copied so that the symlink npm leaves
# at node_modules/@geolibre/collab-core points at something, since a dangling
# entry in node_modules trips anything that walks the tree in the container. The
# reinstall drops esbuild and the other dev dependencies now that the bundle is
# built, which is 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.

&& mkdir -p /runtime/workers/collab-node \
&& cp -a node_modules /runtime/node_modules \
&& cp -a packages /runtime/packages \
&& cp -a workers/collab-node/package.json workers/collab-node/dist /runtime/workers/collab-node/ \
&& if [ -d workers/collab-node/node_modules ]; then \
cp -a workers/collab-node/node_modules /runtime/workers/collab-node/node_modules; \
fi

# Load the staged bundle from the path it will actually sit at, so a layout
# regression fails the build here instead of at container start. Importing it is
# side-effect-free: server.js only listens when it is process.argv[1]. The
# `test` is the weaker half -- nothing at runtime resolves that symlink, it just
# asserts the tree was copied whole.
RUN node --input-type=module \
-e "await import('/runtime/workers/collab-node/dist/server.js');" \
&& test -e /runtime/node_modules/@geolibre/collab-core

FROM node:22-bookworm-slim
ENV NODE_ENV=production PORT=8787 COLLAB_DB_PATH=/data/collab.sqlite
WORKDIR /app
# --chown on the COPY rather than a recursive chown afterwards: chowning /app
# rewrites every file, node_modules included, into a second copy in a new layer.
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/workers/collab-node/package.json ./workers/collab-node/package.json
COPY --from=build --chown=node:node /app/workers/collab-node/dist ./workers/collab-node/dist
COPY --from=build --chown=node:node /runtime ./
# /data is created and owned here so a named volume mounted over it inherits the
# ownership; Docker only copies image ownership onto a volume when the path
# already exists, otherwise the mountpoint lands root-owned.
Expand Down
16 changes: 16 additions & 0 deletions workers/collab-node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,22 @@ Endpoints are `POST /sessions`, `GET /sessions/:id/ws`, and `GET /health`.
Persist the directory containing `COLLAB_DB_PATH`, and terminate TLS at the
ingress so browsers can connect with `wss://`.

## Checking a deployment

`scripts/smoke.mjs` exercises the three endpoints against a running relay —
health, session creation, and a WebSocket join round-trip — and needs nothing
beyond Node:

```bash
node workers/collab-node/scripts/smoke.mjs http://127.0.0.1:8787
```

CI runs it against the freshly built image. The Dockerfile already imports the
staged bundle during `docker build`, so a missing `ws` or a broken
`@geolibre/collab-core` target fails there; this script covers the rest, which
only a running container shows: that the process starts and stays up, and that
it actually serves HTTP and completes a WebSocket upgrade.

## Volume ownership

The container runs as the unprivileged `node` user, and the image creates
Expand Down
129 changes: 129 additions & 0 deletions workers/collab-node/scripts/smoke.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
#!/usr/bin/env node
// Smoke-test a *running* GeoLibre collaboration relay: health, session
// creation, and a WebSocket join round-trip.
//
// The relay's worst failure mode is an image that builds clean and then exits at
// startup because the runtime stage is missing a dependency the bundle imports
// (GeoLibre#1866). No unit test or type-check sees that, and neither does
// `docker build` -- only starting the container does. So CI builds the image,
// runs it, and points this script at it. It talks plain HTTP plus the global
// WebSocket, so it needs nothing installed beyond Node itself and can be aimed
// at any deployed relay:
//
// node workers/collab-node/scripts/smoke.mjs http://127.0.0.1:8787

const baseUrl = (process.argv[2] ?? "http://127.0.0.1:8787").replace(/\/+$/, "");
// Generous: on a cold CI runner the container has to start Node and open the
// SQLite database before it listens.
const STARTUP_TIMEOUT_MS = 60_000;
const WS_TIMEOUT_MS = 15_000;
const PROBE_TIMEOUT_MS = 5_000;

function fail(message, detail) {
console.error(`FAIL: ${message}`);
if (detail !== undefined) console.error(detail);
process.exit(1);
}

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function waitForHealth() {
const deadline = Date.now() + STARTUP_TIMEOUT_MS;
let lastError = "no response";
while (Date.now() < deadline) {
try {
// Bounded, because a container that accepts the connection and then never
// answers falls back on undici's own header/body timeouts, which are
// minutes long: the job would stall well past the startup budget instead
// of failing at it. Capped at whatever is left of that budget so a probe
// started near the deadline cannot overrun it either.
const signal = AbortSignal.timeout(Math.min(PROBE_TIMEOUT_MS, deadline - Date.now()));
const response = await fetch(`${baseUrl}/health`, { signal });
// The same signal still covers this: aborting after the headers arrive
// errors the body stream rather than leaving the read hanging.
const body = await response.json();
if (response.ok && body?.ok) return body;
lastError = `HTTP ${response.status} ${JSON.stringify(body)}`;
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
await sleep(500);
}
fail(`the relay never answered GET /health at ${baseUrl}`, lastError);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

async function createSession() {
const response = await fetch(`${baseUrl}/sessions`, {
method: "POST",
headers: { "content-type": "application/json" },
body: "{}",
// Bounded for the same reason as the health probe: the relay has answered
// by now, so anything slower than this is a hang, not a slow start.
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
});
const body = await response.json().catch(() => null);
if (!response.ok || !body?.sessionId || !body?.hostToken)
fail(
"POST /sessions did not return a session",
`HTTP ${response.status} ${JSON.stringify(body)}`,
);
return body;
}

// The join round-trip is the part that actually exercises `ws`: the relay only
// reaches WebSocketServer.handleUpgrade here, so a missing or broken copy of it
// shows up as a failed upgrade rather than a passing health check.
function join(session) {
const wsUrl = `${baseUrl.replace(/^http/, "ws")}/sessions/${session.sessionId}/ws`;
return new Promise((resolve) => {
const socket = new WebSocket(wsUrl);
const timer = setTimeout(() => {
socket.close();
fail(`no welcome frame within ${WS_TIMEOUT_MS}ms of joining ${wsUrl}`);
}, WS_TIMEOUT_MS);

socket.addEventListener("open", () => {
socket.send(
JSON.stringify({
type: "join",
clientId: "smoke-test",
displayName: "Smoke test",
color: "#2563eb",
hostToken: session.hostToken,
}),
);
});
socket.addEventListener("message", (event) => {
clearTimeout(timer);
let message;
try {
message = JSON.parse(String(event.data));
} catch {
fail("the relay sent a frame that is not JSON", String(event.data).slice(0, 200));
}
if (message.type !== "welcome")
fail(
`expected a welcome frame, got "${message.type}"`,
JSON.stringify(message).slice(0, 200),
);
if (message.role !== "host")
fail(`the host token did not claim the host role (got "${message.role}")`);
socket.close();
resolve(message);
});
socket.addEventListener("error", () => {
clearTimeout(timer);
fail(`the WebSocket upgrade to ${wsUrl} failed`);
});
});
}

const health = await waitForHealth();
console.log(`ok GET /health -> ${JSON.stringify(health)}`);
const session = await createSession();
console.log(`ok POST /sessions -> ${session.sessionId} (${session.mode})`);
const welcome = await join(session);
console.log(
`ok WebSocket join -> welcome as ${welcome.role}, ${welcome.participants.length} participant(s)`,
);
console.log(`PASS: the relay at ${baseUrl} is serving`);
Loading