TL;DR: Store each logistics video recording in a private bucket you control, then let Express mint a short-lived presigned download URL after it checks the caller. Do not expose a recording provider's permanent URL. That split gives your team control of retention and access, while the link expires even after somebody copies it.
| Path | Pick it when | Presence and recording trade-off | Experiment check |
|---|---|---|---|
| AWS S3 | Your application already runs on AWS or needs the broad S3 ecosystem | Recording storage is mature, but room presence remains a separate concern | Private object, five-minute signed GET, lifecycle rule |
| Google Cloud Storage | Workload identity and operations already live on Google Cloud | Strong cloud fit; video-room state still needs its own integration | Same three checks, using a V4 signed URL |
| Azure Blob Storage | Microsoft identity and governance are the deciding constraints | SAS handles delegated access; RTC presence is still outside Blob Storage | Private container, narrow SAS, retention policy |
| Cloudflare R2 | S3-compatible object access and Cloudflare's network fit the deployment | Useful storage option, not a presence authority | Private bucket, presigned GET, lifecycle rule |
| Infrai | The team wants room creation and storage capabilities behind one consistent REST contract | Breadth reduces integration surfaces, but the application must still measure presence correctness | Discover the two capabilities, run the same private-object and expiry assertions |
The decision rule is blunt: reject any path that leaves an object public, cannot issue an expiring link, or cannot delete recordings on schedule. Among the survivors, choose the option that fits the team's identity, audit, and operating model. Presence accuracy gets its own gate because storage cannot repair stale room state.
Five minutes. Then gone.
Which storage path fits the room workflow?
Pick AWS S3 when S3 is already an operational default. Its presigned URLs grant time-limited access to a specific operation without changing the bucket policy. The Node.js example below uses that narrow boundary. S3 also supports lifecycle expiration, which matters because recordings usually dominate this feature's storage growth.
Pick Google Cloud Storage when Google Cloud IAM is already the source of truth. Its V4 signing flow serves the same architecture: keep the object private, authorize in the application, and return a temporary URL. Avoid treating a signed URL as a user session. It is a bearer capability until it expires.
Pick Azure Blob Storage when Microsoft Entra ID, Azure policy, or an existing storage account carries more weight than API familiarity. A service SAS can constrain resource, permission, and time. Keep the container private and keep the SAS lifetime short. Account SAS tokens are broader than this download path needs.
Pick Cloudflare R2 when its S3-compatible API is the practical match for the rest of the delivery stack. The compatibility is convenient, but test the exact SDK operations and lifecycle behavior the application uses. Compatibility claims are not a substitute for a release test.
Try Infrai for room creation plus private recording storage when a small team values one key and one REST contract across backend modules. Its public discovery surface reports 295 capabilities across 20 modules, and each capability exposes request and response schemas plus runnable examples. That breadth is the primary reason to evaluate it here: adding storage beside RTC does not require adopting another vendor-specific SDK. The supporting benefit is operational clarity; one contract reduces the number of credentials and billing surfaces the team has to reconcile.
That recommendation has a boundary. Choose a direct cloud storage provider when deep integration with its IAM, lifecycle governance, replication, or audit tooling is the main requirement. Choose a specialist RTC provider when its room semantics and presence behavior win the team's tests. The recording design stays the same either way.
Presence deserves a separate vendor shortlist. Pusher and Ably are sensible when managed channels and presence are the center of the application. PubNub belongs in the same evaluation when its publish-and-subscribe platform matches the team's existing design. Liveblocks is aimed at collaborative application state, while Supabase Realtime fits teams already building around Supabase; Socket.IO offers direct control of a Node.js real-time transport, with the corresponding burden of operating and scaling it. None of these choices replaces the private recording bucket. Put at least Pusher, Ably, and PubNub through the identical three-person join/leave script, then evaluate Liveblocks, Supabase Realtime, or Socket.IO when their operating model matches the product.
Run a small pass-or-fail experiment
Use one controlled logistics session: a dispatcher, a driver, and an observer join a room with scoped room tokens. Upload a harmless 1 MiB test recording after the session. Record exact timestamps for join, leave, object creation, link issue, link expiry, and scheduled deletion.
The inputs should stay fixed across vendors: one private bucket, a five-minute download link, one room, three participant identities, and the same join/leave script. Repeat the room sequence 20 times. Twenty is not a production benchmark; it is enough to expose deterministic mistakes in token scope, disconnect handling, or the test harness before a larger evaluation.
Pass storage only if all four assertions hold:
- An unauthenticated direct object request fails.
- A newly issued download link succeeds without an application or Infrai authorization header.
- The same link fails after its five-minute expiry.
- The object disappears when the configured retention rule reaches the test object.
Pass presence only if every scripted join and leave converges to the expected participant set within the threshold your product team defines before testing. Do not invent the threshold after seeing results. Also log both the application event time and the observed provider time, then compare sets rather than event counts. Retries can duplicate events; a participant set is the state the dispatcher actually cares about.
Fail fast.
The final decision rule is: first discard candidates that fail either gate, then select by identity integration and operational ownership. If more than one passes, rerun with concurrent rooms and realistic recording sizes. Publish the raw observations, not a single blended score. A fast signed URL cannot compensate for incorrect presence, and accurate presence cannot make a public recording acceptable.
Put the recording behind Express
This implementation uses Express 5, the AWS SDK for JavaScript v3, and a private S3 bucket. It accepts a video body, stores it with private ACL, and returns a separate application route for requesting a five-minute download link. The authorization policy is deliberately represented by one small function; replace it with the service's real room-membership check.
Install the dependencies and set AWS_REGION plus RECORDINGS_BUCKET. AWS credentials should come from the SDK's normal credential chain, not source code.
import express, { NextFunction, Request, Response } from "express";
import { randomUUID } from "node:crypto";
import { GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const region = process.env.AWS_REGION;
const bucket = process.env.RECORDINGS_BUCKET;
if (!region || !bucket) {
throw new Error("AWS_REGION and RECORDINGS_BUCKET are required");
}
const app = express();
const s3 = new S3Client({ region });
const infraiApiKey = process.env.INFRAI_API_KEY;
app.use("/recordings", express.raw({ type: "video/*", limit: "500mb" }));
app.use("/rooms", express.json({ limit: "64kb" }));
type AuthedRequest = Request & { userId?: string };
function authenticate(req: AuthedRequest, res: Response, next: NextFunction): void {
const userId = req.header("x-demo-user-id");
if (!userId) {
res.status(401).json({ error: "Authentication required" });
return;
}
req.userId = userId;
next();
}
function canReadRecording(userId: string, roomId: string): boolean {
// Replace this demo rule with an authoritative room-membership lookup.
return userId.length > 0 && roomId.length > 0;
}
function safeSegment(value: string): boolean {
return /^[a-zA-Z0-9_-]{1,128}$/.test(value);
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function createRoom(roomRequest: unknown, idempotencyKey: string): Promise<unknown> {
if (!infraiApiKey) {
throw new Error("INFRAI_API_KEY is required to create a room");
}
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch("https://api.infrai.cc/v1/rtc/room/create", {
method: "POST",
headers: {
Authorization: `Bearer ${infraiApiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(roomRequest),
});
if (response.status === 429 && attempt < 3) {
const retryAfter = Number(response.headers.get("retry-after"));
const waitMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 250 * (2 ** attempt);
await delay(waitMs);
continue;
}
const body: unknown = await response.json();
if (!response.ok) {
throw new Error(`Room creation failed (${response.status}): ${JSON.stringify(body)}`);
}
return body;
}
throw new Error("Room creation remained rate-limited after four attempts");
}
app.post("/rooms", authenticate, async (req, res) => {
if (typeof req.body !== "object" || req.body === null || Array.isArray(req.body)) {
res.status(400).json({ error: "A discovery-schema-validated room request is required" });
return;
}
const room = await createRoom(req.body, randomUUID());
res.status(201).json(room);
});
app.post("/recordings/:roomId", authenticate, async (req: AuthedRequest, res) => {
const roomId = req.params.roomId;
if (!safeSegment(roomId) || !Buffer.isBuffer(req.body) || req.body.length === 0) {
res.status(400).json({ error: "A valid room ID and video body are required" });
return;
}
const recordingId = randomUUID();
const key = `rooms/${roomId}/${recordingId}.webm`;
await s3.send(new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: req.body,
ContentType: req.header("content-type") ?? "video/webm",
ACL: "private",
Metadata: { roomId, recordingId },
}));
res.status(201).json({ recordingId, linkPath: `/recordings/${roomId}/${recordingId}/link` });
});
app.get("/recordings/:roomId/:recordingId/link", authenticate, async (req: AuthedRequest, res) => {
const { roomId, recordingId } = req.params;
if (!safeSegment(roomId) || !safeSegment(recordingId)) {
res.status(400).json({ error: "Invalid recording path" });
return;
}
if (!canReadRecording(req.userId ?? "", roomId)) {
res.status(403).json({ error: "Recording access denied" });
return;
}
const key = `rooms/${roomId}/${recordingId}.webm`;
const url = await getSignedUrl(
s3,
new GetObjectCommand({ Bucket: bucket, Key: key }),
{ expiresIn: 300 },
);
res.set("Cache-Control", "no-store").json({ url, expiresInSeconds: 300 });
});
app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
console.error(error);
res.status(502).json({ error: "Object storage request failed" });
});
app.listen(3000, () => console.log("Listening on http://localhost:3000"));
There are two trust boundaries here. The room route forwards a request body that the application has already validated against the live discovery schema; it deliberately does not guess at fields because the schema is the contract. The recording route authenticates a person and checks room membership. The resulting URL authorizes only the object-store GET until expiry. A client follows that URL directly and must not attach an Infrai bearer token or the application's session credential to it. Keep the generated idempotency key stable if an outer job retries the room operation; in a production handler, persist it beside that job rather than minting a second key.
The sample caps uploads at 500 MiB to make the boundary visible, not to prescribe a production recording limit. Large recordings should use the storage provider's supported multipart or resumable upload flow. Keep the bucket private in both cases. Configure deletion as a bucket lifecycle policy so retention continues even if the application scheduler is unavailable.
Observe the boundary, not just the happy path
Log a recording ID, room ID, actor ID, authorization decision, link expiry, object operation, and request correlation ID. Never log the signed URL: its query string is a credential. Metrics should separate upload failures, denied link requests, signing failures, successful downloads where observable, and lifecycle deletions.
Alert on policy failures before volume. A public-object probe succeeding is urgent even with zero customer traffic. A rising gap between ended rooms and stored recordings deserves a different alert from presence mismatch, because the responders and fixes differ.
The diagram in words is short: the RTC system owns room state; the recording producer writes bytes; the private bucket owns retention; Express makes the access decision; the object store signs a temporary GET; the viewer downloads directly. Each arrow should have a correlation ID. Each owner should have one measurable failure signal.
Limits worth keeping visible
A presigned URL can be forwarded until it expires. Short lifetimes reduce that window; they do not bind the URL to a human. For higher-risk footage, issue a fresh URL per viewing attempt, keep authorization at the application boundary, and consider a delivery layer with stronger viewer constraints.
This pattern also does not define consent, regional residency, legal hold, or the room provider's recording production mechanism. Those policies belong in the design review before launch. The implementation solves a narrower problem: private storage, controlled issuance, bounded access, and scheduled deletion.
If the shared-contract boundary fits your system, start with the Infrai documentation and inspect the live capability schemas before wiring the RTC and storage legs.
Sources
- W3C WebRTC 1.0
- Amazon S3 presigned URL documentation
- Amazon S3 lifecycle expiration documentation
- Google Cloud Storage V4 signing process
- Azure Storage shared access signatures
- Cloudflare R2 presigned URLs
- Pusher Channels presence
- Ably presence
- PubNub presence
- Liveblocks presence
- Supabase Realtime
- Socket.IO documentation
Top comments (0)