Skip to content

feat(gateway)!: migrate realtime from socket.io to raw WebSocket - #2810

Merged
Innei merged 23 commits into
masterfrom
feat/ws-migration
Aug 15, 2026
Merged

feat(gateway)!: migrate realtime from socket.io to raw WebSocket#2810
Innei merged 23 commits into
masterfrom
feat/ws-migration

Conversation

@Innei

@Innei Innei commented Aug 15, 2026

Copy link
Copy Markdown
Member

Summary

Replaces the socket.io realtime layer with raw WebSocket (@nestjs/platform-ws). Web clients no longer need any framework — native WebSocket + the new zero-dependency @mx-space/ws-client package.

  • New wire protocol: envelope {v:1, event, payload?, id?}; acks for id-carrying uplinks; paths /ws/web and /ws/admin
  • BREAKING — event names: all BusinessEvents values renamed to Stripe-style dot form (POST_CREATEpost.create, fn#fn., companion.presence.changedcompanion_presence.changed). Webhook egress carries the new names; @mx-space/webhook bumped to 1.0.0
  • Cross-node: Redis pub/sub bus (mx-ws:bus) replaces @socket.io/redis-emitter; write-through presence registry (heartbeat + GC, atomic Lua room cleanup) replaces fetchSockets scatter-gather — soft-timeout/fallback machinery deleted
  • New package packages/ws-client: isomorphic client (reconnect backoff, ack requests, ping liveness) shared by admin, Yohaku, tg-bot
  • Admin SPA switched to ws-client; useTaskSubscription is ack-gated with a cross-mount refcount
  • Fixes a latent multi-replica bug: gateway metadata hash is no longer wiped on boot
  • E2E: real-socket suites for both gateways + dual-instance cross-node/GC coverage

Release order (see spec §7)

  1. Publish @mx-space/webhook@1.0.0 + @mx-space/ws-client (flip private, license fixed)
  2. Ship core as v14 major
  3. Add /ws/web + /ws/admin upgrade routes to web-gateway (nothing connects without this)
  4. Deploy Yohaku / tg-bot companion PRs (swap file: deps to npm semver first)

Notes

  • Unknown inbound events are silently dropped by WsAdapter (no UNKNOWN_EVENT ack); client request() bounds this via timeout
  • Yohaku apps/mobile still uses socket.io — must follow up before cutover
  • Spec: docs/superpowers/specs/2026-08-14-socketio-to-ws-migration-design.md

Innei added 18 commits August 14, 2026 23:27
New @mx-space/ws-client workspace package for the socket.io-to-ws
migration: reconnecting client (exponential backoff + full jitter),
request/ack correlation, ping/pong keepalive, and the shared wire
protocol (WsEnvelope/WsAckPayload + guards) under a ./protocol subpath
export. Zero runtime dependencies.
Enum keys stay unchanged; values move from SCREAMING_SNAKE to
dot-namespaced strings (post.create, ai_agent.message, ...) ahead of
the socket.io -> raw WebSocket migration. SERVERLESS_EVENT_PREFIX
'fn#' becomes 'fn.'. Updates the admin socket/types.ts mirror and
fixes tests/docs pinning old literal values.
…to 1.0.0

Regenerates src/models.generated.ts from the updated core enum
(pnpm -C packages/webhook build) so BusinessEvents/EventScope carry
the new dot-namespaced values. Bumps 0.10.1 -> 1.0.0 since every
subscriber string a consumer passes to handler.emitter.on(...) is a
breaking change. Updates readme examples and the companion-presence
contract test to the new values.
…elope)

Adds apps/core/src/processors/gateway/ws/ — the raw-ws building blocks
the socket.io-to-ws gateway rewrite (next task) will consume:
WsBusService (Redis pub/sub fanout across pods), WsPresenceService
(Redis-backed connection/room bookkeeping + dead-node GC), WsRoomManager
and WsConnectionRegistry (in-process room/connection tracking), and
ws-envelope helpers (incoming frame validation, ack building).

