-
v1.1.0
Stablereleased this
2026-08-20 16:30:02 +00:00 | 43 commits to main since this releasev1.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.tokenback. 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 torequestAnimationFrame- each threshold swept across its crossing rather
than extrapolated, with time-slicing tracked as #67 and an unguardedprotocol.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 toconnectas itstokenoption.
exchange({url, provider, credential, json}, cb)POSTsjson.encode({ token = credential })as
application/jsonand calls back(err, token)exactly once. The option iscredentialwhile
the body field istoken: the body shape belongs to your backend, andtokenmeans a
SpacetimeDB token everywhere else in this SDK, including this function's own result. The
credential travels in the body and never in anAuthorizationheader - 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.timeoutis 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.retriesis 3 further
attempts withbackoff_base0.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 noprint, 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.storagedefaults tonil, so nothing is cached - deliberately unlikeconnect, which defaults
tosys_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-suppliedproviderstring. 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, andconnectalready handles a rejected token by reconnecting - so a stale
entry surfaces as a failure atconnect, 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/authand/v1/identity/exchangeall answer 404, onlyPOST /v1/identityexists and it
mints an anonymous identity, andspacetimedb-standalone startexposes a single
--jwt-pub-key-path/--jwt-priv-key-pathkeypair with no issuer list or JWKS URL, so it cannot
be pointed at a third-party OIDC issuer either.opts.urlis 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.luaand 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), wherestatus == 0means nothing answered.There is deliberately no separate
errargument. 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-648sends the real status only onRESULT_OKor
RESULT_NOT_200_OKand a literal 0 otherwise,script_http.cpp:158passes it through, and
Defold's own engine test asserts it -test_http.lua.raw:79-87expects 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 publishedhttpdocs do not document the convention. Two traps are recorded in
the seam's comment:res.responseis""and notnilon a transport failure, andres.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. Anilspread 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 callingmath.randomseedclobbers the consuming game's
own RNG sequence and any deterministic simulation built on it, and an unseededmath.randomhands
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 is4294967296 * 31 + 255=133143986431,
against2^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 flat1s.The boundary:
backoff_spreadreturnsnilbefore 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.tokenfield - the seam was written forconnect's
{identity, token}record and silently made itself useless for any other shape, so the storage
configuration documented forauth.exchangenever cached and evicted on every save.loadnow
treats an empty table as "nothing stored", which is what Defold'ssys.loadreturns for a
missing file. No released consumer can observe the widening:connectreads only
persisted.token, where an empty table behaved asnilalready.connect's identity save rewrote the storage slot whole - theIdentityTokenhandler
calledsave({identity = ..., token = ...})wholesale, so every connect dropped anauthcache
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 branchdev.
sys.save's__EMSCRIPTEN__branch (engine/script/src/script_sys.cpp:202-218) is
fopen/fwrite/fcloseand does not sync, butdmloader.jsmounts the save root as IDBFS and
patchesFS.close, so every save starts an asynchronousFS.syncfs(false, cb), coalesced and
retried up to 3 times. Asyncfsis 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.persistentStorageis
set to false, andsys.savewrites the in-memory layer only with no error raised. The SDK adds
no flush of its own, becausehtml5.run("FS.syncfs(false, function(){})")starts the same
fire-and-forget operation the patchedFS.closealready 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.shderived
PIDFILE,DATA,KEYS,LOGFILEandHOMEfrom a fixed/tmp/spacetimedb-defold, so a
second port reported the first port's server as its own andcurlthen failed to connect. The
default is now keyed on$PORT-/tmp/spacetimedb-defold-3000for the defaultSTDB_PORT-
andserver_pid()verifies the recorded pid is our server for this port by reading
/proc/$pid/cmdlinefor--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 forset -o pipefailto reach, and stays true during the window before
the process has bound, which keepsstopable to kill a server that is still starting; a host
without a readable/procdegrades to the previouskill -0. An explicitSTDB_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.projectdependencies:https://git.nokopia.com/yuris/spacetimedb-defold/archive/v1.1.0.zip https://github.com/defold/extension-websocket/archive/refs/tags/4.2.3.zipextension-websocket is required: Defold does not resolve dependencies
transitively, so without itconnectfails at runtime.Targeting HTML5? 4.2.3 drops the subprotocol there, so it needs the
patch inpatches/AND the remote dependency above removed - keeping
both puts twowebsocketextensions 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
gets200on a public repo - while?token=is validated and a bad
one gets a plain401.Downloads
-
Source code (ZIP)
2 downloads
-
Source code (TAR.GZ)
1 download