Skip to content

Session recovery

A WebSocket session with a button that kills it, showing what a disconnect destroys and what it takes to resume without a gap.

Updated View as Markdown
// The whole point of this example, with no DOM in it.
//
// Everything a Cap'n Web client has to do about disconnection lives here:
// noticing one, throwing away the capabilities it invalidated, establishing a
// fresh session, and picking the event stream back up without a gap.

import { newWebSocketRpcSession, RpcTarget } from './vendor/capnweb.js';

/**
 * The object the server calls back into.
 *
 * Passing this over RPC gives the server a stub for it, and calling a method
 * on that stub is an RPC in the other direction. This is all "bidirectional
 * calling" is: there is no separate subscription mechanism.
 */
class EventSink extends RpcTarget {
  #onEvent;
  #onGap;

  constructor({ onEvent, onGap }) {
    super();
    this.#onEvent = onEvent;
    this.#onGap = onGap;
  }

  onEvent(event) {
    this.#onEvent(event);
  }

  onGap(sinceId) {
    this.#onGap(sinceId);
  }
}

/**
 * A client that reconnects.
 *
 * `report` is called with a log line for the UI; `onEvent` with each event as
 * it arrives. Everything else is internal.
 */
export class RecoveringClient {
  #url;
  #token;
  #report;
  #onEvent;
  #onStateChange;

  /** Set while connected. All four are invalidated together by a disconnect. */
  #socket = null;
  #api = null;
  #authed = null;
  #subscription = null;

  /**
   * The last authenticated stub we held, kept after teardown purely so the
   * demo can call a method on it and show what a dead stub does.
   */
  #staleAuthed = null;

  /**
   * The resume token: the id of the last event we actually processed.
   *
   * This is the only thing that survives a reconnect, and it survives because
   * it lives out here in our own state rather than in anything the session
   * owns. A stub cannot survive; a number can.
   */
  #cursor = null;

  /** Set when the caller asked to stop, to tell a deliberate close from a drop. */
  #closing = false;

  #state = 'offline';

  constructor({ url, token, report, onEvent, onStateChange }) {
    this.#url = url;
    this.#token = token;
    this.#report = report;
    this.#onEvent = onEvent;
    this.#onStateChange = onStateChange ?? (() => {});
  }

  get state() {
    return this.#state;
  }

  get cursor() {
    return this.#cursor;
  }