Existing socket.io gateways are untouched; only gateway.module.ts wiring
and cache.constant.ts key additions were needed alongside the new files.
leaveRoom, sweepDeadNode's per-room cleanup, and roomSizes' lazy-prune each
did HDEL/HLEN-read then a separate DEL+SREM — a concurrent joinRoom from
another node between the read and the write could get its just-written
membership destroyed and the room wrongly evicted from the rooms set.
Replace all three with one shared atomic EVAL (ROOM_PRUNE_SCRIPT /
pruneRoomIfEmpty) that HDELs then re-checks HLEN inside the same script.

roomSizes also now checks the per-command error slot of the pipelined HLEN
results before treating a room as empty — an errored HLEN no longer falls
through to "size 0" and triggers a destructive prune.
Install the WsAdapter with the envelope messageParser in bootstrap, drop
RedisIoAdapter and the redis emitter surface, and add the inbound event
constants and payload schemas.
Both gateways now run on @nestjs/platform-ws behind /ws/web and /ws/admin.
WsGatewayBase carries the shared transport: connection registry, local room
manager, redis bus subscription, 30s heartbeat and disconnect release.
Broadcast goes out exclusively through the bus, so self-delivery follows the
same path as cross-node delivery. Inbound commands are dot-named, validated
per event and answered with an ack when the frame carries an id.

GatewayService keys metadata by the new connection id and no longer wipes the
shared hash at boot, which used to let one replica erase another's entries.
activity resolves a presence sid against either the connection id or the
handshake session id, since clients never learn the server-generated id, and
reads connectedAt from metadata now that there is no handshake. The pg crud
factory composes dot event names to match the BusinessEvents values.
The web gateway resolved the reader session before writing presence, so a
socket closing during that lookup let handleDisconnect run its cleanup first
and the resumed connect handler then re-created the conns and metadata rows.
Nothing reclaimed them: presence GC only sweeps ids owned by dead nodes, so
each phantom kept inflating the visitor count for the life of the process.

Presence and metadata are now written before the auth lookup, and the handler
re-checks that the connection is still tracked before writing anything else,
undoing its own writes when it is not. The auth gateway guards its token
binding the same way.
…bus/presence

Real-socket, real-Redis end-to-end specs for the raw-ws gateway layer:
connect/greeting, room-scoped broadcast delivery, lang rooms, unique-session
online counting, disconnect broadcast, malformed/unknown frame survival, the
three admin auth paths plus auth failure and token-expiry close, ai_task
room-scoped delivery, and a dual-instance cross-node spec covering the bus
delivery path, the shared presence path, and dead-node reclamation via
presence.sweepOnce.

Extracts createWsAdapter(app) out of bootstrap.ts (pure extraction, no
behavior change) so the harness can wire the identical adapter.
Zero-comments-by-default violation: 5 explanatory comment blocks removed
(redis timeout, teardown grace, duplicated testTimeout rationale x3,
afterEach presence-settle rationale). Kept the WsTestClient listener-ordering
comment, compressed, since it documents an unexpected-behavior workaround a
future reader would otherwise revert. Extracted the 50ms teardown grace into
a named REDIS_QUIT_GRACE_MS constant instead of prose. No logic changes.
Replaces socket.io-client with the shared @mx-space/ws-client transport
against the raw ws gateway at /ws/admin, matching the server-side cutover
in T3/T4. SocketBridge now dispatches per-event via ws-client's on() API
instead of parsing a single socket.io 'message' frame, and DEV-only
lifecycle toasts map from ws-client's $state instead of socket.io's
connect/reconnect event set. useTaskSubscription switches
ai-task:subscribe/unsubscribe emits to ai_task.subscribe/unsubscribe
request()s, marking itself subscribed only after an ok:true ack and
resubscribing on $state reconnect. Also removes two dead EventTypes
members (PAGE_UPDATED, DANMAKU_CREATE) confirmed unused by grep, and
re-exports WsAckPayload/WsRequestError from ws-client's index for
consumers.
subscribe()'s ack handler set `subscribed = true` unconditionally, but
unmount (or a visibility-hide) could run first and see `subscribed ===
false`, so cleanup's unsubscribe() silently no-opped. The subscribe ack
then resolved into a dead closure with nobody left to unsubscribe it,
leaking the server-side ai_task room subscription for the life of the
page's WsClient. Track the caller's live intent (wantSubscribed)
independently of subscribed/pending so a stale ack that resolves after
intent has flipped fires ai_task.unsubscribe itself instead of marking
a torn-down subscription active.
…ibers

