Skip to content

Client Connection

SpacetimeDB: Client Connection ↗

Stdb.project(Module) produces a client-safe projection. It contains no server runtime; it is only the callable, route, table, and subscription shape needed by clients.

import * as Effect from "effect/Effect"
import * as Stdb from "effect-spacetimedb"
declare const Module: Stdb.AnyStdbModule
declare const uri: string
declare const databaseName: string
declare const token: string
const Example = Stdb.project(Module)
const HttpLive = Example.client.http.layer({
uri,
databaseName,
token,
callDurationMax: "10 seconds",
})
const program = Effect.gen(function* () {
const client = yield* Example.client.http.Tag
yield* client.reducers.user_upsert({ userId: "u1", name: "Ada" })
}).pipe(Effect.provide(HttpLive))

Typed HTTP handlers are called with just their payload. Raw handlers take the payload and request options.

Typed post, put, and patch routes can be projected into a stock Effect HttpApi:

const api = Stdb.toHttpApi(Module)

This is useful when callers already use HttpApiClient.make(...) and want status-based declared-error decoding.

WebSocket sessions use generated native bindings through effect-spacetimedb/client.

import * as StdbClient from "effect-spacetimedb/client"
import * as Stdb from "effect-spacetimedb"
declare const Module: Stdb.AnyStdbModule
declare const DbConnection: StdbClient.GeneratedWsConnectionFactory<
typeof Module.spec,
unknown
>
declare const uri: string
declare const databaseName: string
declare const token: string
const Example = Stdb.project(Module)
const WsLive = Example.client.ws.layerGenerated({
DbConnection,
uri,
databaseName,
token,
connectTimeoutMillis: 10_000,
})

layerGenerated and scopedGenerated are the generated-client paths. The standalone generated config helper is reserved for the package testing entrypoint.

Named tags let one process hold multiple sessions for the same module.

import * as StdbClient from "effect-spacetimedb/client"
import * as Stdb from "effect-spacetimedb"
declare const Module: Stdb.AnyStdbModule
declare const DbConnection: StdbClient.GeneratedWsConnectionFactory<
typeof Module.spec,
unknown
>
declare const uri: string
declare const databaseName: string
declare const token: string
const Example = Stdb.project(Module)
const MainSession = Example.client.ws.tag("main")
const MainLive = Example.client.ws.layerGenerated(
{ DbConnection, uri, databaseName, token, connectTimeoutMillis: 10_000 },
{ name: "main" },
)

The native generated client still owns the actual WebSocket connection. effect-spacetimedb layers typed Effect services, scoped sessions, and projected HTTP clients over the module contract.

The handshake is the dividing line. A failure before the connection is established fails the scoped acquisition with WsConnectError; a failure after it is a connection loss, which invalidates the session and fails in-flight and subsequent calls with ConnectionLostError.

What happenedDuring acquisitionOn an established session
Socket error before the handshakeWsConnectError with the socket error as cause—
Socket error after the handshakeWsConnectError with the socket error as causeConnectionLostError whose raw is that error’s message
Close with no reported errorWsConnectError with cause: undefinedConnectionLostError with raw: "WebSocket connection disconnected"
Connect timeoutWsConnectError whose cause is WsConnectTimeoutError—
Generated client missing contract relationsGeneratedArtifactShapeError, naming the missing keys—

A socket error raised after the handshake never arrives as a connect error. The SDK records it, closes the socket, and reports it on the disconnect path, so cause is what distinguishes a connection that died with a reason from one that simply closed. The causeless case covers both a clean close and the close the SDK performs when an inbound frame fails to decompress.

In a browser, onerror hands the SDK an ErrorEvent rather than an Error. The SDK normalizes that to an Error carrying the event’s message, with the original event on .cause, so WsConnectError.cause is an Error on every runtime.

ConnectionLostError means the client never observed a terminal response for a call. The reducer or procedure may already have committed on the server. Treat the outcome as unknown, prefer idempotent mutations, and reconcile from the replacement session’s subscribed state before retrying anything that is not.

A bare scoped session surfaces terminal invalidation and stops there. Long-lived clients can opt into the package supervisor for bounded acquisition, jittered reconnect backoff, scoped per-session setup, and resubscription; a reconnect after a causeless close behaves exactly like one after a reported socket error.

Set automaticReconnect on a WebSocket config to let the native SDK reconnect an established connection by itself. After a dropped socket the SDK reopens it with exponential, jittered backoff and keeps the same connection object, client cache, subscription handles and callbacks. It replays every subscription in one batch and reconciles the cache: rows unchanged across the outage fire no callbacks, changed rows fire one update, insert or delete, and each replayed subscription fires onApplied again.

import * as StdbClient from "effect-spacetimedb/client"
import * as Stdb from "effect-spacetimedb"
declare const Module: Stdb.AnyStdbModule
declare const DbConnection: StdbClient.GeneratedWsConnectionFactory<
typeof Module.spec,
unknown
>
declare const uri: string
declare const databaseName: string
declare const token: string
const Example = Stdb.project(Module)
const WsLive = Example.client.ws.layerGenerated({
DbConnection,
uri,
databaseName,
token,
connectTimeoutMillis: 10_000,
// The SDK raises delays below 500 ms (first) and 1 s (cap) to those floors.
automaticReconnect: { delayMillisMin: 1_000, delayMillisMax: 30_000 },
})

The session outlives the drop. Until the SDK has reconnected and its replay has re-applied every subscription the session holds, reads keep returning the last rows and streams stay open, while calls are refused without being sent:

CallFails withMeaning
Made while the SDK reconnects or replaysCallNotSentErrorThe server never saw it; retrying once the session is live is safe.
In flight when the socket droppedConnectionLostErrorThe outcome is unknown; it may have committed.
Made on a session whose reconnect changed identityConnectionIdentityChangedErrorThe session ended; no retry on it can succeed.

The SDK does not retry the initial connection: that failure still fails the acquisition with WsConnectError. Recovery starts once the session is live (a supervised session after it connected, subscribed and ran setup); a drop before that ends it. The session is invalidated when the SDK schedules no further attempt (a protocol-error close, a refused fresh token, an identity change, disconnect()), when a token refresh fails (see Tokens), or when a reconnect attempt or its replay is still unresolved connectTimeoutMillis after its scheduled start; the SDK has no handshake or replay timeout of its own, so the library ends such a connection.

A socket can also close without delivering its close event. The SDK notices only on a page-resume event, so any call, subscribe or liveness check that finds the socket closed puts the session in recovery (reconnectAttempt 0) and the same bound ends it if no reconnect follows, as in a WebView or a server runtime that never sends resume events.

A subscription that fails to replay is dropped by the SDK. A consumer stream on it fails with SubscriptionRejectedError on its own; a supervisor’s subscription target that fails to replay ends the session, so the supervisor replaces it.

Supervised Sessions With Automatic Reconnect

Section titled “Supervised Sessions With Automatic Reconnect”

Automatic reconnect layers under the session supervisor. The SDK handles transport blips without replacing the session; the supervisor still owns the first connection, terminal failures (rebuilding the session after its own backoff), and per-session setup. Its phase reports which one is happening:

PhaseSessionWho is recovering
connectingnone yetthe supervisor’s first activation
livecurrent—
recoveringretained (published as waiting); cache readable, calls refusedthe SDK, on the same session (reconnectAttempt, reconnectDelayMillis, reason)
reconnectingthe previous one, as waitingthe supervisor, building a replacement
fatalthe last onenobody: retrying cannot help

setup runs once per session. Server state that belongs to a connection rather than to a session (presence, for example) is discarded when the server sees the old socket disconnect, so redo it in onAutomaticReconnect, which runs after every automatic reconnect (once the replay has re-applied the subscriptions) and before the session is published live again. Its scope lasts until the next drop; a failure ends the session (the supervisor then replaces it), and a drop while it runs, including a call it makes being refused by a socket found closed, interrupts it instead. Keep it idempotent.

import * as Effect from "effect/Effect"
import * as StdbClient from "effect-spacetimedb/client"
import * as Stdb from "effect-spacetimedb"
declare const Module: Stdb.AnyStdbModule
declare const DbConnection: StdbClient.GeneratedWsConnectionFactory<
typeof Module.spec,
unknown
>
declare const uri: string
declare const databaseName: string
// Never a token it already handed out, nor one due for refresh (see Tokens).
declare const fetchFreshToken: Effect.Effect<string, Error>
declare const registerPresence: (
session: StdbClient.WsSession<typeof Module.spec>,
) => Effect.Effect<void>
const supervisor = StdbClient.wsSupervisorGenerated({
module: Module.spec,
config: {
DbConnection,
uri,
databaseName,
token: fetchFreshToken,
connectTimeoutMillis: 10_000,
automaticReconnect: {},
},
setup: registerPresence,
onAutomaticReconnect: registerPresence,
})

A reconnect that comes back under a different identity parks the supervisor in fatal with a WsConnectError whose cause is ConnectionIdentityChangedError: replacing the session would silently serve another identity’s data, so the owner decides, typically by constructing a supervisor for the new credentials.

token is either a fixed string or an Effect that produces one. An Effect is evaluated for every connection attempt: each scoped acquisition, every session a supervisor builds, and with automatic reconnect also whenever the SDK refreshes its retained token, which it does before a reconnect when that token has no expiry, has 5% of its lifetime (at least 30 seconds) or less left, or was rejected. An acquisition’s evaluation is bounded by connectTimeoutMillis, and a failure (any Error) fails that attempt with WsConnectError carrying it as cause. A refresh runs within its reconnect attempt’s bound (the SDK’s delay plus connectTimeoutMillis). One that fails, dies or overruns that bound ends the session rather than let the SDK retry a token that may never come back (a signed-out user). A supervisor reports a failure as that WsConnectError and an overrun as WsConnectError with a WsConnectTimeoutError cause, and its isRetryable policy decides whether to build a new session; a refresh that died parks it as fatal, like any defect. A token captured once goes stale for a long-lived client; reading the current token in an Effect keeps every reconnect authenticated. session.token reports the token the connection currently uses.

The SDK takes whatever a refresh returns as fresh: if the server refuses that token too, the SDK stops reconnecting. So the Effect must return a token that is not due for refresh by the rule above, and never the one it returned last, even while the client clock counts it valid: the server may just have refused it (revoked, or the client clock runs behind the server’s). The pattern: a token cached when the session was checked may serve the first attempt while it is not due; every later evaluation fetches a fresh token, or fails.