No description
  • TypeScript 67.5%
  • Rust 26.4%
  • C++ 2.3%
  • CSS 2%
  • HTML 0.7%
  • Other 1.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Conrad Kramer f6c437cb3b
Some checks are pending
Deploy webn.es / deploy (push) Waiting to run
Stream Eden and Dolphin textures directly to NVENC
2026-07-25 17:18:59 -07:00
.forgejo/workflows Run deploys on Namespace Linux 2026-07-20 01:50:53 -07:00
data Initial webn.es emulator app 2026-06-20 18:01:36 -07:00
docs Deploy deterministic GPU runtime pool 2026-07-20 01:47:44 -07:00
dolphin-supervisor Stream Eden and Dolphin textures directly to NVENC 2026-07-25 17:18:59 -07:00
icons Initial webn.es emulator app 2026-06-20 18:01:36 -07:00
infra/aws Make Eden runtime state lazy from GCS 2026-07-25 14:44:21 -07:00
moq-relay Stream live emulator frames through relay batches 2026-07-11 02:32:59 -07:00
roms Initial webn.es emulator app 2026-06-20 18:01:36 -07:00
runtime-node Deploy deterministic GPU runtime pool 2026-07-20 01:47:44 -07:00
scripts Make Eden runtime state lazy from GCS 2026-07-25 14:44:21 -07:00
src Show per-system keyboard controls 2026-07-20 23:13:10 -07:00
workers/push Reduce Eden input and repeat-launch latency 2026-07-24 21:32:35 -07:00
.gcloudignore Make Eden runtime state lazy from GCS 2026-07-25 14:44:21 -07:00
.gitignore Protect private Switch runtime inputs 2026-07-11 18:55:14 -07:00
bun.lock Deploy deterministic GPU runtime pool 2026-07-20 01:47:44 -07:00
index.html Ship continuous cloud play and motion input 2026-07-19 20:40:11 -07:00
manifest.webmanifest Fix mobile app shell controls 2026-06-26 18:17:10 -07:00
package.json Make Eden runtime state lazy from GCS 2026-07-25 14:44:21 -07:00
README.md Verify production cloud gamepad input 2026-07-20 02:38:36 -07:00
styles.css Ship continuous cloud play and motion input 2026-07-19 20:40:11 -07:00
sw.ts Stream private emulator libraries from GCS 2026-07-18 05:50:45 -07:00
tsconfig.json Move app shell to TypeScript 2026-06-27 01:55:20 -07:00

webn.es

Fullscreen offline EmulatorJS, rooted at /, built as a tiny PWA for webn.es.

The installed Home Screen app boots the local Tetris ROM immediately. Normal browser visits show a soft install gate with a Share button and Add-to-Home-Screen instructions, plus a small browser-continue escape hatch for testing. The floating + button opens the native file picker and stores picked ROMs locally in IndexedDB so they can boot again offline. ROM files stay private and are intentionally ignored by git.

The interface stays deliberately thin: hardware gamepads drive EmulatorJS directly, touch devices get a bottom-half control deck, and MENU opens a MinUI-style in-game menu with Continue, Save, Load, State Slot, Options, Netplay, Add ROM, Restart, and Quit-to-Tetris flows. The menu is keyboard/gamepad navigable; Start + Select opens it from a standard web gamepad.

This package discovers supported file types from the local EmulatorJS data/cores/cores.json. The checked-in offline bundle mirrors the official EmulatorJS core catalog, including Game Boy, Game Boy Advance, SNES, Genesis, N64, DS, PlayStation, PSP, arcade, and computer cores where EmulatorJS publishes local core files.

EmulatorJS is the default local emulator multiplexer. The planned Lazuli integration is an additional experimental local GameCube runtime; Dolphin and Eden remain explicitly selected paid cloud runtimes and are never automatic compatibility fallbacks. See docs/runtime-architecture.md for the shared content, input, metadata, and save boundaries.

Cloud capacity is designed as a deterministic four-slot pool: one principal owns at most one slot, changing games replaces that principal's old session, the first four sessions pack onto one GPU node, and the fifth starts the next node. Cloud Run remains the deployed scale-to-zero fallback until the AWS GPU quota is approved. See docs/runtime-capacity.md for measured concurrency, current pricing, lifecycle policy, and the activation runbook.

