CRUMB a card from devarno-cloud

Per-Build WebSocket Stream

sparki intermediate 4 min read

ELI5

Each build has its own radio channel. You may tune in only if you belong to the workspace that owns the build. The station never waits for a slow listener: if your radio cannot keep up, you are switched off, and you catch up by reading the transcript, which is kept in the database.

Technical Deep Dive

Source: sparki-tools/api-engine pkg/api/server.go:304-332, pkg/api/handlers/builds.go:680-691, pkg/websocket/manager.go:14-240,282-290, pkg/api/handlers/worker.go:549-633 @0b0309f, read 2026-10-25. The previous revision described an internal/websocket hub with global broadcast, rooms and direct messages; the deployed engine has only per-build fan-out.

Who may connect

builds.Use("/:id/stream", AuthorizeBuildStream, UpgradeMiddleware) runs before the upgrade. AuthorizeBuildStream parses the build id and checks that the caller is a member of the build’s workspace (builds.go:682-691). The route sits under /api/v1, so the airlock session check has already run.

sequenceDiagram
participant B as Browser
participant E as api-engine
participant M as websocket.Manager
participant K as build-worker
B->>E: GET /api/v1/builds/ID/stream with session cookie
E->>E: airlock session, then workspace membership
E->>M: register client under build ID
K->>E: POST /internal/worker/builds/ID/logs
E->>E: store new lines in build_logs
E->>M: BroadcastLog per new line
M-->>B: JSON log frame
K->>E: POST complete
E->>M: BroadcastStatus
M-->>B: JSON status frame

Shape

classDiagram
class Manager {
-map buildClients
-chan register 10
-chan unregister 10
-chan broadcast 100
+BroadcastLog(buildID, LogMessage)
+BroadcastStatus(buildID, StatusMessage)
}
class Client {
+string ID
+UUID BuildID
+chan Send 256
}
class LogMessage {
+string build_id
+time timestamp
+string line
+int sequence
+string stream
+string type
}
class StatusMessage {
+string build_id
+string status
+time timestamp
+string message
}
Manager --> Client : per build
Manager --> LogMessage
Manager --> StatusMessage

Back-pressure

  • BroadcastLog and BroadcastStatus wait at most 100 ms for room on the 100-slot broadcast channel, then drop the message and return nil (manager.go:212-238).
  • When a client’s 256-slot Send buffer is full, the manager unregisters that client rather than blocking (manager.go:195-201).
  • The manager is in memory, per API process. Railway runs one engine replica (UNVERIFIED for the current service; the engine’s railway.toml sets no replica count). A second replica would not see the first one’s subscribers.

The live stream is best-effort. The durable record is build_logs, written once per (build_id, seq) before the broadcast, and served by GET /api/v1/builds/:id/logs.

Key Terms

  • buildClients → map from build UUID to that build’s connected clients.
  • stream → stdout, stderr or system; system lines are prefixed [sparki] .
  • AuthorizeBuildStream → pre-upgrade workspace membership check.

Q&A

Q: Who is allowed to open /api/v1/builds/:id/stream, and when is that checked? A: A signed-in member of the workspace that owns the build. The session is checked by the /api/v1 auth middleware and membership by AuthorizeBuildStream, both before the WebSocket upgrade.

Q: What happens to a log line when a client or the broadcast channel is slow? A: If the broadcast channel stays full for 100 ms the message is dropped; if one client’s send buffer is full, that client is disconnected. Neither case blocks the worker’s log upload.

Q: Where does a client get the lines it missed? A: From GET /api/v1/builds/:id/logs, which reads build_logs. Every accepted line is stored there before it is broadcast.

Examples

A user opens a build page on a slow connection. Live frames arrive until the send buffer fills, then the manager drops the connection. The correct recovery for any client is to reconnect, fetch /logs to fill the gap by sequence number, and resume the stream. Whether web-app does exactly this was not checked for this card (UNVERIFIED).

neighbors on the map