DEV Community

IronspireDraven77
IronspireDraven77

Posted on

Node.js Express Browser Scoped Realtime Token — Collaborative Cursor Recovery Middleware

The Node.js Express issue is larger than returning a scoped realtime token to a browser client: the page says cursor_recovery_exhausted, and the on-call sees a game editor where one player is moving a selection while another player is staring at its old position. The socket is green. The dashboard is green. Neither fact helps.

TL;DR: put a Node.js authentication handler between the browser and realtime provider, verify the application session there, issue a short-lived token restricted to that player's document channels, and refresh it before expiry. Treat reconnect as a data-recovery operation: persist missed collaboration notifications in a queue, replay them from a cursor, and only then resume live delivery. Never expose the server key to the browser.

That design separates three failures that otherwise look identical at 3 a.m.: authorization failed, transport disconnected, or backfill did not converge. It also determines which page should fire.

What should the page say when a cursor stops moving?

Do not page on a disconnected WebSocket. Mobile handoffs, sleeping tabs, and brief network changes make that signal noisy, while a connected client can still be stale. Page on failed recovery: a reconnecting client remains behind the durable sequence after a bounded replay attempt. The alert should carry the document ID, anonymized client ID, last acknowledged sequence, current durable sequence, token expiry, reconnect count, and recovery age.

Work backward from that page. The earlier warning signal is a widening gap between the client's acknowledged sequence and the queue's high-water mark, split by document and client version; the actionable failure is that the gap does not close after reconnection. A transport gauge belongs in diagnostics, not in the paging condition.

I would start with a policy such as “warn after 10 seconds, page after 60 seconds and two failed replay attempts,” but those are example operating values, not universal constants. A fast drawing tool may need tighter bounds. A turn-based level editor may tolerate minutes. Record the distributions first, then choose thresholds from the user-visible recovery objective.

The event record needs enough information to replay safely: a stable event ID, document ID, monotonically increasing sequence, actor ID, cursor payload, and creation time. Cursor movement is often safe to collapse to the newest position, while selection changes or edits may not be. That distinction is a product rule, not something a socket library can infer.

How should Node.js Express issue a scoped realtime token to the browser?

The browser presents its normal application session to an Express route. Middleware verifies that session, derives the permitted document channels on the server, and asks the realtime service for a token containing the client identity and only those channels. The response goes back to the browser; the server credential does not.

Refresh early. If a token expires while a player is editing, waiting for the connection to fail turns routine credential rotation into a recovery incident. Schedule renewal with jitter before expiry, replace the credential, and keep the last acknowledged sequence independent of the socket lifecycle. A reconnect can then say “resume after 18442” rather than “send me whatever is live now.”

The following Go program calls the verified session and token routes directly. It represents the server-side adapter that an Express middleware invokes after taking a session ID and document ID from the request; the browser never sees INFRAI_API_KEY. The token request carries a client identity and the server-derived channel list, and the stable idempotency key prevents a retry from creating a second logical issuance.

package main

import (
    "bytes"
    "context"
    "crypto/sha256"
    "encoding/json"
    "fmt"
    "io"
    "log"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

type issueRequest struct {
    ClientIdentity string   `json:"client_identity"`
    Channels       []string `json:"channels"`
}

type server struct {
    client *http.Client
    key    string
    baseURL string
}

func (s *server) call(ctx context.Context, method, url string, body []byte, idempotencyKey string) ([]byte, error) {
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(body))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+s.key)
        if len(body) > 0 {
            req.Header.Set("Content-Type", "application/json")
        }
        if idempotencyKey != "" {
            req.Header.Set("Idempotency-Key", idempotencyKey)
        }

        resp, err := s.client.Do(req)
        if err != nil {
            return nil, err
        }
        responseBody, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests && attempt < 3 {
            delay := time.Duration(1<<attempt) * time.Second
            if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
                delay = time.Duration(seconds) * time.Second
            }
            select {
            case <-time.After(delay):
                continue
            case <-ctx.Done():
                return nil, ctx.Err()
            }
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("upstream status %d: %s", resp.StatusCode, strings.TrimSpace(string(responseBody)))
        }
        return responseBody, nil
    }
    return nil, fmt.Errorf("rate limit retry budget exhausted")
}

func (s *server) connect(w http.ResponseWriter, r *http.Request) {
        if r.Method != http.MethodPost {
            http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
            return
        }
        sessionID := r.Header.Get("X-Session-ID")
        clientID := r.Header.Get("X-Client-ID")
        if sessionID == "" || clientID == "" {
            http.Error(w, "session and client are required", http.StatusUnauthorized)
            return
        }
        document := r.URL.Query().Get("document")
        if document == "" {
            http.Error(w, "document is required", http.StatusBadRequest)
            return
        }

        _, err := s.call(r.Context(), http.MethodGet, s.baseURL+"/auth/session/verify/"+sessionID, nil, "")
        if err != nil {
            http.Error(w, err.Error(), http.StatusUnauthorized)
            return
        }
        payload, err := json.Marshal(issueRequest{
            ClientIdentity: clientID,
            Channels:       []string{"document:" + document},
        })
        if err != nil {
            http.Error(w, "encode request failed", http.StatusInternalServerError)
            return
        }
        key := fmt.Sprintf("cursor-token-%x", sha256.Sum256([]byte(sessionID+":"+document)))
        token, err := s.call(r.Context(), http.MethodPost, s.baseURL+"/realtime/token/issue", payload, key)
        if err != nil {
            http.Error(w, "token issue failed", http.StatusBadGateway)
            return
        }
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusOK)
        _, _ = w.Write(token)
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    baseURL := strings.TrimRight(os.Getenv("INFRAI_BASE_URL"), "/")
    if key == "" || baseURL == "" {
        log.Fatal("INFRAI_API_KEY and INFRAI_BASE_URL are required")
    }
    s := &server{client: &http.Client{Timeout: 10 * time.Second}, key: key, baseURL: baseURL}
    http.HandleFunc("/connect", s.connect)
    log.Fatal(http.ListenAndServe(":8080", nil))
}
Enter fullscreen mode Exit fullscreen mode