Release Plan

  1. Edit source files in this folder.
  2. Run bun run mirror:emulatorjs when refreshing the local EmulatorJS core bundle.
  3. Run bun run pack.
  4. Upload the app package to the CDN backing webn.es.
  5. Upload the emulator package only when data/ changes.
  6. Serve the tarball contents at the domain root.

The app release id and emulator release id are separate content hashes by default. Override them with WEBNES_APP_RELEASE=<id> and WEBNES_EMULATOR_RELEASE=<id> when you need named releases. WEBNES_RELEASE=<id> is still accepted as an app-release alias.

bun run pack writes three tarballs:

  • releases/webnes-app-<appRelease>.tar.gz: app shell, service worker, icons, local boot ROM, styles, JS.
  • releases/webnes-emulator-<emulatorRelease>.tar.gz: vendored EmulatorJS runtime, compression workers, local cores, core reports, localization.
  • releases/webnes-<appRelease>-<emulatorRelease>.tar.gz: full one-shot package.

R2 Static Site

R2 stores the static app and EmulatorJS assets. The Cloudflare Worker is the only webn.es/* route and serves static objects from the webnes R2 bucket, while also handling /api/*, /list, and /socket.io/*.

One-time setup:

bunx wrangler login
bunx wrangler r2 bucket create webnes

Deploy:

WEBNES_R2_BUCKET=webnes bun run deploy:r2

The deploy script uploads dist/ one object at a time to the remote R2 bucket with content types and cache metadata. The Worker serves index.html, manifest.webmanifest, release.json, and sw.js as no-cache; release-stamped assets are served immutable.

For app-only releases:

WEBNES_R2_BUCKET=webnes WEBNES_DEPLOY_SCOPE=app bun run deploy:r2

For emulator/core releases:

WEBNES_R2_BUCKET=webnes WEBNES_DEPLOY_SCOPE=emulator bun run deploy:r2
WEBNES_R2_BUCKET=webnes WEBNES_DEPLOY_SCOPE=app bun run deploy:r2

Use WEBNES_DEPLOY_SCOPE=all or omit it for a full upload.

Forgejo Actions

The source repo lives at compatible.systems/conrad/webn.es. Pushes to main run .forgejo/workflows/deploy.yml, restore the private boot ROM from a secret, package with Bun, deploy the Worker, and upload dist/ to the webnes R2 bucket.

Required Forgejo repository secrets:

CLOUDFLARE_ACCOUNT_ID
CLOUDFLARE_API_TOKEN
WEBNES_TETRIS_NES_BASE64

CLOUDFLARE_API_TOKEN needs permission to deploy the webnes-push Worker and write objects to the webnes R2 bucket. VAPID and admin-token Worker secrets stay configured in Cloudflare; the workflow deploy does not need to know their values.

Create the ROM secret from the local file:

base64 -i roms/tetris.nes | pbcopy

Worker API

The static app remains in R2. The Worker handles every https://webn.es/* request and serves static objects for non-API paths.

Current Worker features:

  • GET /api/config: returns runtime settings for the static app, including EMULATORJS_NETPLAY_SERVER when configured.
  • GET /api/push/vapid-key: returns the configured VAPID public key.
  • POST /api/push/subscribe: stores a Home Screen web-app push subscription in the webnes R2 bucket under _worker/push/subscriptions/ and sends a test wake push when VAPID secrets are configured.
  • POST /api/push/send: admin-token protected endpoint to send a wake push to one or all stored subscriptions.
  • GET /list: EmulatorJS netplay room list, backed by a Durable Object.
  • GET /socket.io/*: EmulatorJS-compatible Socket.IO-over-WebSocket netplay relay, backed by a Durable Object.
  • GET /api/sync/status: placeholder for future no-email, passkey-backed, client-side-encrypted ROM sync.
  • GET /*: static app and EmulatorJS assets from the webnes R2 bucket.

Generate VAPID keys:

bun run generate:vapid

Set Worker secrets:

wrangler secret put VAPID_PUBLIC_KEY -c workers/push/wrangler.toml
wrangler secret put VAPID_PRIVATE_KEY -c workers/push/wrangler.toml
wrangler secret put WEBNES_ADMIN_TOKEN -c workers/push/wrangler.toml

Set public Worker vars:

[vars]
VAPID_SUBJECT = "https://webn.es"
EMULATORJS_NETPLAY_SERVER = "https://webn.es"

Deploy Worker:

bun run deploy:worker

EmulatorJS Netplay

The app exposes EmulatorJS native netplay through the Netplay menu item. That path uses EmulatorJS state and input synchronization and points EJS_netplayServer at https://webn.es.

The Cloudflare Worker in this repo is the netplay backend. It implements the Socket.IO-compatible subset EmulatorJS uses: room listing, create room, join room, users-updated, data-message relay, and binary attachment relay. Pairing happens through the EmulatorJS room UI; game state and input messages are relayed centrally through a Durable Object.

For local testing without a Worker, open:

/?netplay=https://webn.es

That stores the URL in localStorage until the next /api/config response overrides it.

Cloud Dolphin Sessions

GameCube/Wii is outside EmulatorJS. The cloud path is split deliberately:

  • Cloudflare Worker is root for public state, matchmaking, and join links.
  • A CloudGameSessionObject Durable Object owns each persistent game session.
  • Cloud Run is the runtime backend and is woken by the Worker through POST /spawn.
  • Browser clients attach to the DO for many-to-many signaling at GET /api/cloud/sessions/:id/socket?peer=<peerId>.
  • The deployed H.264 play path uses one live publisher in Cloud Run, an authenticated Worker fetch response, and browser WebCodecs. Explicit AV1/IVF requests use the same bounded path. ?codec=exact or ?codec=png requests the lossless diagnostic stream for pixel-for-pixel capture checks.
  • Browser clients draw exact PNG frames into a native 640x480 cloud canvas without smoothing or color conversion where supported. AV1/H.264 clients decode with WebCodecs and keep the same native frame geometry, but those modes are lossy play modes rather than pixel-for-pixel proof.
  • Cloud Run uses the NVIDIA L4 for Dolphin rendering plus NVENC encode. The routed Trixie runtime exposes both h264_nvenc and av1_nvenc; public routing and session authority stay at the Cloudflare gateway.
  • The current FFmpeg bridge captures Dolphin's own X11 window with -window_id, requires the window to match the native stream size exactly, and applies no scaler/filter. Exact mode encodes those captured frames as PNG; H.264/AV1 remain lossy low-latency play modes. Texture-to-NVENC interop is still the planned final capture path.
  • Runtime media, diagnostics, and input responses carry a supervisor session identity. The Worker rejects mismatches so a stale or wrong Cloud Run instance cannot look like a valid black-but-connected game.
  • Runtime services are selected through a Worker-managed pool. With the current single Cloud Run backend, a Durable Object runtime lease makes a second room receive 409 runtime service busy; with multiple configured service URLs, each room leases an available runtime.
  • Native MoQ/WebTransport is implemented behind a readiness gate and has no WebSocket fallback. The retired public draft-14 relay is disabled for browser selection; production stays on authenticated fetch until an account-owned Cloudflare relay and draft-compatible clients pass the strict no-fallback smoke. Iroh remains an optional server/native fabric. See docs/live-cloud-transport.md and docs/moq-iroh-relay.md.

The private Eden/Switch runtime requires the owner's dumped prod.keys at the configured EDEN_KEYS_DIR (default /root/.local/share/eden/keys). The supervisor checks for that file before downloading a game or starting the display and returns a visible SWITCH KEYS REQUIRED state when it is absent. Keys, firmware, and game backups are private runtime inputs and are never committed to this repository.

Private Switch inputs use three separate stores:

  • prod.keys and title.keys are versioned in Google Secret Manager, mounted read-only below /var/run/secrets/, and copied with mode 0600 into Eden's writable keys directory at process start.
  • Firmware dumps are stored as versioned private objects under gs://webnes-private-roms-555460938162/switch/firmware/.
  • Base games, updates, and DLC are stored as separate private objects under gs://webnes-private-roms-555460938162/switch/packages/.

With an nxdt dump mounted at /Volumes/Switch, inventory and upload it with Bun:

bun run upload:switch --dry-run
bun run upload:switch
WEBNES_SWITCH_UPLOAD_SCOPE=firmware bun run upload:switch
WEBNES_SWITCH_UPLOAD_SCOPE=packages bun run upload:switch
WEBNES_SWITCH_UPLOAD_CONCURRENCY=2 WEBNES_SWITCH_GCLOUD_ATTEMPTS=8 bun run upload:switch

The uploader validates every published base game, uploads normal NSPs directly, composes FAT32-style split NSP directories in GCS, verifies final object sizes, uploads the firmware NCAs, and writes a private manifest at switch/catalog/manifest.json. It never reads or uploads NAND, PRODINFO, saves, certificates, account data, or keys. Both Dolphin and Eden Cloud Run services mount the bucket read-only at /mnt/webnes-roms. The generic supervisor maps direct gs:// images onto that seekable Cloud Storage FUSE volume, so .ciso, .iso, .rvz, .nsp, and other emulator-native formats are demand-read through GCS ranges instead of copied in full before launch. Authenticated download and extraction remain a compatibility fallback for archived inputs.

For Eden, the selected base NSP stays on the read-only GCS mount and is demand-read directly by the emulator. The supervisor discovers only that base title's matching update/DLC packages, validates their PFS0 tables, and streams their NCA entries once into Eden's writable ephemeral UserNAND. A package-and-size fingerprint reuses the selected content on subsequent launches in the same warm instance; changing games clears that cache instead of accumulating the library. Firmware NCAs use the same warm-instance pattern in ephemeral SysNAND. Source packages remain private and read-only, and Eden never scans or downloads unrelated games.

Cloud controller spots map directly to four Eden pads through the supervisor's in-process localhost Cemuhook/DSU server. Dolphin keeps its native named controller FIFOs. Neither runtime depends on browser keyboard focus for remote play.

Standard gamepads are sampled once per animation frame for cloud play. Eden receives batched analog sticks and optional, user-enabled mobile motion data; local EmulatorJS games continue using EmulatorJS's own browser Gamepad API.

The repository contains only secret resource names, IAM, and mount/deploy configuration. A transient local handoff may use the ignored private/ or switch-dumps/ directories; plaintext keys, firmware, tickets, certificates, NAND dumps, and games must not enter Git history or a container build context. A full NAND backup is a console-recovery artifact and should remain in encrypted offline storage rather than the cloud runtime.

See docs/switch-dumping.md for the read-only Hekate, Lockpick, TegraExplorer, and Mac handoff procedure.

Worker API:

  • GET /api/cloud/capabilities: reports whether Cloud sessions and Cloud Run are configured.
  • GET /api/cloud/games: returns the safe public Switch game catalog without bucket URLs.
  • POST /api/cloud/sessions: creates or reuses a per-session Durable Object.
  • GET /api/cloud/sessions/:id: returns the persistent session snapshot.
  • POST /api/cloud/sessions/:id/join: adds or refreshes a peer.
  • POST /api/cloud/sessions/:id/controller: claims, releases, requests, or grants controller spots.
  • GET /api/cloud/sessions/:id/socket?peer=<peerId>: WebSocket signaling connection.
  • POST /api/cloud/sessions/:id/spawn: asks Cloud Run to start the Dolphin supervisor.
  • POST /api/cloud/sessions/:id/stop: asks Cloud Run to stop the Dolphin supervisor.
  • POST /api/cloud/sessions/:id/runtime: authenticated runtime status callback.
  • POST /api/cloud/sessions/:id/signal: HTTP signaling injection for runtime-side events.
  • POST /api/cloud/sessions/:id/input: relays web/gamepad/touch input to Dolphin or Eden.
  • GET /api/cloud/sessions/:id/diagnostics: reports runtime FFmpeg/NVENC readiness plus Dolphin FIFO or Eden DSU subscription and packet counters through the Cloudflare gateway.
  • GET /api/cloud/sessions/:id/live: reports runtime-local live publisher state through the Cloudflare gateway.
  • GET /api/cloud/sessions/:id/transport: token-protected exact PNG, AV1, or H.264 media gateway endpoint.
  • GET /api/cloud/sessions/:id/live-offer: returns the room's native MoQ/WebTransport offer when the relay is configured and enabled.
  • POST /api/cloud/sessions/:id/close: marks the session stopped and closes sockets.

Worker vars/secrets:

[vars]
CLOUDFLARE_GATEWAY_URL = "https://webn.es"
CLOUD_RUN_SERVICE_URL = "https://your-dolphin-service-url"
CLOUD_RUN_AUDIENCE = "https://your-dolphin-service-url"
WEBNES_MOQ_RELAY_URL = "https://draft-14.cloudflare.mediaoverquic.com"
# Optional prewarmed runtime pool. Audiences are comma-aligned with service URLs.
CLOUD_RUN_SERVICE_URLS = "https://runtime-1,https://runtime-2"
CLOUD_RUN_AUDIENCES = "https://runtime-1,https://runtime-2"
MARIO_KART_ROM_GS_URI = "gs://your-private-rom-bucket/mario-kart.7z"

Use the Cloud Run status.url value for both CLOUD_RUN_SERVICE_URL and CLOUD_RUN_AUDIENCE; ID-token auth is audience-sensitive.

wrangler secret put CLOUD_RUN_SUPERVISOR_TOKEN -c workers/push/wrangler.toml
wrangler secret put GOOGLE_SERVICE_ACCOUNT_KEY -c workers/push/wrangler.toml

The Rust supervisor lives in dolphin-supervisor/. It exposes GET /healthz, GET /readyz, GET /state, GET /diagnostics, GET /snapshot, GET /transport, POST /spawn, POST /stop, POST /restart, and POST /input. The Dockerfile installs Dolphin plus FFmpeg/GStreamer bridge tooling and uses the Rust binary as PID 1. See dolphin-supervisor/STREAMING.md for the exact PNG/H.264/AV1 paths and the L4 texture-to-NVENC plan.

Cache Model

  • dist/index.html, dist/manifest.webmanifest, and dist/sw.js are app-release-stamped by the packager.
  • /data/* URLs are emulator-release-stamped separately from the app shell.
  • The service worker precaches only the small app shell. EmulatorJS files and cores are cached in the emulator namespace when a local game actually requests them.
  • The service worker uses two cache namespaces: webnes-app-<appRelease> and webnes-emulator-<emulatorRelease>.
  • App-only updates keep the existing emulator cache, so EmulatorJS/core assets are not redownloaded unless data/ changes.
  • Cloud routes do not preload the local EmulatorJS runtime or NES core.
  • The app checks for a service-worker update on open, focus, foreground, and pageshow.
  • Navigations and the manifest are network-first with cached fallback, so the app updates when online and still boots offline.
  • dist/_headers contains CDN header recommendations for hosts that support that file format.

Commands

bun run pack
bun run preview
bun run mirror:emulatorjs
WEBNES_R2_BUCKET=webnes bun run deploy:r2
WEBNES_R2_BUCKET=webnes WEBNES_DEPLOY_SCOPE=app bun run deploy:r2
bun run deploy:worker
bun run smoke:cloud-room
bun run smoke:cloud-p2
bun run smoke:cloud-controller-request
bun run smoke:cloud-multiplayer-socket
bun run smoke:cloud-runtime-control
bun run smoke:cloud-runtime-lease
bun run smoke:cloud-socket
bun run smoke:cloud
bun run smoke:cloud-exact-browser
bun run generate:vapid

Use a disposable room ID to verify the deployed browser-to-runtime gamepad path as well as native H.264 frame advancement:

WEBNES_CLOUD_SESSION=codex-input-$(date +%s) \
WEBNES_CLOUD_GAME_ID=mario-kart-double-dash \
WEBNES_CLOUD_BROWSER_CODECS=h264 \
WEBNES_CLOUD_VERIFY_GAMEPAD=1 \
WEBNES_CLOUD_CLOSE_SESSION=1 \
bun run smoke:cloud-codec-browser

The probe observes the live room WebSocket (or the HTTP input fallback), asserts one button and mapped-axis edge per transition, then closes the disposable room.

Preview serves dist/ at http://127.0.0.1:5173.

Files

  • index.html, styles.css, src/app.ts, src/webnes.d.ts, sw.ts: source templates.
  • scripts/pack.ts: Bun release packager with split app/emulator tarballs.
  • scripts/deploy-r2.ts: Bun R2 static-object uploader with all, app, and emulator scopes.
  • scripts/mirror-emulatorjs.ts: mirrors the official EmulatorJS core catalog and published core files into local data/.
  • scripts/generate-vapid.ts: VAPID key generator for Web Push.
  • scripts/serve.ts: Bun static preview server.
  • dolphin-supervisor/: Rust Cloud Run supervisor container for Dolphin.
  • workers/push/: Cloudflare Worker for VAPID push, room signaling, and future sync APIs.
  • data/: vendored EmulatorJS runtime, localization, compression workers, and local cores.
  • roms/: local private ROM directory; only .gitkeep is tracked.
  • icons/: install icon assets.
  • dist/: generated static site.
  • releases/: generated CDN tarballs.