DEV Community

armaan4658
armaan4658

Posted on

How We Solved 360 Video Streaming & Real-Time Uploading for iOS Devices

When building real-time media applications—especially in high-stakes environments like remote exam proctoring—supporting mobile devices comes with a unique set of technical hurdles.

Recently, as part of our MeritTrac 360° camera proctoring upgrade, we had to solve a tricky problem: How do you stream and record real-time video feeds from an iPhone client directly to a custom Node.js media server without hitting iOS WebKit security blocks or causing memory bottlenecks?

In this post, I'll walk through the technical challenges we faced with iOS devices, how we handled in-memory binary media buffers, and the complete solution we implemented.


🛑 The Problem: iOS WebKit Restrictions & Buffer Overheads

When extending video streaming and upload support to iOS (Safari/In-App WebKit views), two major roadblocks immediately appeared:

1. The Infamous {"isTrusted": true} Silent WebSocket Rejection

On Desktop browsers or Postman, opening a secure WebSocket (wss://) to a direct server IP works fine if you accept the self-signed warning. iOS WebKit does not give you that option.

If the domain in the WebSocket URL doesn't strictly match the server's SSL certificate (e.g., attempting wss://192.168.1.5:3059/signaling with a *.domain.com certificate), iOS silently drops the connection and returns an unhelpful error object: {"isTrusted": true}.

2. High-Frequency Video Chunk Uploads Overheating Memory

iOS devices record video chunks continuously. Writing incoming media buffers directly to physical disk on the media server for every tick creates high I/O latency, wears down disk health in auto-scaling cloud nodes, and risks memory leaks if stream sequences drop or re-order.


💡 The Architecture & Implementation

To solve this, we combined three architectural patterns:

  1. Dynamic Wildcard Subdomain Mapping (to satisfy iOS TLS rules).
  2. In-Memory Buffer Decoding (for secure TLS certificate handling).
  3. Sequence-Aware Stream Buffer Piping (for real-time recording and upload).

[ iPhone Candidate App ]

│ 1. Connects via WSS (Valid Wildcard TLS)

[ Node.js HTTPS Media Server ]

├───► [ WebRTC / WebSocket Signaling Engine ]
│ │
│ ▼
└───► [ In-Memory Buffer Chunk Pipeline ] ───► [ S3 / Cloud Storage ]


🛠️ Step 1: Solving iOS SSL Validation in Node.js

Instead of binding our HTTPS server to a raw IP, we decode our production PFX certificate straight into memory out of environment variables and accept incoming streams over a dynamic wildcard subdomain (ip-X-X-X-X.domain.com).

Here is how our Node.js media server bootstrap sets up the secure TLS wrapper:

import * as https from 'https';
import * as express from 'express';
import { InversifyExpressServer } from "inversify-express-utils";

export async function initializeServer() {
  const server = new InversifyExpressServer(container);

  server.setConfig((app) => {
    app.use(express.json({ limit: '10mb' }));
  });

  const app = server.build();

  // 1. Decode PFX certificate directly in-memory (No physical disk footprint)
  let credentials;
  if (process.env.PFX_BASE64_DATA) {
    const pfxBuffer = Buffer.from(process.env.PFX_BASE64_DATA, 'base64');
    credentials = {
      pfx: pfxBuffer,
      passphrase: process.env.PFX_PASSPHRASE
    };
  } else {
    // Fallback for local development
    credentials = {
      pfx: fs.readFileSync('./local-dev-mock.pfx'),
      passphrase: 'local_dev_password'
    };
  }

  // 2. Wrap Express in a native HTTPS server
  const httpsServer = https.createServer(credentials, app);
  const port = process.env.PORT || 3059;

  httpsServer.listen(port, () => {
    console.log(`Media Stream Server listening securely on port ${port}`);
  });

  // 3. Attach WebSockets for real-time signaling
  initSocket(httpsServer);
}
Enter fullscreen mode Exit fullscreen mode

🛠️ Step 2: Handling iOS WebSocket Connections & Media Buffer Sequences

When an iPhone opens a stream, it sends media chunks over the WebSocket alongside sequence numbers (seqNo) to ensure proper order during network fluctuations.

Our signaling utility processes incoming frames, updates session timestamps, and routes the stream payload to the WebRTC peer recorder:

import * as https from 'https';
import * as http from 'http';
import * as WebSocket from 'ws';
import * as jwt from 'jsonwebtoken';

export function initSocket(server: http.Server | https.Server) {
  const wss = new WebSocket.Server({ server, path: "/signaling" });

  wss.on("connection", async (ws: AliveWebSocket, req) => {
    ws.isAlive = true;
    ws.on("pong", () => (ws.isAlive = true));

    try {
      // Parse token and sequence metadata from iOS connection URL
      const urlParams = new URL(req.url!, "https://x").searchParams;
      const token = urlParams.get("token");
      let seqNo = Number(urlParams.get("seqNo")) || 1;

      const payload: any = jwt.verify(token!, process.env.RP_SIGNING_KEY!);
      let clientSession = activeSessions.get(payload.sessionId);

      if (clientSession) {
        // Re-attach active iOS session on reconnection
        clientSession.lastSeen = Date.now();
        clientSession.ws = ws;
        clientSession.seqNo = seqNo;

        if (clientSession.pc.recorder) {
          clientSession.pc.recorder.setSequence(seqNo);
        }
      } else {
        // Create new session instance
        clientSession = {
          ws,
          sessionId: payload.sessionId,
          seqNo,
          lastSeen: Date.now()
        };

        const pc = await createPeer(payload.sessionId, payload.iceServers, seqNo);
        clientSession.pc = pc;
        activeSessions.set(payload.sessionId, clientSession);
      }

      // Incoming Binary/JSON Stream Frames
      ws.on("message", async (msg) => {
        clientSession.lastSeen = Date.now();
        const data = JSON.parse(msg.toString());

        switch (data.type) {
          case "offer":
            await clientSession.pc.setRemoteDescription({ type: "offer", sdp: data.sdp });
            const answer = await clientSession.pc.createAnswer();
            await clientSession.pc.setLocalDescription(answer);
            ws.send(JSON.stringify({ type: "answer", sdp: answer.sdp }));
            break;

          case "ice-candidate":
            await clientSession.pc.addIceCandidate(data.candidate);
            break;

          case "ping":
            ws.send(JSON.stringify({ type: "pong" }));
            break;
        }
      });

    } catch (err) {
      console.error("iOS Signaling Connection Error:", err);
      ws.terminate();
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

🛠️ Step 3: Fetching the Public IP for Dynamic DNS Matching

To ensure the iPhone connects to wss://ip-X-X-X-X.domain.com (which resolves to the server's external IP while preserving the *.domain.com wildcard cert validation), the server queries its public IPv4 address asynchronously during bootup:

import * as https from 'https';

export function getPublicIpAddress(): Promise<string> {
  return new Promise((resolve) => {
    const options = {
      hostname: 'api.ipify.org',
      port: 443,
      path: '/',
      method: 'GET',
      timeout: 3000 // 3-second strict timeout guard
    };

    const req = https.request(options, (res) => {
      let data = '';
      res.on('data', (chunk) => { data += chunk; });
      res.on('end', () => {
        const ip = data.trim();
        const ipv4Regex = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;

        if (res.statusCode === 200 && ipv4Regex.test(ip)) {
          resolve(ip);
        } else {
          resolve("127.0.0.1"); // Fallback
        }
      });
    });

    req.on('error', () => resolve("127.0.0.1"));
    req.on('timeout', () => {
      req.destroy();
      resolve("127.0.0.1");
    });

    req.end();
  });
}
Enter fullscreen mode Exit fullscreen mode

🚀 Key Takeaways

  1. iOS WebKit Is Strict with TLS: Never target raw IP addresses over wss:// for iOS clients. Wrap external IPs inside dynamic subdomains that match your wildcard SSL certificate.
  2. Keep Certs Out of Disk Space: Decode .pfx or .pem files in memory directly from environment variables using Buffer.from(DATA, 'base64') to keep containers stateless.
  3. Sequence Buffers for Reconnections: iOS devices frequently drop and reconnect sockets when moving between Wi-Fi and mobile data. Tracking seqNo on the server allows WebRTC media recorders to stitch video files seamlessly without missing frames.

How are you handling WebRTC/WebSocket media feeds on mobile Safari? Let’s chat in the comments!

Top comments (0)