In Node.js, store each completed customer-support video session recording in a private bucket you control, then put playback behind a short-lived presigned link. Do not pass a recording vendor's permanent URL to an agent or customer.
TL;DR: create the video room with scoped participant tokens, copy the completed recording into your own bucket, and presign each playback request after authorization. Keep recording completion in a durable queue; use the realtime channel only to tell connected clients that playback is ready. A publish is not a substitute for a job that must eventually run.
That split is the practical result. It preserves your retention policy and access control, while an expired link limits the useful lifetime of a copied URL. Recordings also grow faster than the rest of this feature, so scheduled deletion belongs in the first release, not a later cleanup sprint.
How should Node.js store a video session recording behind a private link?
A vendor URL couples playback access to the room provider's storage and retention model. Even if the URL is difficult to guess, difficulty is not an authorization decision. A presigned URL is different: the application first decides that this support agent may view this ticket, then creates a link with a narrow lifetime.
The bucket should remain private or signed-only. Never add public-read, and never attach your backend's provider authorization header when the browser follows a presigned URL. The signature in that URL is already the temporary credential.
Keep it private.
There are two clocks to choose. Link expiry should cover a normal playback session without becoming a reusable bookmark. Object retention should follow the support case and compliance policy. They solve different problems. Thirty minutes for a link and 30 days for an object might be reasonable inputs for one business, but they are not universal defaults; decide them explicitly and test deletion.
The minimal Express implementation
This example accepts a completed recording from a trusted internal worker, writes it to a private S3-compatible bucket, and issues an expiring playback URL only after an application authorization hook succeeds. It uses a random object key rather than customer-supplied path text. The upload is idempotent for a given recordingId, so a retried completion job overwrites the same object instead of producing duplicates.
Before wiring an Infrai room or storage call, the following small program reads its live capability contract. This is the useful part of a self-describing API: the integration begins with the actual method, path, JSON Schema, billing information, and runnable examples instead of a guessed SDK call. The discovery surface is public, but the sample still reads the same key used by the RTC, queue, realtime, and storage calls so the handoff has one credential boundary.
type Capability = {
id: string;
method: string;
path: string;
params: unknown;
idempotent: boolean;
};
const baseUrl = process.env.INFRAI_BASE_URL;
const apiKey = process.env.INFRAI_API_KEY;
if (!baseUrl || !apiKey) {
throw new Error("INFRAI_BASE_URL and INFRAI_API_KEY are required");
}
async function discover(id: string, attempt = 0): Promise<Capability> {
const response = await fetch(`${baseUrl}/discovery/${encodeURIComponent(id)}`, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429 && attempt < 4) {
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));
return discover(id, attempt + 1);
}
if (!response.ok) {
throw new Error(`Discovery failed (${response.status}): ${await response.text()}`);
}
return response.json() as Promise<Capability>;
}
const roomCreate = await discover("rtc.room.create");
console.log({
method: roomCreate.method,
path: roomCreate.path,
requestSchema: roomCreate.params,
idempotent: roomCreate.idempotent,
});
Use the returned method, path, and schema to build the room request rather than deriving a path from prose. The same pattern exposes the queue and realtime contracts. This avoids pretending that a made-up field is runnable code.
import express, { type Request } from "express";
import crypto from "node:crypto";
import {
GetObjectCommand,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const bucket = required("RECORDINGS_BUCKET");
const region = required("AWS_REGION");
const s3 = new S3Client({ region });
const app = express();
app.use(express.raw({ type: "video/*", limit: "2gb" }));
function required(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Missing ${name}`);
return value;
}
function safeId(value: string): string {
if (!/^[a-zA-Z0-9_-]{1,128}$/.test(value)) {
throw new Error("Invalid identifier");
}
return value;
}
function objectKey(ticketId: string, recordingId: string): string {
return `support/${safeId(ticketId)}/${safeId(recordingId)}.webm`;
}
async function canViewRecording(req: Request, ticketId: string): Promise<boolean> {
// Replace with the same ticket-membership check used by the support console.
return req.header("x-demo-ticket") === ticketId;
}
app.put("/internal/recordings/:ticketId/:recordingId", async (req, res) => {
if (!Buffer.isBuffer(req.body) || req.body.length === 0) {
return res.status(400).json({ error: "A video body is required" });
}
try {
const Key = objectKey(req.params.ticketId, req.params.recordingId);
await s3.send(new PutObjectCommand({
Bucket: bucket,
Key,
Body: req.body,
ContentType: req.header("content-type") ?? "video/webm",
ServerSideEncryption: "AES256",
Metadata: { checksum: crypto.createHash("sha256").update(req.body).digest("hex") },
}));
return res.status(201).json({ recordingId: req.params.recordingId });
} catch (error) {
console.error(error);
return res.status(502).json({ error: "Recording storage failed" });
}
});
app.post("/tickets/:ticketId/recordings/:recordingId/playback", async (req, res) => {
try {
if (!(await canViewRecording(req, req.params.ticketId))) {
return res.status(403).json({ error: "Forbidden" });
}
const command = new GetObjectCommand({
Bucket: bucket,
Key: objectKey(req.params.ticketId, req.params.recordingId),
});
const url = await getSignedUrl(s3, command, { expiresIn: 900 });
res.setHeader("Cache-Control", "no-store");
return res.json({ url, expiresInSeconds: 900 });
} catch (error) {
console.error(error);
return res.status(502).json({ error: "Playback link creation failed" });
}
});
app.listen(3000);
The demo header is deliberately a replaceable seam, not production authentication. The important ordering is fixed: authorize first, sign second. Also configure a bucket lifecycle rule for deletion; an application cron job is easier to forget and harder to audit.
For large recordings, stream or use multipart upload instead of buffering two gigabytes in the Express process. The buffer keeps this sample readable, but it sets the wrong memory profile for production unless an upstream proxy enforces a much smaller limit.
Delivery guarantees belong outside the socket
Recording completion is a state transition. A connected support console benefits from a realtime notification, but an offline console must not make the completion disappear. Put a small message such as { ticketId, recordingId, status: "ready" } on a durable queue after storage succeeds. A worker can then publish to the ticket's realtime channel and record notification delivery idempotently. Standard queues are at-least-once, so use recordingId as the consumer's deduplication key.
This creates a clear fan-out rule: the queue owns eventual work; the socket owns low-latency presentation. Slow or disconnected subscribers can refetch ticket state after reconnecting, while fast clients get the update immediately. Imagine the upload finishing at 14:03 while the assigned agent's laptop is asleep: the queued recordingId remains work to perform, and the console later reconciles from ticket state. If the worker receives that message twice, its deduplication record turns the second delivery into a no-op. A bare socket publish provides neither property. No recording bytes belong in either path.
Infrai is one option when reducing integration surface matters. Its public discovery response describes capability paths, request and response schemas, billing, and runnable examples, so adding room creation, scoped RTC tokens, a queue, and realtime publishing starts from one machine-readable contract. The queue and socket share one API key and base URL. A Pusher plus Amazon SQS alternative requires two signups, two credential sets, and application glue that maps queued events to channel publishes. The trade-off is concentration: separate services let a team select and replace each delivery component independently.
Pusher is a focused managed-channel choice with broad client support. Ably is worth evaluating when its realtime messaging model matches the delivery semantics you need. PubNub also targets managed publish-and-subscribe applications. Socket.IO gives a Node.js team more direct control over the server layer, but the team then owns its operation and still needs a durable queue. These products do not erase the queue-versus-socket distinction; compare their documented guarantees against the exact offline workflow.
One missed event is enough.
Do not infer stronger delivery from a shared credential. Keep the queue-to-publish worker idempotent, acknowledge a job only after the publish attempt finishes, and make the client reconcile against durable ticket state. Short code is useful. Ambiguous ownership is not.
Choosing the storage and video boundary
The best choice depends on what you want to own:
| Option | Useful fit | Boundary to account for |
|---|---|---|
| Amazon S3 | Teams already using AWS IAM, lifecycle rules, and SQS | More AWS policy and credential configuration |
| Cloudflare R2 | S3-compatible object storage near a Cloudflare delivery stack | Verify the S3 features your implementation relies on |
| Google Cloud Storage | Teams centered on Google Cloud IAM and signed URLs | The signing setup differs from the S3 SDK example |
| Mux | A managed video pipeline where processing and playback matter more than owning raw storage mechanics | Keep your application authorization and retention contract explicit |
| Infrai | One self-describing API surface for RTC, queues, realtime, and storage | A broader shared abstraction is less component-specific than direct vendor SDKs |
These are not interchangeable purchases. S3, R2, and Cloud Storage are primarily object-storage choices. Mux moves further into video processing and playback. Infrai spans backend capabilities behind one contract. For a small team, I would choose the narrowest boundary that removes work I do not want to maintain, then keep the object key and authorization model portable.
The room token should be scoped to the room and participant role. It grants call access, not recording access. Playback still passes through the ticket authorization check, even for somebody who joined the original call.
What to measure before copying this design
Measure the completed-recording size distribution, upload duration, queue age, duplicate completion count, notification lag, presign failures, playback starts after link expiry, and deletion lag. Percentiles matter more than a single average because one long support call can dominate memory and transfer time.
Run three failure drills. Disconnect the agent before completion, deliver the queue message twice, and request playback after both link expiry and object deletion. The expected outcomes are boring: state appears after reconnect, duplicate work changes nothing, the expired link fails, and the deleted recording stays gone.
That is the design worth shipping: private storage under your policy, temporary access after authorization, durable completion work, and realtime fan-out as an optimization. Everything else is vendor selection.
Sources
- W3C WebRTC 1.0
- Amazon S3 presigned URL documentation
- Cloudflare R2 presigned URL documentation
- Google Cloud Storage signed URL documentation
- Mux secure video playback documentation
- Pusher Channels documentation
- Ably message delivery documentation
- PubNub message reliability documentation
- Socket.IO delivery guarantees
- Amazon SQS delivery documentation
Top comments (0)