Releases: FluxStackCore/fluxstack-live
Releases · FluxStackCore/fluxstack-live
Release list
v0.8.0 — Custom ID Generator & Performance Overhaul
Highlights
- Custom ID generator —
LiveServerOptions.generateIdlets devs provide their own() => string - Compact 8-char IDs — ~5x faster than
crypto.randomUUID(), 78% smaller on the wire - Zero-copy sanitizePayload — clean payloads pass through without cloning (1.7x faster)
- ACTION_RESPONSE 34% smaller — dead fields removed (
originalType,responseId,timestamp) - Heartbeat 98% less overhead — 1 ping per connection instead of N per component
- Reconnect with exponential backoff — 1s → 2s → 4s → 8s → 16s cap
Packages
| Package | Version |
|---|---|
@fluxstack/live |
0.8.0 |
@fluxstack/live-client |
0.8.0 |
@fluxstack/live-react |
0.8.0 |
@fluxstack/live-redis |
0.8.0 |
@fluxstack/live-vue |
0.8.0 |
@fluxstack/live-cli |
0.8.0 |
See CHANGELOG.md for full details.
v0.7.2
Fixes
- deepAssign: cycle guard now tracks
(source, target)pairs instead of flat source set — fixes silent skips when the same object appears in multiple positions of a diff - deepAssign: null guard prevents crash on null/undefined source
- computeDeepDiff:
maxDepthdefault raised from 3 to 100 — prevents silent drops at nested depths (e.g.,players.id.position.xat depth 4)
Features
- LiveComponentsProvider:
connect()anddisconnect()exposed on context for manual WS lifecycle control withautoConnect: false - LiveComponentsProvider:
autoConnectnow waits forwindow.loadbefore connecting
Tests
- 149 new regression tests covering multiplayer game state scenarios
v0.7.0 — security, lifecycle, state & protocol fixes
Six issues closed (#2–#7) across security, lifecycle, state semantics,
and the binary/JSON transport surface. The common theme is failing
loudly instead of silently dropping data: the framework now surfaces
classes of bugs that previously produced silent server↔client drift.
Security
- Replay attack across nonce eviction is closed (#4). The nonce map
cap (added in 0.6.1 to prevent DoS) used to evict legitimate nonces
that were still within their TTL, opening a replay window for an
attacker holding a capturedSignedState. The manager now tracks an
evictionHighWaterMarkset to the newest embedded timestamp ever
evicted, andvalidateNoncerejects any nonce whose embedded ts is at
or below that mark. Fail-closed.StateSignature.ts. $auth.sessionis now deep-frozen (#4).AuthenticatedContext
used to store the caller's session by reference, so a handler bug
that mutatedthis.$auth.session.roles.push('admin')silently
altered every subsequentauthorize()decision in the same process.
The constructor now copiesroles/permissions, freezes them,
freezes the session, and freezes the context instance itself.
ANONYMOUS_CONTEXT(a shared singleton) is also frozen.
LiveAuthContext.ts.authorize()return value is strictly coerced (#4). A
user-suppliedauthorize()that returned a primitive ('true',0,
null,undefined, or an object withoutallowed) used to land in
result.allowed === undefined. Callers usingif (!result.allowed)
were fail-closed by accident, but strict consumers checking
result.allowed === falsewould miss the deny. New
coerceAuthResulthelper accepts onlytrue,false, or objects
with explicitallowed: true/false.LiveAuthManager.ts.
Added
getBatcherStats()/resetBatcherStats()exported from
@fluxstack/live(#7). Running totals of messages dropped by the
WsSendBatcheracross three drop paths:droppedBackpressure— per-connection queue reached
MAX_QUEUE_SIZE.droppedClosed— WebSocket was closed at queue or flush time.droppedSerializationError—JSON.stringify/ws.sendthrew
during flush.
- One-shot backpressure warning per connection (#7). When the
batcher starts dropping for a given ws, a singleliveWarnis
emitted (gated by aWeakSet) so operators notice churn without log
spam. - Regression suites totalling 92 new tests across the fixes:
StateSignature.replay-eviction.test.ts(5)auth/session-freeze.test.ts(8)auth/authorize-strict-return.test.ts(13)rooms/LiveRoomManager.lifecycle-hooks.test.ts(14, 1 skipped)component/setstate-edge-cases.test.ts(15)transport/protocol-codec.test.ts(28)utils/deepDiff.test.ts(+8)
Changed — Breaking
setState({ x: null })at the top level now storesnullinstead
of deleting the key (#6). Top-level state keys come from the
component'sdefaultStateschema and are not dynamically added or
removed. AnyNullable<T>field is now expressible; any code that
was (accidentally) using null-at-root to delete a key needs to
restructure. Nestednullinside aRecord<string, T>still works
as a deletion sentinel — this is what the issue #1/#3 fix needed and
what game rooms rely on. Applied consistently in all five
server/client deep-merge implementations.deepDiff.ts,
react/useLiveComponent.ts,vue/index.ts,client/component.ts,
client/rooms.ts.setState({ x: undefined })is now a no-op (#6).undefined
values used to leak into internal state and then be stripped by
JSON.stringifyon the wire, silently desyncing server and client.
BothcomputeDeepDiffanddeepAssignnow skipundefinedvalues
entirely. Callers wanting to clear a field should passnullif the
type allows it.LiveComponent.$options.deepDiffdefault flipped totrue(#3).
Aligns withLiveRoomManagerwhich already defaulted totrue. In
the old shallow default,setState({ players: {...next} })emitted
{ players: { A: ... } }without marking removed keys asnull, so
the client's deepMerge never deleted them. Opt out with
static $options = { deepDiff: false }.onCreatenow runs BEFORE the firstonJoinon aLiveRoom
(#5). The previous order contradicted theLiveRoom.ts:148docs
("Called once when the room is first created (first member joins)")
— the first member saw uninitialized state. Any room that relied on
the buggy order should move its first-member initialization into
onCreate.onJointhrowing is now treated as an implicit rejection (#5).
Previously a sync throw propagated out ofjoinRoomand crashed the
transport; an async rejection becameunhandledRejection. Both now
take the same path asreturn falseand reject the join cleanly.onDestroyis now awaited soPromise<false>can cancel
destruction (#5). The documentedasync onDestroy() { return false }
cancel signal was silently ignored because the code compared a
Promise tofalse. The empty-room cleanup is extracted into a
privatescheduleEmptyRoomDestructionhelper that awaits the result,
honoursPromise<false>, and isolates throws.onEventis observer-only (#5).emitToRoomis synchronous and
the broadcast cannot be cancelled or mutated by a hook return value.
The hook can still be declaredasyncbut its completion is not
awaited before the broadcast. Sync throws and async rejections are
now caught and logged instead of crashing / surfacing as
unhandledRejection.msgpackEncodenow throws on unsupported types instead of
silently emitting nil or empty maps (#7). PreviouslyDatebecame
{}(becauseObject.keys(new Date())is[]), andBigInt,
Symbol,Function,Map,Set,RegExpall fell through to the
0xc0(nil) fallback — silent data loss. Each type now throws
TypeErrorwith a clear message pointing at the supported
alternative (store timestamps as numbers, convertMapto a plain
object, etc.).- Binary frame field limits are enforced (#7).
sendBinaryDelta,
buildRoomFrame,buildRoomFrameTail, andprependMemberHeader
nowthrowwhencomponentIdorroomIdexceeds the u8 length
prefix (255 bytes), or wheneventexceeds u16 (65535 bytes).
Framework-generated ids are ~41 bytes so normal use is unaffected;
custom id schemes that were silently wrapping are now caught. msgpackDecoderejects truncated input (#7). Previously
decodeAtreturned{ value: null, offset }on buffer underrun and
kept decoding, so a truncated frame produced a corrupt object where
truncated fields were indistinguishable from real nulls. Every read
site now assertsneedBytesand throwsRangeError. Unknown type
bytes throwTypeErrorinstead of silently returningnull.sendBinaryImmediate/sendImmediatenow flush the per-WS batch
queue first (#7). Previously
queueWsMessage(A); sendBinaryImmediate(B)sentBbeforeA
because batched messages waited for the next microtask while binary
writes were synchronous. A binary state delta could therefore
overtake the JSONSTATE_UPDATEthat established its keys. Both
immediate senders now call a newflushOne()helper on the same ws
before writing, preserving caller ordering.
Fixed
@fluxstack/live-reactuseEffectre-registered the component on
everySTATE_DELTA(#2). The register effect hadstateDataand
the caller's handler props (onStateChange,onRehydrate,
onError) in its dependency array, causing an unregister → register
cycle per delta — unnecessary overhead and a risk of message loss in
the gap. Deps reduced to
[componentId, registerComponent, registerBinaryHandler, updateState];
handlers, persist metadata, and the binary decoder are now read via
refs (handlersRef,persistMetaRef,binaryDecoderRef).
useLiveComponent.ts.- Key removal not detected in nested
Record<string, T>(#3). See
thedeepDiffdefault change above. cleanupComponentcould leak members ononLeavethrow (#5).
The for-of loop wrappedawait instance.onLeave(...)without a
try/catch, so a single bad hook stopped the loop and left the
component as a member of every subsequent room. Each iteration is
now wrapped individually; the single-roomleaveRoom()path got the
same protection.WsSendBatcherswallowed serialization errors (#7). Thecatch
block insideflushAllwas empty, so circular refs,BigInt,
getter throws, and post-closews.sendfailures were all silenced
with no telemetry. Now each failure increments
droppedSerializationErrorand emitsliveWarn. This is also what
turns a circular state into a visible error instead of a stack
overflow.
Not fixed — documented limitations
Captured as pinned tests in the new regression suites so any future
change is an explicit decision:
- Shallow state proxy —
this.state.nested.x = yemits no delta.
Users should call
setState({ nested: { ...this.state.nested, x } }). Map/Setas state values serialize as{}on the wire.
State must remain JSON-serializable; the msgpack path nowthrows
(see breaking changes above), but the JSON path still silently
serializes them as empty.Dateis reference-compared bycomputeDeepDiff; two equivalent
Date instances emit a delta. Store timestamps as numbers.ComponentMessaging.emit()accepts any string astype— the
LiveMessage['type']union is TypeScript-only. No runtime whitelist.onStateChangereentrancy guard — cascadingsetStateinside
onStateChangeapplies the change and emits a delta, but the hook
itself is not re-entered. Intentional.