Round 1's stale-ack guard was scoped to a single effect closure, so it
only knew whether THIS mount still wanted the subscription. Server-side
room membership is per-connection with no refcount, so an unmount
followed by a same-payload remount (two detail panels for one task,
StrictMode's double-invoke, or a fast route remount) let the unmounted
instance's deferred ai_task.unsubscribe silently drop the room out from
under the live remounted sibling — subscribed=true locally, no error,
no $state change, updates just stop arriving.

Track want-count per payload (JSON-keyed) at module scope instead of
per-closure. Every instance's intent flip increments/decrements the
shared count; the wire-level ai_task.unsubscribe (whether sent
immediately or deferred until a stale ack resolves) now only fires when
that count reaches zero, i.e. nobody else sharing the payload still
wants it live.
- require non-empty sid in UpdatePresenceSchema (empty string matched
  connections whose socket metadata write failed)
- cover WsGatewayBase heartbeat sweep with a unit spec (dead-connection
  terminate on second sweep, alive connection survives repeated pongs,
  closed sockets are skipped)
- fix ws-client package.json license SPDX id (AGPLv3 -> AGPL-3.0-only)
- heartbeat: implemented semantics is single-cycle isAlive marking
  (terminate on the sweep after a missed pong), not "two consecutive
  missed pongs"
- unknown event: note the shipped deviation -- WsAdapter silently
  swallows unmatched events with no UNKNOWN_EVENT ack; client
  request() falls back to its own timeout
- release: spell out the pre-worktree-deletion steps -- web-gateway
  upgrade routes for /ws/web and /ws/admin, Yohaku/tg-bot file:
  deps to npm semver, ws-client private:false + AGPL-3.0-only license,
  and publish order (webhook 1.0.0 + ws-client before core v14)
- non-goals: flag apps/mobile (Yohaku repo) as still on socket.io,
  needing separate follow-up before cutover
@safedep

safedep Bot commented Aug 15, 2026

Copy link
Copy Markdown

SafeDep Report Summary

Green Malicious Packages Badge Green Vulnerable Packages Badge Green Risky License Badge

Package Details
Package Malware Vulnerability Risky License Report
icon @mx-space/ws-client @ link:../../packages/ws-client
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon @mx-space/ws-client @ workspace:*
apps/admin/package.json apps/core/package.json
ok icon
ok icon
ok icon
🔗
icon @nestjs/platform-ws @ 11.1.29
pnpm-lock.yaml apps/core/package.json
ok icon
ok icon
ok icon
🔗
icon @types/node @ 26.2.0
packages/ws-client/package.json
ok icon
ok icon
ok icon
🔗
icon @types/ws @ 8.18.1
pnpm-lock.yaml packages/ws-client/package.json apps/core/package.json
ok icon
ok icon
ok icon
🔗
icon tsdown @ 0.22.14
packages/ws-client/package.json
ok icon
ok icon
ok icon
🔗
icon typescript @ 6.0.3
packages/ws-client/package.json
ok icon
ok icon
ok icon
🔗
icon vitest @ 4.1.10
packages/ws-client/package.json
ok icon
ok icon
ok icon
🔗
icon ws @ 8.21.3
pnpm-lock.yaml packages/ws-client/package.json apps/core/package.json
ok icon
ok icon
ok icon
🔗

View complete scan results →

This report is generated by SafeDep Github App

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7a461168a6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +170 to +172
this.hooks.onDisconnected.forEach((fn) => fn(conn))
leftRooms.forEach((room) => {
this.hooks.onLeaveRoom.forEach((fn) => fn(conn, room))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Persist presence only once when a connection disconnects

When a visitor with an active presence disconnects after joining a room, this invokes both onDisconnected and onLeaveRoom for the same connection. ActivityService registers handlePresencePersistToDb directly for the former and calls it again from the latter, so the same read-duration activity is inserted at least twice—and once more for every additional room such as lang:*—corrupting activity analytics.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3e57f39 — added a synchronous check-and-set dedupe keyed on (socket.id, operationTime) in ActivityService, so the onDisconnected + per-room onLeaveRoom invocations for one disconnect persist a single read-duration row. Note this was pre-existing on master (identical hook wiring), not introduced by the migration. Regression test: ActivityService presence persist dedupe fires all three hooks concurrently and asserts one insert.

Comment on lines +98 to +99
await this.redis.del(this.nodeKey()).catch(() => undefined)
await this.redis.srem(this.nodesKey(), this.nodeId).catch(() => undefined)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Sweep this node's presence before removing its node ID

During a graceful shutdown, removing nodeId from WsNodes makes this node undiscoverable to sweepOnce(), which only iterates IDs still in that set. Because the connection, metadata, and room hashes have no TTL and are not removed here, every graceful deploy leaves permanent phantom connections and room members, inflating online counts and returning nonexistent sockets until Redis is manually cleaned.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3e57f39 — onModuleDestroy now runs sweepDeadNode(this.nodeId) to reclaim this node's conn/room/metadata entries before deleting the node key and srem-ing it from the nodes set. Regression test in cross-node.e2e-spec: after a graceful presence shutdown, node B observes zero room members and no lingering connection ids.

Comment on lines 148 to 151
const remaining = await this.presence.roomMemberIds('admin', room)
const others = remaining.filter((id) => id !== selfId)
if (others.length === 0) {
await this.roomSubs.remove(room)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove the local task subscription when no local member remains

When this pod's last subscriber leaves while another pod still has a member, the global roomMemberIds() result keeps this branch from calling roomSubs.remove(). The current pod therefore retains the room in its local heartbeat set and refreshes its stale pod entry indefinitely; after the remote subscriber also leaves, emitToAdminRoom() continues publishing every task update despite there being no recipients. The emptiness check here needs to be local to this gateway's room manager.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3e57f39 — dropEmptyRoomSubs now checks the local roomManager.membersOf(room) instead of the global presence registry; roomSubs entries are per-pod, so a remote member keeps only its own pod's entry alive. Regression test in cross-node.e2e-spec: A and B both subscribe, A unsubscribes, and the redis set retains only B's podId.

Innei added 2 commits August 15, 2026 14:35
The duplicated subscriber client inherits enableOfflineQueue: false, so a
subscribe issued before its connection was established was rejected outright
with no retry — the bus never delivered any broadcast in production shape
while the e2e fake redis (offline queue enabled) masked it.
- reclaim this node's conn/room entries during graceful shutdown before
  deregistering from the nodes set, which sweepOnce alone can no longer reach
- decide ai-task roomSubs removal on local room membership: entries are
  per-pod, so a remote member must not keep this pod's subscription alive
- dedupe presence read-duration persistence across the onDisconnected +
  per-room onLeaveRoom hooks that all fire for one disconnect (pre-existing
  on master, surfaced by review)
@cursor

cursor Bot commented Aug 15, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3e57f394b5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +40 to +42
await subClient.subscribe(this.channel)
} catch (error) {
this.warn('Failed to subscribe ws bus channel', error)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Retry the bus subscription after startup failure

If the duplicated Redis client takes longer than 15 seconds to become ready, or the initial SUBSCRIBE command fails after the main client is usable, this catch logs the error and leaves the subscriber running without ever retrying. Because both cross-node and local gateway delivery flow exclusively through this channel, that process will accept WebSocket clients but deliver no broadcasts until it is restarted; subscribe again on a later ready event or fail startup instead.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d7f2962 — a subscribed flag plus a ready-event listener retries the subscription until it succeeds; once subscribed, ioredis restores it across reconnects itself. Regression test: initial waitForReady failure, then a fired ready event ends subscribed.

Comment on lines +123 to +127
async removeConnection(ns: WsNamespace, id: string): Promise<void> {
try {
await this.redis.hdel(this.connsKey(ns), id)
} catch (error) {
this.warn('Failed to remove ws connection', error)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reconcile presence entries after transient Redis failures

When Redis is briefly unavailable during a disconnect, this failed HDEL is only logged and the connection remains permanently present in mx-ws:conns:*; the same problem affects failed room leaves. sweepOnce() only removes records owned by nodes whose heartbeat has expired, so entries owned by this still-running node are never retried or reclaimed, leaving online counts and room membership polluted until the process itself stops.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d7f2962 — gateways register a live local-connection index with WsPresenceService, and sweepOnce now also reconciles entries owned by this node against it, reclaiming conn/room/metadata writes lost to transient redis failures. Regression test: a zombie conn + room member owned by a live node is pruned while the live conn survives.

Comment thread packages/ws-client/package.json Outdated
{
"name": "@mx-space/ws-client",
"version": "0.1.0",
"private": true,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Make the new client package publishable

The migration's release sequence requires publishing @mx-space/ws-client before deploying core and the external consumers, but npm treats a package with private: true as non-publishable. Workspace builds mask this because they link the source directly, while Yohaku and mx-tg-bot cannot install the required package from the registry, blocking the stated cutover.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d7f2962 — dropped private: true and added prepublishOnly: tsdown, so npm publish builds dist and ships the publishConfig (dist) shape. The flag was a deliberate guard against premature publishing, but with @mx-space/webhook@1.0.0 already out the guard now costs more than it protects.

…tion

- retry the bus subscription on later ready events so a slow or flapping
  redis at boot cannot leave the node permanently broadcast-dead
- reconcile this node's own conn/room entries against the live local
  registry during sweeps, reclaiming writes lost to transient redis
  failures on disconnect
- drop private: true from @mx-space/ws-client and add prepublishOnly so
  the package is publishable for the release sequence

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d7f2962b3e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

credentials: true,
},
})
@WebSocketGateway({ path: '/ws/web' })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Cap inbound WebSocket frame size

For the unauthenticated /ws/web endpoint, specifying only path leaves ws at its 100 MiB maxPayload default, whereas the replaced Socket.IO transport rejected messages above its roughly 1 MiB default. An external client can therefore make the server buffer and JSON-parse very large frames, and several concurrent connections can cause severe memory pressure or process termination; set an explicit, appropriately small maxPayload on this gateway.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4d41d60 — both gateways now set maxPayload: 1024 * 1024 (passed through WsAdapter to ws.Server), matching the ~1 MiB limit the socket.io transport enforced. Oversized frames close the socket at the transport layer before any buffering/parse.

Comment on lines +120 to +123
} catch (error) {
this.warn('Failed to register ws connection', error)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Re-add live presence records after failed writes

When Redis is unavailable during this HSET, the connection remains live in the local registry but is permanently absent from mx-ws:conns:web, so online counts omit it and room/activity lookups cannot find it after Redis recovers; failed joinRoom() writes have the analogous problem. Fresh evidence after the earlier reconciliation change is that reconcileOwnEntries() only deletes stale owned fields and never recreates missing entries for live connections or their rooms, so reconciliation must also restore the local registry state.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4d41d60 — the local index is now a full snapshot ({conns, rooms}) and reconciliation works in both directions: owned entries with no live connection are dropped, and live connections/room memberships missing from redis are re-added with this nodeId. Caveat noted: socket metadata contents (sessionId etc.) written by the gateway cannot be reconstructed here, so a restored conn participates in counts/routing but its metadata stays absent until the client reconnects. Regression test covers restore + prune in one sweep.

- cap inbound frames at 1 MiB on both gateways, matching the socket.io
  transport's limit instead of ws's 100 MiB default
- presence reconciliation now works both ways: gateways hand the sweep a
  full local snapshot (conns + room memberships) so entries lost to
  transient redis failures at connect/join time are re-added, not only
  stale ones removed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4d41d60489

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/ws-client/src/protocol.ts Outdated

const candidate = value as Record<string, unknown>

if (typeof candidate.v !== 'number') return false

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject envelopes from unsupported protocol versions

When a newer or incompatible peer sends a v: 2 frame, this predicate accepts it as a valid v1 envelope and createWsClient dispatches the event—or even resolves a pending request if it is an ack—using v1 semantics. Since the server parser explicitly accepts only version 1, the shared client should likewise require candidate.v === WS_PROTOCOL_VERSION so older clients ignore frames they cannot safely interpret.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a983b32isWsEnvelope now requires candidate.v === WS_PROTOCOL_VERSION, matching the server parser's v:1 literal; v:2/v:0 frames are dropped instead of being dispatched or resolving a pending ack under v1 semantics. Tests added for both.

isWsEnvelope accepted any numeric v, so a hypothetical v2 frame would be
dispatched (or resolve a pending ack) under v1 semantics while the server
parser pins v to 1 — pin the client predicate to the same literal.
@Innei
Innei merged commit ac021b7 into master Aug 15, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant