• v1.1.0 a60640662a

    v1.1.0
    All checks were successful
    release / release (push) Successful in 2s
    ci / ci (push) Successful in 1m33s
    Stable

    yuris released this 2026-08-20 16:30:02 +00:00 | 43 commits to main since this release

    v1.1.0 adds a provider-credential exchange and a deterministic per-client reconnect spread.
    Nothing a v1.0.0 caller wrote stops working: the renames in
    #68 all land on surfaces that first
    appear in this release, and the two changes to frozen surfaces - sys_storage's load filter and
    connect's identity save - cannot be observed by a released consumer, which only ever reads
    persisted.token back. Applying a first subscription was also measured, if you are budgeting one:
    it is a single frame's work, which natively exceeds the 60fps budget at roughly 6,100 rows and
    100ms at roughly 31,000, and on HTML5 reaches 100ms at roughly 9,800, where an idle wasm frame
    already costs 17ms to requestAnimationFrame - each threshold swept across its crossing rather
    than extrapolated, with time-slicing tracked as #67 and an unguarded protocol.quote_big_ints
    costing 40-44% of the native frame as #69.

    Features

    stdb.auth.exchange(opts, cb)

    Trades a provider credential - a Steam session ticket, a Google or Apple ID token, your own
    session cookie - for a SpacetimeDB token you hand to connect as its token option.
    exchange({url, provider, credential, json}, cb) POSTs json.encode({ token = credential }) as
    application/json and calls back (err, token) exactly once. The option is credential while
    the body field is token: the body shape belongs to your backend, and token means a
    SpacetimeDB token everywhere else in this SDK, including this function's own result. The
    credential travels in the body and never in an Authorization header - the exchange target is
    not the resource server, and a header invites a proxy to forward it as one. The tests assert
    there is no other header and no other field.

    timeout is 10s per attempt rather than total, armed after the request and skipped if the
    request already answered, since a seam is free to call back synchronously. retries is 3 further
    attempts with backoff_base 0.5s doubling - four lines of its own arithmetic rather than
    connection.backoff_delay, so one knob does not move both the exchange and the socket reconnect.
    A 5xx or a transport failure retries; a 4xx never does, because a rejected credential will not be
    accepted on the second ask and retrying would cost 3.5s before the game learns the player is not
    signed in. Neither the credential nor the token reaches an error string or a log line: auth.lua
    contains no print, no response body is interpolated into a message - a body may itself carry a
    token - and errors name a status and a count. A redaction test sweeps every failure and success
    path and asserts none of the strings the SDK can produce contains either secret, plus that the
    sweep collected more than 8 composed strings, so it cannot pass vacuously.

    storage defaults to nil, so nothing is cached - deliberately unlike connect, which defaults
    to sys_storage(). An identity token is the SDK's own to persist; a provider-exchanged token is
    the game's credential, so the caller opts in. Opting in stores
    { auth = { [provider] = token } }, merged on save so a second provider cannot evict the first,
    keyed on the caller-supplied provider string. There is no JWT parsing anywhere, so no base64url
    decoder ships and no unverified claim can be mistaken for an authorization decision. A hit
    short-circuits and makes no request. The cached token is not validated locally - verifying one
    needs a round trip, and connect already handles a rejected token by reconnecting - so a stale
    entry surfaces as a failure at connect, and clearing the cache is the caller's move.

    The boundary: SpacetimeDB 2.8.2 has no exchange endpoint. /v1/identity/token, /v1/token,
    /v1/auth and /v1/identity/exchange all answer 404, only POST /v1/identity exists and it
    mints an anonymous identity, and spacetimedb-standalone start exposes a single
    --jwt-pub-key-path/--jwt-priv-key-path keypair with no issuer list or JWKS URL, so it cannot
    be pointed at a third-party OIDC issuer either. opts.url is therefore your own backend, which
    verifies the credential and obtains the identity. It is a configurable URL either way, so a
    future server version that grows an endpoint needs no code change here.

    stdb.http_seam()

    The SDK's first HTTP, defined in stdb/auth.lua and re-exported for symmetry with
    stdb.sys_storage, which it mirrors: a factory returning a table of closures, so tests
    substitute a table and skip the engine. post(url, headers, body, cb) calls back
    (status, body), where status == 0 means nothing answered.

    There is deliberately no separate err argument. Status 0 is Defold's own transport-failure
    signal, verified against engine source rather than assumed:
    engine/dlib/src/dlib/http/http_service.cpp:637-648 sends the real status only on RESULT_OK or
    RESULT_NOT_200_OK and a literal 0 otherwise, script_http.cpp:158 passes it through, and
    Defold's own engine test asserts it - test_http.lua.raw:79-87 expects 0 for an unresolvable
    host while a real 404 asserts 404. The HTML5/XHR path reports 0 too, so one detection covers both
    targets. Defold's published http docs do not document the convention. Two traps are recorded in
    the seam's comment: res.response is "" and not nil on a transport failure, and res.error
    is not a transport indicator - it is set only for a failed download-to-disc write.

    connection.backoff_spread(identity, attempt)

    A deterministic multiplier in [0.5, 1.0) derived from the identity and the attempt, so a server
    restart does not bring the whole player base back in one instant.
    connection.backoff_delay(attempt, base, cap, spread) gains a trailing optional fourth parameter
    applied after the cap, so a capped delay is spread too - otherwise every client in a long outage
    converges on exactly 30s, which is the herd again at the worst moment. A nil spread is an exact
    no-op, which is why v1.0.0's seven exact-value assertions still pass untouched:
    backoff_delay(99, 1, 30, 0.5) == 15, backoff_delay(6, 1, 30) == 30.

    Deliberately not math.random. A library calling math.randomseed clobbers the consuming game's
    own RNG sequence and any deterministic simulation built on it, and an unseeded math.random hands
    every client the same spread, which is the stampede this exists to prevent. Every client already
    holds a unique 64-hex-char identity by the time it can be in a reconnect herd, so the spread
    needs no RNG, no global state and no seeding, and stays exactly testable. The arithmetic is
    integral in Lua 5.1: the largest intermediate is 4294967296 * 31 + 255 = 133143986431,
    against 2^53 = 9.0072e15. The spread redistributes per attempt rather than holding one fixed
    offset - for a single identity, attempts 1..10 give 0.7405, 0.537, 0.6855, 0.834, 0.6305, 0.779,
    0.9275, 0.724, 0.8725, 0.521. Conformance logs it on the wire: reconnecting attempt 1 in 0.744s,
    where before the change it was a flat 1s.

    The boundary: backoff_spread returns nil before an identity exists, because a first connect is
    a cold start and not a herd. A fleet relaunching into an outage therefore still retries in
    lockstep, tracked as #65.

    Bug fixes

    • sys_storage's load filtered on a .token field - the seam was written for connect's
      {identity, token} record and silently made itself useless for any other shape, so the storage
      configuration documented for auth.exchange never cached and evicted on every save. load now
      treats an empty table as "nothing stored", which is what Defold's sys.load returns for a
      missing file. No released consumer can observe the widening: connect reads only
      persisted.token, where an empty table behaved as nil already.
    • connect's identity save rewrote the storage slot whole - the IdentityToken handler
      called save({identity = ..., token = ...}) wholesale, so every connect dropped an auth cache
      sharing the slot, which nesting the auth record under its own key fixed in one direction only.
      The handler now loads, sets the two fields and saves. A slot was always a caller-chosen
      sys_storage(name), so pointing two things at one is a supported arrangement in both
      directions.
    • The README's HTML5 persistence caveat named the wrong mechanism - it was written from
      observed behaviour; it is now read out of Defold engine source at tag 1.13.1, with the loader
      patch confirmed present at 1.4.8, 1.6.0, 1.9.5, 1.10.4, 1.11.2, 1.12.4 and branch dev.
      sys.save's __EMSCRIPTEN__ branch (engine/script/src/script_sys.cpp:202-218) is
      fopen/fwrite/fclose and does not sync, but dmloader.js mounts the save root as IDBFS and
      patches FS.close, so every save starts an asynchronous FS.syncfs(false, cb), coalesced and
      retried up to 3 times. A syncfs is mount-wide, so a single failed flush is written by the next
      one that succeeds; what has no recovery is three consecutive failures, after which the loader's
      guard is left stuck and persistence is dead for the lifetime of the page. Where IndexedDB is
      unavailable - some private-browsing modes - the mount is skipped, Module.persistentStorage is
      set to false, and sys.save writes the in-memory layer only with no error raised. The SDK adds
      no flush of its own, because html5.run("FS.syncfs(false, function(){})") starts the same
      fire-and-forget operation the patched FS.close already started; the one ordering that
      genuinely waits is reloading from inside the callback, and that is what the caveat now gives.
    • The dev-server shared one state directory across ports - dev/tools/dev-server.sh derived
      PIDFILE, DATA, KEYS, LOGFILE and HOME from a fixed /tmp/spacetimedb-defold, so a
      second port reported the first port's server as its own and curl then failed to connect. The
      default is now keyed on $PORT - /tmp/spacetimedb-defold-3000 for the default STDB_PORT -
      and server_pid() verifies the recorded pid is our server for this port by reading
      /proc/$pid/cmdline for --listen-addr 127.0.0.1:$PORT , where the trailing space is
      load-bearing: it stops port 1234 matching a server on 12345. Reading argv needs no external
      binary, has no pipeline for set -o pipefail to reach, and stays true during the window before
      the process has bound, which keeps stop able to kill a server that is still starting; a host
      without a readable /proc degrades to the previous kill -0. An explicit STDB_STATE_DIR
      still overrides, and two ports pointed at one explicit directory can still leak a server,
      tracked as #63.

    What's Changed

    • release: v1.1.0 in #70
    • api: settle the v1.1.0 surface before it freezes in #68
    • Measure the frame that applies a first subscription (#30) in #66
    • auth: exchange a provider credential for a SpacetimeDB token (#31) in #64
    • docs: state the real HTML5 save mechanism, from engine source (#20) in #61
    • feat(connection): spread the reconnect backoff per client, deterministically (#19) in #62
    • dev: isolate dev-server state per port and verify the pid holds it (#54) in #60

    Full Changelog: v1.0.0...v1.1.0

    Use as a Defold library

    Add to game.project dependencies:

    https://git.nokopia.com/yuris/spacetimedb-defold/archive/v1.1.0.zip
    https://github.com/defold/extension-websocket/archive/refs/tags/4.2.3.zip
    

    extension-websocket is required: Defold does not resolve dependencies
    transitively, so without it connect fails at runtime.

    Targeting HTML5? 4.2.3 drops the subprotocol there, so it needs the
    patch in patches/ AND the remote dependency above removed - keeping
    both puts two websocket extensions in one build and the link fails.
    The README has the four commands.

    Private repo? Append ?token=<access-token> to the URL, the same
    shape Defold documents for GitLab. Basic auth in the URL
    (https://user:token@...) is not a substitute: the archive endpoint
    ignores URL credentials - a real account with a wrong password still
    gets 200 on a public repo - while ?token= is validated and a bad
    one gets a plain 401.

    Downloads