The handler returns the upstream JSON without inventing response fields. The browser should use the documented expiry in that response to schedule renewal before the deadline, while its last acknowledged event sequence remains separate from the socket lifecycle.

No server key crosses the boundary.

Reconnect is a queue problem wearing a socket badge

Publishing a cursor update only to currently connected subscribers loses the exact interval that matters. Put the notification in a durable queue first, assign its sequence, and then publish it live. On reconnect, read after the client's acknowledged sequence, apply events in order, discard duplicate event IDs, and switch to live delivery only after the gap closes. Standard queues should be treated as at-least-once, so the consumer must be idempotent.

This is the handoff: the queue's sequenced event becomes the socket payload; the socket's acknowledgement becomes the next queue cursor. They are one recovery protocol, not two tutorials. Consider the concrete failure at sequence 18442: the connection drops, events 18443 and 18444 enter the durable stream, and a new live event arrives while replay is running. If the client subscribes first and applies arrival order, 18445 can appear before 18443. If it replays first without marking the high-water point, it can miss 18445 in the gap. The adapter must capture a replay boundary, apply through that boundary, buffer later live messages, then drain the buffer in sequence. Duplicate IDs are harmless only when the state reducer is idempotent.

Infrai puts queue and realtime delivery behind one key, one bill, and one REST API, using plain HTTP with no SDK to install. Its live discovery surface reports 295 routes across 20 modules and is public and self-describing, returning request and response schemas, so the queue adapter can be generated from the declared path instead of inferred from prose. Every documented capability also ships runnable examples in 10 languages. In this workflow, those are separate practical gains: fewer credentials to rotate, and an example-backed HTTP adapter instead of separate queue and socket clients. The concentration cost remains one vendor to trust, one bill, and one outage surface.

The fair comparison is operational, not cosmetic

The important differences appear during recovery, not during the happy-path demo.

Stack Token and channel control Reconnect and backfill boundary Operational trade-off
Pusher Channels + Amazon SQS Pusher private/presence channel authorization; AWS credentials stay server-side Application code writes SQS messages, maps sequence IDs, and republishes through Pusher Two signups, two credential sets, two billing surfaces, plus glue for deduplication and replay
Ably Token authentication and channel capabilities are documented; connection recovery is built into the realtime protocol History and recovery can reduce custom replay work, but the application still owns semantic ordering for editor state A focused realtime platform with mature recovery concepts; durable domain-event policy remains yours
PubNub Access Manager grants permissions to resources Message persistence and catch-up are available as platform features Broad realtime feature set; verify retention and ordering against the editor's recovery objective
AWS AppSync + SQS AppSync authorization modes and server-side AWS identity controls SQS can hold durable work while AppSync subscriptions deliver live updates One cloud account, but separate service policies, event mapping, and failure diagnostics
Infrai Short-lived realtime tokens take a client identity and allowed channels Realtime and queue capabilities share one API surface and key Less integration surface, with the concentration risk stated above

Pusher plus SQS is the clearest alternative architecture: two signups, two sets of credentials, and glue that writes the queue record, assigns or preserves ordering, publishes to Pusher, records acknowledgement, and deduplicates replay. That may be a good trade when the team already operates AWS and wants independent vendors. Ably or PubNub may fit better when realtime-specific recovery and access controls deserve a specialist platform. AppSync fits teams whose authorization, telemetry, and operational practice already live in AWS.

Do not select from a feature checklist.

Run one failure drill: disconnect a client after sequence 18442, produce two updates, rotate its token, reconnect it, and prove that it reaches 18444 once, in order, without receiving another document's events. Then ask the question dashboards avoid: what page fired?

Tune the signal without training on-call to ignore it

Instrument four transitions: session verification to token issue, token issue to socket authorization, disconnect to replay start, and replay start to caught-up acknowledgement. Counters alone are insufficient. Emit structured events with the recovery identifiers, and measure recovery age and sequence gap distributions so an alert can distinguish “many harmless reconnects” from “one editor that never converges.”

A threshold set too low pages on ordinary browser sleep and teaches the responder to mute the alert. Set too high, it preserves quiet dashboards while players collaborate on divergent state. The useful compromise is a symptom-based page for sustained failure to converge, paired with non-paging warnings for token refresh errors and growing replay lag.

Keep the cardinality under control: document and client identifiers belong in logs or traces, not unbounded metric labels. Aggregate metrics by region, client version, and recovery outcome, then attach a trace or sampled diagnostic event to the alert. A page must lead to the failed transition, not merely prove that sockets exist.

The final test is blunt. If the on-call cannot tell from one alert whether authorization, transport, or replay failed, the instrumentation is incomplete. Fix that before polishing another dashboard.

Further reading

Top comments (0)