Serverless VOD and StreamVault

My gameplay goes from a Switch capture to a shareable HLS stream through a serverless AWS pipeline, a private manager page, and a public site on GitHub Pages.

Nintendo Switch Joy-Con, Xbox and PlayStation controllers, and headphones on a dark desk

At a glance

Pipeline
S3 · MediaConvert · Lambda
Control
Cognito · API Gateway
Edge
CloudFront
Player
React · hls.js · Pages

Built with

  • S3
  • MediaConvert
  • Lambda
  • EventBridge
  • API Gateway
  • Cognito
  • CloudFront
  • React
  • TypeScript
  • hls.js
  • GitHub Pages

From a console capture to a shareable link

I wanted to share my Switch matches without handing them to a video platform, so I built a small video service of my own. Private recordings go in, adaptive HLS streams come out, and a public site plays them.

The product spans two codebases. A serverless AWS workflow does the processing and has its own login-protected manager page. A static React site handles playback. Nothing runs while nobody is uploading or watching.

This match was recorded on a Switch, transcoded by MediaConvert, published by the pipeline, and is streamed from CloudFront, the same path every video on StreamVault takes. Watch it on StreamVault →

Two projects, one product

Project one · Private

Serverless VOD workflow

The AWS side ingests private recordings, transcodes them to adaptive HLS, publishes a catalog, and gives me a login-protected page to run it all.

  • S3
  • Lambda
  • MediaConvert
  • EventBridge
  • CloudFront
  • Cognito
  • API Gateway

Project two · Open source

StreamVault

The viewer side is a static React site on GitHub Pages. It reads the published catalog and plays each video straight from CloudFront.

  • React 19
  • TypeScript
  • Chakra UI
  • Video.js
  • hls.js
  • GitHub Pages

A recording's path to a viewer

Every video follows the same six steps, and only the first three involve me.

  1. Capture on Switch
  2. Upload to S3
  3. Start from the manager
  4. Transcode to HLS
  5. Verify and publish
  6. Watch on StreamVault

Nothing waits on the transcode

The Lambda submits a MediaConvert job and exits. EventBridge reports when the job finishes, so no function sits idle while a long recording is processed.

Publishing is earned

A video enters the catalog only after its master playlist exists and a browser-style CORS check passes on its playlists and segments.

Five boundaries around one catalog

The system splits into a local admin plane, a control plane in AWS, the processing core, a playback edge, and a public client. Everything added after the first version sits at the edges of the core, and all of it meets at the catalog file.

Public client

Playback edge

Processing core

Control plane

Admin plane, local only

sign in

Bearer token

list

start job

edit metadata

publish

catalog and HLS

🧑‍💻 Operator

👤 Viewer

🖥️ Manager SPA

🔐 Cognito user pool

🚪 HTTP API with JWT authorizer

⚙️ ManagerApi

📥 S3 recordings

⚙️ StartVideo

🎞️ MediaConvert

📨 EventBridge

✅ FinalizeVideo with CORS gate

📦 S3 HLS output

📄 catalog.json

🌐 CloudFront with CORS function

📺 StreamVault on GitHub Pages

A private door for the operator

The first version ran on aws lambda invoke and hand-written JSON. The manager page puts the same actions behind a login, validates input, and asks for the video ID typed out before it deletes anything.

Address:localhost:5173
Manager sign-in form with username and password fields and no sign-up option
Sign-in only. Accounts are created by an administrator.
Address:localhost:5173
Catalog list of gameplay videos with playback URLs beside a metadata editor with Save and Remove buttons
Edit titles, dates, and tags, copy playback URLs, or remove a video.
Address:localhost:5173
Recordings list from the input bucket with one file selected and the Start Video form filled in
Pick a recording from S3, describe it, and start a MediaConvert job.

It can't upload files or delete source recordings. That's deliberate, and it keeps the page's permissions smaller than the command line it replaced.

Every request is checked before code runs

The browser calls the API directly, so the API is public at the network level. Cognito and API Gateway decide who gets in before any Lambda starts.

⚙️ ManagerApi🚪 HTTP API🔐 Cognito🖥️ Manager SPA⚙️ ManagerApi🚪 HTTP API🔐 Cognito🖥️ Manager SPAManagerApi never runsalt[Token valid for issuer and audience][Token missing or expired]Sign in with email and passwordAccess tokenPOST /videos/start with Bearer tokenInvokeInvoke StartVideo202 job submitted401 Unauthorized

Asynchronous from upload to publish

A job moves through a short lifecycle, and only one path ends with a video in the catalog. Failures are logged and leave the catalog untouched.

StartVideo creates the job

COMPLETE event

ERROR event

master.m3u8 exists

manifest missing

browser can read every file

header or status wrong

catalog.json updated

logged, catalog untouched

logged, catalog untouched

Submitted

Transcoding

Complete

Failed

ManifestCheck

CorsGate

Rejected

Published

Adaptive HLS from one template

The MediaConvert template produces the master playlist, the renditions, and the segments. There is no transcoder code to maintain.

Private buckets, public edge

Both buckets stay private. CloudFront reads the output bucket through Origin Access Control and is the only public path to a video.

See how the pipeline is built →

The bug curl couldn't see

Some videos played on the public site and others didn't, while every curl test passed. Origin wasn't part of CloudFront's cache key, so whoever requested a file first decided whether the cached copy carried CORS headers.

📦 Private S3☁️ CloudFront🌐 Browser on github.io🔧 curl or health check📦 Private S3☁️ CloudFront🌐 Browser on github.io🔧 curl or health checkStored by path onlyGET segment, no Origin headerCache miss200 without CORS headersGET same segment, Origin github.ioCache hit, still no CORS headersBlocks the read, playback fails

The fix

Make the header constant, then check it before publishing

A CloudFront Function now adds CORS headers to every response, cached or not. The publishing Lambda requests each new video the way a browser would and refuses to list one that a browser couldn't read.

Read the incident →

The public vault

Address:duranalberto.github.io/stream-vault/
StreamVault home page with a welcome panel, game and mode filters, and a grid of six gameplay video cards
The catalog as a grid, filterable by game and by online or offline play.

Static by design. React 19, TypeScript, and Chakra UI, with a lazy-loaded player route so Video.js and hls.js only download when someone opens a video.

It doesn't trust the catalog.A timeout and a strict parser guard every fetch, and a copy bundled at build time kept the grid on screen during the CORS incident.

Hosted on GitHub Pages. A project base path and a 404 redirect handle deep links, and GitHub Actions deploys every push to main.

Address:duranalberto.github.io/stream-vault/video/…
StreamVault video page playing a Splatoon 3 Turf War match with its title, publish date, and duration below
Each video gets its own route with metadata and a share bar.

Read the full build

This page is the overview. The two articles cover the private pipeline and manager in detail, including the decisions and the mistakes.

The outcome

A private pipeline and a public player that meet at one JSON file.

Recordings stay private, playback stays public, and nothing runs between uploads. Each side can change on its own schedule as long as the catalog keeps its shape.