Sessions in the browser
Give each visitor a session of their own: the page allocates and connects an H3 session over the browser’s WebRTC and plays it, and your server’s only job is to mint the session’s tokens.
The pieces
Section titled “The pieces”- A token server. It holds the API key and answers one request: without a session id, a token that may create one session, capped; with one, a fresh token bound to that open session. The page never sees the key.
- The page’s
Tokens.reactor.createtakestokens: { create, bind }, two effects that ask your server. The session starts oncreate’s token and asksbindfor the next before each expires, so it outlives its first token. - One runtime.
ManagedRuntime.make(layer)builds the SDK’s services once; button handlers are ordinary DOM code that callruntime.runPromise. BuildingBrowserPeer.layerchecks forRTCPeerConnectionandMediaStreamand fails withUnsupportedHostwithout them, before any session is paid for. - Crypto.
H3needs Effect’sCryptoservice for request identities. Effect’s@effect/platform-browserprovides it asBrowserCrypto.layer; this page builds the same layer over Web Crypto, so it needs no further package. - Tracks.
BrowserMedia.tracks(session)gives the current connection’s tracks as the browser’s ownMediaStreamTracks, andBrowserMedia.play(track, element)plays a clone of one in a media element until its scope closes. - A scope the page holds. The session, its peer, its tracks and their playback all live in one
Scopethe page keeps between clicks, so one close releases everything.
Build the page
Section titled “Build the page”-
The token endpoint’s contract, shared by the server and the page as an Effect
HttpApi:Api.ts import { Schema } from "effect";import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";/** A session token, as the page's `Tokens` needs it. */export const SessionToken = Schema.Struct({jwt: Schema.String,expiresAt: Schema.Finite,maxSessionSeconds: Schema.optionalKey(Schema.Int),});export class TokenUnavailable extends Schema.TaggedError<TokenUnavailable>()("TokenUnavailable",{ message: Schema.String },{ httpApiStatus: 503 },) {}/** Without `session`, a token that may create one session; with it, one bound to that session. */export class Api extends HttpApi.make("tokens").add(HttpApiGroup.make("session", { topLevel: true }).add(HttpApiEndpoint.post("token", "/api/token", {payload: Schema.Struct({ session: Schema.optionalKey(Schema.String) }),success: SessionToken,error: TokenUnavailable,}),),) {} -
The server mints tokens with the key it reads from
REACTOR_API_KEY. A creating token caps its session at five minutes, so a page closed without cleanup leaves its session running no longer than that:server.ts import { Config, Effect, Redacted } from "effect";import { HttpApiBuilder } from "effect/unstable/httpapi";import { CoordinatorClient, H3 } from "reactor-effect-client";import { Api, TokenUnavailable } from "./Api.ts";export const TokenHandlers = HttpApiBuilder.group(Api,"session",Effect.fn(function* (handlers) {const apiKey = yield* Config.Redacted("REACTOR_API_KEY");const coordinator = yield* CoordinatorClient.make();const tokens = coordinator.tokens({apiKey,modelName: H3.modelName,maxSessionDuration: "5 minutes",expiresAfter: "6 minutes",});return handlers.handleAll({token: ({ payload }) =>(payload.session === undefined ? tokens.create : tokens.bind(payload.session)).pipe(Effect.map((grant) => ({jwt: Redacted.value(grant.jwt),expiresAt: grant.expiresAt,...(grant.maxSessionSeconds === undefined? {}: { maxSessionSeconds: grant.maxSessionSeconds }),})),Effect.mapError(() => TokenUnavailable.make({ message: "no session token" })),),});}),);Serve
HttpApiBuilder.layer(Api)with these handlers throughHttpRouter.serveand@effect/platform-node’sNodeHttpServer, beside the page and its bundle; it needs anHttpClientsuch asFetchHttpClient.layer. A real deployment authenticates and rate-limits this endpoint, and binds only sessions its caller created: a creating token starts a paid session, and a bound token commands, watches and ends its session. -
The page’s runtime, built once, with the Web Crypto layer beside it:
WebCrypto.ts import { Crypto, Effect, Layer, PlatformError } from "effect";/** Effect's `Crypto` service over Web Crypto, which every secure context has. */export const WebCrypto = Layer.sync(Crypto.Crypto, () =>Crypto.make({randomBytes: (size) => globalThis.crypto.getRandomValues(new Uint8Array(size)),digest: (algorithm, bytes) =>Effect.tryPromise({try: () =>globalThis.crypto.subtle.digest(algorithm, Uint8Array.from(bytes)).then((digest) => new Uint8Array(digest)),catch: (cause) =>PlatformError.systemError({_tag: "Unknown",module: "Crypto",method: "digest",cause,}),}),}),);page.ts import { Effect, Exit, Layer, ManagedRuntime, Redacted, Scope } from "effect";import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";import { HttpApiClient } from "effect/unstable/httpapi";import { CoordinatorClient, H3, Reactor, ReactorError } from "reactor-effect-client";import type { Session } from "reactor-effect-client";import { BrowserMedia, BrowserPeer } from "reactor-effect-browser";import { WebCrypto } from "./WebCrypto.ts";import { Api } from "./Api.ts";const runtime = ManagedRuntime.make(Reactor.layer().pipe(Layer.provide(CoordinatorClient.layer()),Layer.provideMerge(Layer.mergeAll(FetchHttpClient.layer, WebCrypto, BrowserPeer.layer)),),);CoordinatorClient.layer()talks tohttps://api.reactor.incwith no key: the session’s own calls carry its token. -
Start a session on tokens from the server, in a scope the page keeps. The video plays as soon as the session connects:
interface Live {readonly scope: Scope.Closeable;readonly session: Session.Session;readonly provider: H3.Provider;readonly media: BrowserMedia.Tracks;}const start = Effect.fn("start")(function* (video: HTMLVideoElement) {const api = yield* HttpApiClient.make(Api);const token = (session?: string) =>api.token({ payload: session === undefined ? {} : { session } }).pipe(Effect.map((reply) => ({jwt: Redacted.make(reply.jwt),expiresAt: reply.expiresAt,maxSessionSeconds: reply.maxSessionSeconds,})),Effect.mapError(() => ReactorError.ReactorError.fromCode("Http", "no session token")),);const scope = yield* Scope.make();return yield* Effect.gen(function* () {const reactor = yield* Reactor.Reactor;const session = yield* reactor.create({model: H3.modelName,tokens: { create: token(), bind: token },});const provider = yield* H3.make(session);yield* provider.setAutoplay(true);const media = yield* BrowserMedia.tracks(session);yield* BrowserMedia.play(yield* media.track("main_video"), video);const live: Live = { scope, session, provider, media };return live;}).pipe(Scope.provide(scope),Effect.onError(() => Scope.close(scope, Exit.void)),);});A failed start closes the scope at once, which ends a session that was allocated.
-
Sound on its own click. Browsers refuse unmuted playback that starts seconds after the gesture that asked for it, and connecting takes seconds. So the video element is muted (
<video autoplay muted playsinline>) and plays at connect, and the audio waits for a click of its own, in the session’s scope:const soundOn = (live: Live, audio: HTMLAudioElement) =>runtime.runPromise(Effect.gen(function* () {yield* BrowserMedia.play(yield* live.media.track("main_audio"), audio);}).pipe(Scope.provide(live.scope)),); -
Stop, and on
pagehide. Closing the session terminates it and returns itsCloseReport; closing the scope then stops the tracks and playback. Leaving the page does the same, then disposes of the runtime. A start in progress is never doubled: a second Start would open a second paid session that nothing could stop.const stop = (live: Live) =>runtime.runPromise(live.session.close.pipe(Effect.tap(() => Scope.close(live.scope, Exit.void))),);// The DOM side.const video = document.querySelector("video");const audio = document.querySelector("audio");let live: Live | undefined;let starting = false;document.querySelector("#start")?.addEventListener("click", () => {if (video === null || starting || live !== undefined) return;starting = true;runtime.runPromise(start(video)).then((started) => {live = started;starting = false;},() => {starting = false;},);});document.querySelector("#sound")?.addEventListener("click", () => {if (live !== undefined && audio !== null) void soundOn(live, audio);});document.querySelector("#stop")?.addEventListener("click", () => {if (live === undefined) return;const current = live;live = undefined;void stop(current).then((report) => {document.title = report.remote.confirmed ? "ended" : "end not confirmed";});});window.addEventListener("pagehide", () => {const current = live;live = undefined;const closed = current === undefined ? Promise.resolve() : stop(current);void closed.finally(() => runtime.dispose());});A tab that closes may not finish its requests. The creating token’s cap bounds what it leaves running.
Bundle the page for the browser, for example with bun build page.ts --target=browser. The bundle
of this page reaches no Node or native code.
The Studio example is a page built this way, with its token server, that follows each clip from acceptance to its end.
Reconnects
Section titled “Reconnects”The session reconnects a dropped connection on its own. Tracks belong to one connection: after a
reconnect the old clones stop, and BrowserMedia.tracks(session) returns the new connection’s
tracks. A page that should keep playing follows session.changes to the next snapshot whose status
is ready and calls BrowserMedia.play again. publish, unpublish, setTrackActive and
setMaxBitrate act on the connection they came from.
Pitfalls and limits
Section titled “Pitfalls and limits”- Not yet run on hosted Reactor. The browser package has no paid run. Its tests run the peer and playback against DOM fakes on Node and Bun, and real Chrome has exchanged media with the native host locally; see hosted evidence.
- One session per visitor. Each page holds a paid session, and an account runs five at once by default. For many viewers of the same picture, run one channel on a server and stream it.
- No decoded frames. A browser host has platform tracks only:
session.decodedand a playout’svideoandaudiofail withUnsupportedCapability. - Playing is not seeing.
BrowserMedia.playresolves once the element has started playing. It refuses a track that is not live and an element that already has a source, and fails withTimeoutwhen starting takes longer thanplayTimeout(10 seconds by default). - Message bounds. The peer fails the connection with
Overflowfor a message larger than the data channel’s negotiated bound (at most 256 KiB), and refuses a send while more than 1 MiB waits in the channel’s buffer. - Secure context. WebRTC and Web Crypto need
localhostor HTTPS.