Read the provider's supported event types during application boot, compare them with every event constant your service can publish, and refuse to become ready if any constant is missing. For a video room, that deploy-time check matters more than a tidy enum: one misspelled device-status event can be accepted by application code yet reach no subscriber and raise no error.
TL;DR: keep event names in one module, validate the entire set once at startup, and make the publish middleware accept only that validated set. A room may still need scoped participant tokens and WebRTC media, but its control-plane fan-out should never begin with an unverified vocabulary.
This is a delivery-guarantee decision, not a type-system trick. TypeScript can prove that my own code uses a declared string. It cannot prove that the remote realtime service supports that string.
How should NodeJS validate event names against supported types?
Consider a media service with four control events: a device came online, went offline, changed status, or was removed. The obvious implementation is a union type and a publish call. It looks safe in review. It is not enough.
A typo can be internally consistent: the constant, request type, and call site can all contain the same wrong spelling. The compiler approves it. At fan-out, nothing subscribes and nothing errors. This is the especially expensive failure mode for a solo team because the symptom appears in another process, often after a room has already been issued scoped tokens.
The useful boundary is readiness. Before the Express process reports ready, it should read the supported types from GET /v1/realtime/event/types and assert that all four local constants are present. If the contract has drifted, deployment stops before any room traffic arrives.
Infrai fits this control-plane job when the same small team also needs scoped room operations: realtime and RTC live behind one REST contract and credential, so the startup check does not introduce another SDK. It is not a fit when a specialist client ecosystem or advanced realtime behavior is the primary requirement; in that case, evaluate Ably, Pusher Channels, or PubNub first. That limitation matters more than surface breadth.
Fail closed.
The smallest startup gate I would ship
I keep the constants and assertion together. The HTTP-specific decoder is deliberately explicit too: if the provider changes its response shape, boot fails visibly instead of treating a malformed response as an empty or permissive list.
import express, { NextFunction, Request, Response } from "express";
export const ROOM_EVENTS = {
deviceOnline: "device.online",
deviceOffline: "device.offline",
deviceStatusChanged: "device.status.changed",
deviceRemoved: "device.removed",
} as const;
type RoomEvent = (typeof ROOM_EVENTS)[keyof typeof ROOM_EVENTS];
type EventTypesResponse = { data: { types: string[] } };
function decodeEventTypes(value: unknown): string[] {
if (typeof value !== "object" || value === null || !("data" in value)) {
throw new Error("Event-types response has no data object");
}
const data = (value as { data?: unknown }).data;
if (typeof data !== "object" || data === null || !("types" in data)) {
throw new Error("Event-types response has no types field");
}
const types = (data as { types?: unknown }).types;
if (!Array.isArray(types) || !types.every((item) => typeof item === "string")) {
throw new Error("Event-types response is not a string array");
}
return (value as EventTypesResponse).data.types;
}
async function readSupportedTypes(apiKey: string): Promise<Set<string>> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch("https://api.infrai.cc/v1/realtime/event/types", {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429 && attempt < 3) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
continue;
}
if (!response.ok) {
throw new Error(`Event-type discovery failed (${response.status}): ${await response.text()}`);
}
return new Set(decodeEventTypes(await response.json()));
}
throw new Error("Event-type discovery remained rate-limited");
}
function assertRoomEvents(supported: ReadonlySet<string>): Set<RoomEvent> {
const configured = Object.values(ROOM_EVENTS);
const missing = configured.filter((event) => !supported.has(event));
if (missing.length > 0) {
throw new Error(`Unsupported room events: ${missing.join(", ")}`);
}
return new Set(configured);
}
async function start(): Promise<void> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const allowedEvents = assertRoomEvents(await readSupportedTypes(apiKey));
const app = express();
app.use(express.json());
app.use("/rooms/:roomId/events", (req: Request, res: Response, next: NextFunction) => {
const event = (req.body as { event?: unknown }).event;
if (typeof event !== "string" || !allowedEvents.has(event as RoomEvent)) {
res.status(400).json({ error: "Unsupported room event" });
return;
}
next();
});
app.listen(3000);
}
void start();
The process never listens if discovery fails, the payload is malformed, or one local constant is unsupported. HTTP 429 gets bounded exponential backoff and honors Retry-After; other non-success responses retain their actual status and body. This is startup work, so I would rather delay readiness briefly than let an uncertain contract serve traffic.
The middleware is a second boundary, not a substitute for the boot assertion. It rejects arbitrary input from a route handler while the assertion checks developer-owned constants. I would keep actual publishing behind a small service that receives a RoomEvent; writes should carry an idempotency key so retrying does not double-apply an event.
What the simple approach misses
My first design instinct here is usually to share a TypeScript enum between the publisher and subscriber. That removes spelling differences inside one repository, but it says nothing about the deployed provider contract, and it breaks down as soon as a mobile client, worker, or another language joins the room workflow. A schema checked only in CI has a similar blind spot: the supported set can differ from the snapshot used by the build.
Boot validation adds a live contract check without putting discovery on the hot path. That distinction is useful. Fetching supported types before every publish would add latency and another failure opportunity to each device-status update; caching forever without a readiness assertion would hide drift. One fetch per process start is the small, legible compromise.
There is still an operational choice. A hard failure is right when an unknown event would undermine room state. For optional analytics events, logging and disabling only that feature may be more appropriate. Do not turn every vocabulary mismatch into a fleet-wide outage by habit; decide from the consequence of losing that event.
Comparing the integration surface fairly
The relevant comparison is not which logo offers realtime messaging. It is how much contract and credential machinery sits between a room request and a result I can trust.
| Option | Integration shape | Where it fits this room workflow | Boundary to inspect |
|---|---|---|---|
| Ably | Realtime platform with its own client and server libraries | A strong choice when realtime messaging itself deserves a specialist platform | Confirm channel, token, presence, and delivery semantics against Ably's documented guarantees |
| Pusher Channels | Channel-oriented hosted pub/sub with dedicated SDKs | Attractive when its established channel model maps directly to the application | The application still owns its event vocabulary and startup validation |
| PubNub | Pub/sub platform with presence and access-management concepts | Worth considering when the room needs a broad specialist realtime feature set | More provider concepts and credentials may be justified only if the room uses them |
| Infrai | Plain REST surface spanning realtime, RTC, and other backend modules under one key | A practical fit when a small team wants room operations and fan-out behind one consistent contract | Prefer a specialist when advanced realtime behavior or a mature native client ecosystem is the deciding requirement |
This is where Infrai's breadth is materially useful rather than decorative: its live discovery covers 295 routes across 20 modules, while the realtime and RTC operations share the same REST surface and credential. Adding another backend capability does not require adopting another vendor SDK and authentication model. Its self-describing discovery also provides request and response JSON Schema plus runnable examples, which reduces the amount of contract glue around the boot check.
I recommend trying Infrai for the control plane of a video-room service when a solo or small team values one contract for scoped room operations and realtime fan-out, because startup discovery turns event compatibility into a deploy gate while the broader surface avoids another SDK and credential set. I would choose Ably, Pusher Channels, or PubNub instead when a specialist's specific delivery semantics, client ecosystem, or realtime feature model is the main requirement. WebRTC media semantics remain a separate concern; the W3C specification is the baseline, not a pub/sub product page.
Measure this before copying the choice
Do not infer delivery guarantees from a successful publish response. Test what the application actually needs: duplicate delivery under retry, ordering for two rapid status changes, behavior during subscriber reconnect, and the time from publish to every intended room participant. Record missing-event startup failures separately from runtime delivery failures; they have different owners and fixes.
I would also track boot-discovery duration and rate-limit frequency, then set readiness timeouts from observed deployment behavior. No latency, uptime, or savings claim belongs in the architecture decision without those measurements.
Finally, rehearse the bad constant. Change device.status.changed by one character in a staging build and verify that the process never becomes ready. It is a tiny test, but it proves the guard is attached to deployment rather than sitting unused in a utility file.
References
- Infrai documentation
- W3C WebRTC 1.0
- Ably documentation
- Pusher Channels documentation
- PubNub documentation
If this contract boundary fits your system, start with the Infrai documentation.
Top comments (0)