  #setState(state) {
    this.#state = state;
    this.#onStateChange(state);
  }

  /**
   * Connect, authenticate, and subscribe -- in one round trip.
   *
   * `authenticate()` returns a promise for the authenticated API, and we call
   * `subscribe()` on that promise without awaiting it first. That is promise
   * pipelining: the second call is sent immediately, carrying a reference to
   * the not-yet-existing result of the first.
   *
   * @param {{ resume?: boolean }} options
   *   `resume: false` deliberately throws the cursor away, so you can watch
   *   the gap appear that a resume token exists to prevent.
   */
  async connect({ resume = true } = {}) {
    if (this.#state !== 'offline') return;
    this.#closing = false;
    this.#setState('connecting');

    // We construct the socket ourselves rather than passing a URL string, so
    // that we hold it and can close it on demand. `newWebSocketRpcSession`
    // accepts either.
    const socket = new WebSocket(this.#url);
    this.#socket = socket;

    const api = newWebSocketRpcSession(socket, undefined);
    this.#api = api;

    // Fires for any end of session: a clean close, a dropped connection, or a
    // protocol error. There is no separate "disconnected" event to listen for.
    api.onRpcBroken((error) => this.#onBroken(error));

    const sink = new EventSink({
      onEvent: (event) => {
        this.#cursor = event.id;
        this.#onEvent(event);
      },
      onGap: (sinceId) => {
        this.#report(
          `server dropped history before #${sinceId}: too far behind to replay`,
          'warn',
        );
      },
    });

    const sinceId = resume ? this.#cursor : null;

    try {
      const authed = api.authenticate(this.#token);
      const subscription = authed.subscribe(sinceId, sink);

      // One await, so everything above cost a single round trip.
      const user = await authed.whoami();

      this.#authed = authed;
      this.#subscription = subscription;
      this.#setState('online');

      this.#report(
        sinceId === null
          ? `connected as ${user.name}; streaming from now (no resume)`
          : `connected as ${user.name}; resuming after #${sinceId}`,
        'good',
      );
    } catch (error) {
      this.#report(`connect failed: ${error.message}`, 'bad');
      this.#teardown();
      this.#setState('offline');
    }
  }

  /**
   * Prove that the capability really is gone after a drop.
   *
   * Calling a method on a stub from a dead session does not hang or silently
   * no-op; it rejects. This is the check the demo runs to make the point.
   */
  async probeStaleStub() {
    const stub = this.#authed ?? this.#staleAuthed;
    if (!stub) return 'nothing to probe -- connect first';
    try {
      const user = await stub.whoami();
      return `stub still works: ${user.name}`;
    } catch (error) {
      return `stub is broken: ${error.message}`;
    }
  }

  /** Simulate losing the network. The socket dies without a clean handshake. */
  sever() {
    if (!this.#socket) return;
    this.#report('severing the connection', 'warn');
    this.#socket.close(4000, 'simulated network loss');
  }

  /** A deliberate shutdown, so `onRpcBroken` is not treated as a failure. */
  disconnect() {
    if (!this.#socket) return;
    this.#closing = true;
    this.#report('disconnecting', 'plain');

    // Disposing the main stub closes the session, and with it the connection.
    this.#api[Symbol.dispose]();
    this.#teardown();
    this.#setState('offline');
  }

  #onBroken(error) {
    if (this.#state === 'offline') return;

    this.#teardown();
    this.#setState('offline');

    if (this.#closing) return;

    this.#report(`session broken: ${error.message}`, 'bad');
    this.#report(
      this.#cursor === null
        ? 'every stub from that session is now dead'
        : `every stub from that session is now dead; cursor held at #${this.#cursor}`,
      'plain',
    );
  }

  /**
   * Drop our references to the session.
   *
   * Deliberately does *not* touch `#cursor`. Everything the session owned is
   * gone; the resume token is ours.
   */
  #teardown() {
    this.#staleAuthed = this.#authed ?? this.#staleAuthed;
    this.#socket = null;
    this.#api = null;
    this.#authed = null;
    this.#subscription = null;
  }
}
/playground/session-recovery/index.htmlOpen

Connect, watch the events arrive, then sever the connection and reconnect. Everything comes back, including the events that happened while you were gone, because the client held on to one number.

Untick Resume from cursor and do it again. Same disconnect, same reconnect, but now the feed shows a gap, because nothing told the server where to start.

What a disconnect destroys

Everything the session owned, and nothing else.

Survives Does not survive
The event log on the server The AuthedApi stub
The cursor, in client-side state The Subscription stub
The token, in client-side state The authenticated user held on the server object
Any call in flight

The Call a stub from the old session button makes the second column concrete. It holds on to the AuthedApi from before the drop and calls whoami() on it. That call does not hang and does not quietly reconnect. It rejects:

const stub = this.#authed ?? this.#staleAuthed;
try {
  const user = await stub.whoami();
  return `stub still works: ${user.name}`;
} catch (error) {
  return `stub is broken: ${error.message}`;
}

There is no automatic reconnection in Cap’n Web, and this is why: the library cannot know whether the object a stub pointed at still exists, still means the same thing, or should still be reachable by you. Recovery is a decision only the application can make.

Why the log lives outside the session

The server’s API object is created fresh for every connection, and the authenticated user lives on the object that authenticate() returns. That is the object-capability pattern doing its job: the token crosses the wire once, and after that, holding the stub is the authorization.

But it means all of that state dies with the socket. Anything that has to outlive a disconnect has to be somewhere else, which is why createEventLog() is called at module scope and passed in:

const log = createEventLog();

export function createMain() {
  return new PublicApi(log);
}

Resuming without a gap

The subscription takes the id of the last event the client actually processed:

subscribe(sinceId, sink) {
  const from = sinceId ?? this.#log.latestId();

  // Stubs received as parameters are disposed when the call returns, so a
  // callback that will be used later has to be duplicated first.
  return new Subscription(this.#log, sink.dup(), from);
}

The cursor is the client’s, not the server’s. It is a plain number in client-side state, which is exactly why it survives; a stub could not. Design the API so the caller can say where it left off, and reconnection becomes a normal operation rather than a recovery procedure.

sink is a callback going the other way. The client passes an RpcTarget, the server receives a stub for it, and calling a method on that stub is an RPC back into the browser. That is all server-initiated messaging is here; there is no separate subscription mechanism.

The .dup() is mandatory. Stubs arriving in parameters are disposed when the call returns, so holding one past that requires duplicating it. The Subscription disposes its copy in [Symbol.dispose](), which also runs when the session dies, and that is what stops the timer on an abrupt disconnect.

Replay has to be bounded

A resume token from a client that has been gone for a week is a request to replay a week. The server caps it and tells the client when it has fallen too far behind:

const from = Math.max(sinceId, latest - MAX_REPLAY);
return { events, truncated: from > sinceId };

The client surfaces that as a gap rather than pretending it received everything. An unbounded replay is a denial-of-service vector. See Security considerations.

How this page runs

The other two playgrounds shim fetch. A WebSocket upgrade cannot be expressed that way inside a page, so this one replaces the WebSocket constructor for /ws and returns one end of a pair whose other end is handed to a real session:

function Shim(url, protocols) {
  if (new URL(url, location.href).pathname !== WS_PATH) {
    return new NativeWebSocket(url, protocols);
  }
  const { client, server } = connectedPair(String(url));
  newWebSocketRpcSession(server, createMain(ENV));
  return client;
}

This skips the Worker’s fetch handler, so upgrade handling is the one part of the example the page does not exercise. It keeps the API implementation, the session, the wire protocol, calls in both directions, and a connection that can genuinely be severed, which is the only thing this example is really about.

Run it yourself

npm run build   # the examples resolve capnweb to dist/
npx wrangler dev --cwd examples/session-recovery --ip 127.0.0.1 --port 8789

That version uses a WebSocket to a Worker, so you can also disconnect it by turning off your network.

Next

Navigation

Type to search…

↑↓ navigate↵ selectEsc close