Skip to content

feat: request/response messaging for room events - #1513

Open
LautaroPetaccio wants to merge 1 commit into
auth-serverfrom
feat/room-requests
Open

feat: request/response messaging for room events#1513
LautaroPetaccio wants to merge 1 commit into
auth-serverfrom
feat/room-requests

Conversation

@LautaroPetaccio

@LautaroPetaccio LautaroPetaccio commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

What this adds

Request/response messaging in @dcl/sdk/network, on top of the existing room events:

// shared/rpc.ts — imported by both sides
export const rpc = registerRequests({
  loadFarm: { request: Schemas.Map({}), response: FarmStateSchema },
  buySeed:  { request: Schemas.Map({ cropType: Schemas.Int }), response: Schemas.Map({ coins: Schemas.Int }) }
})

// server
rpc.handle('loadFarm', async (_data, context) => toPayload(await store.load(context.from)))

// client
const farm = await rpc.request('loadFarm', {})

No protocol change. Each method derives two ordinary registered events (@dcl/req:<m>, @dcl/res:<m>) that wrap the declared schemas with a correlation id, so the event envelope and wire format are untouched.

What it abstracts

room.send / room.onMessage are fire-and-forget, so any scene that needs an answer builds the correlation by hand. Across the shipped scenes that is 286 room.send sites and 207 onMessage sites, and four recurring pieces of boilerplate — each of which has produced a real bug.

1. Correlation ids, and replies broadcast to everyone

cozy-farm declares the plumbing in the schema (src/shared/farmMessages.ts:174):

const FarmStateLoadedSchema = Schemas.Map({
  requester: Schemas.String,
  requestId: Schemas.String,
  payload: FarmStateSchema
})

then answers without { to: … } (src/server/farmServer.ts:44):

void room.send('farmStateLoaded', { requester: normalized, requestId, payload })

so every connected client receives every other player's full farm payload and drops it on the floor client-side (src/services/saveService.ts:503):

const requester = normalizeAddress(data.requester || data.payload?.wallet)
if (!requester) return
if (playerState.wallet && requester !== playerState.wallet) return

Same shape at farmServer.ts:203 (playerRegistryLoaded), :224 (beautyLeaderboardLoaded), :245 (otherFarmLoaded). beautyLeaderboardLoaded also has a comment noting the personalised rank has to be recomputed per requester even though the list is broadcast.

towerofmadness carries an address field for the same purpose and filters against the local player (src/multiplayer.ts:59, :78):

const localIdentity = PlayerIdentityData.getOrNull(engine.PlayerEntity)
if (!localIdentity?.address) return
if (localIdentity.address.toLowerCase() !== data.address.toLowerCase()) return

With handle, the reply goes out as { to: [context.from] } and none of that exists. There is a test asserting the send target is exactly the caller.

2. Retry loops standing in for a timeout

My-Dear-Pet re-asks for its own save every 2s for 30s because a lost request is indistinguishable from a slow one (src/client/setup.ts:107-120):

let sinceReq = 99
let elapsed = 0
actions.requestState()
engine.addSystem((dt: number) => {
  elapsed += dt
  if (elapsed < 30) {
    sinceReq += dt
    if (sinceReq >= 2) {
      sinceReq = 0
      resolveMyAddress()
      actions.requestState()   // keep asking and hope
    }
  }
})

becomes await rpc.request('loadState', {}), which rejects with RequestTimeoutError if nothing answers.

3. A hand-rolled error channel per handler

cozy-farm repeats a reason: 'server_error' string in every catch block (farmServer.ts:186, :248, :284, :323) and, when the load fails, silently sends the player a fresh empty farm (:137):

} catch (err) {
  console.error('[FarmServer] loadAndSend failed, sending fresh farm:', err)
  const fresh = emptyFarm(context.from.toLowerCase())
  void room.send('farmStateLoaded', { requester: , requestId, payload: farmSaveToPayload(fresh) })
}

dead-surge declares dedicated rejection messages per request instead (src/shared/messages.ts:97 potionClaimRejected, :244 collectibleClaimRejected).

Here a handler throws RequestError('insufficient_funds') and the message is forwarded verbatim; any other throw becomes internal_error and is logged server-side, so an unexpected crash cannot leak storage keys, addresses or stack traces over the wire. A missing reply rejects with RequestTimeoutError, which is separately catchable so retry logic can tell a transport failure from a business rejection.

4. A payload ceiling nothing enforced

Custom events are not chunked. LIVEKIT_MAX_SIZE = 12 (KB) is applied only to CRDT traffic (network/state.ts:98,106; network/server/index.ts:150,160,298) — a grep for any size guard on CommsMessage.CUSTOM_EVENT returns nothing. So an oversized reply is dropped by the transport with no error, and the caller finds out only when its timeout expires. Worse, Room.send catches and logs its own failures, so a payload that does not match its schema vanishes the same way.

Both are now caught before the send: checkPayload() encodes the message, rejects a shape the schema cannot serialize (invalid_payload) and one that exceeds the transport limit (payload_too_large). On the handler side, when the reply is the thing that cannot be sent, the failure is sent in its place — so the caller learns why instead of waiting out the clock. Real chunking for large payloads remains a separate piece of work; this makes the ceiling visible rather than silent.

5. Timers the server runtime does not have

flagtag documents the constraint (docs/STORAGE.md:23): "The runtime does not guarantee setTimeout. Anything time-based on the server (timeouts, retry backoff, debounce) must be driven by an engine system." Deadlines here are swept from an engine system for exactly that reason, so request() behaves the same on both sides. Default 20s — a handler may do several ~2s storage round trips — overridable per call.

Design notes

The response body is never Schemas.Optional. IOptional.serialize checks if (value), so a legitimate 0 / false / '' response would serialize as absent and deserialize as undefined. On failure the body is filled from schema.create() instead, and ok: false tells the caller to ignore it. Regression test included (countCoins returning 0).

Server-initiated requests must name a target, and that is enforced. rpc.request(m, data, { to: address }) is the only valid server form: omitting to would make Room.send broadcast the request and its correlation id to every client, any of which could then answer. A server request without to therefore throws rather than sending, using a new synchronous Room.isServer(). The response path additionally fails closed — a reply is only accepted from the peer the request named, so an unset expectation can never be settled from the wire. (Client → server needs no such check: Room already drops client-side events from anyone but the authoritative server.)

A request queued before the room connects does not burn its budget. Room.send queues while disconnected, so a boot-time request used to be rejected by the sweeper and then executed anyway when the queue flushed — the caller saw a timeout, retried, and the handler ran twice. Queued entries now carry no deadline at all; the clock starts when the room connects and the request actually goes out.

Double handlers are flagged, per room. Registering a second live handler for one method means two replies and the caller settling on whichever lands first. The guard is keyed on the Room (a module-level WeakMap), not on the Requests instance — listeners live on the room, so two instances bound to the same room would each answer, which is exactly the case the warning exists for and which an instance-scoped guard could not see.

Why a separate object rather than room.request / room.handle. Room is generic over the message registry, and requests need a different registry shape ({ request, response } pairs). Folding both into one class means either a second type parameter on Room — which changes getRoom<T>() and registerMessages typing for every existing scene — or losing type safety on one side. A separate typed object keeps both fully typed and makes this change purely additive. Easy to re-surface on Room later if that reads better.

Registration is idempotent for equivalent schemas and throws for a name reused with a different shape. Compared structurally via jsonSchema, not by object identity — Schemas.Map({...}) mints a fresh object per call, so identity comparison would reject two modules declaring the same method with textually identical schemas, and would reject any code path that re-evaluates its definitions literal.

Testing

test/sdk/network/requests.spec.ts, 29 cases driving a real client Room and server Room wired together through the platform's addressing rules (clients can only reach the server; the server broadcasts or targets). Covers the happy path, async handlers, falsy response bodies, RequestError forwarding, internal-error redaction, timeout, out-of-order concurrent requests, reply addressing, and schema conflict detection — plus, for the fixes above: a request issued while the room is disconnected (ticked well past its deadline, then connected, asserting it neither times out nor double-runs the handler), a server request with no target, a reply from a peer the request did not name, an oversized response, an unserializable request, two instances handling one method on one room, and a structurally-identical re-declaration.

  • make test — 158 suites / 1211 tests, 0 failures
  • make lint@dcl/sdk clean
  • make build — passes, including the playground-assets API report gate
  • tsc --noEmit on @dcl/sdk — clean

Notes for review

  • checkPayload costs one extra serialization per message. The alternative was a back door on Room that accepts a pre-encoded buffer; an @internal method would be stripped from the emitted .d.ts while still existing at runtime, which is a worse shape than paying for a second encode on a request/response path.
  • The 12KB constant is duplicated rather than imported from network/server, deliberately: keeping this module outside the network transport's import cluster is worth more than sharing one number. Commented at the definition.
  • Not fixed here: custom events still are not chunked, so a genuinely large payload (a whole player save, say) cannot be sent as one request — it now fails loudly instead of silently, but making it work needs chunking in the event layer and is filed separately.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 31, 2026

Copy link
Copy Markdown

Deploying js-sdk-toolchain with  Cloudflare Pages  Cloudflare Pages

Latest commit: bee191b
Status: ✅  Deploy successful!
Preview URL: https://096144bd.js-sdk-toolchain.pages.dev
Branch Preview URL: https://feat-room-requests.js-sdk-toolchain.pages.dev

View logs

@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Test this pull request

  • The @dcl/sdk package can be tested in scenes by running

    npm install "https://sdk-team-cdn.decentraland.org/@dcl/js-sdk-toolchain/branch/feat/room-requests/dcl-sdk-7.25.1-30664441861.commit-7ebdac5.tgz"
  • The @dcl/js-runtime package can be tested in scenes by running

    npm install "https://sdk-team-cdn.decentraland.org/@dcl/js-sdk-toolchain/branch/feat/room-requests/@dcl/js-runtime/dcl-js-runtime-7.25.1-30664441861.commit-7ebdac5.tgz"
  • To test with npx init

    export SDK_COMMANDS="https://sdk-team-cdn.decentraland.org/@dcl/js-sdk-toolchain/branch/feat/room-requests/dcl-sdk-commands-7.25.1-30664441861.commit-7ebdac5.tgz"
    npx $SDK_COMMANDS init
  • The /changerealm command to test test in-world

    /changerealm https://sdk-team-cdn.decentraland.org/ipfs/feat/room-requests-e2e
    
  • You can preview this build entering:
    https://playground.decentraland.org/?sdk-branch=feat/room-requests

@decentraland-bot decentraland-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I found two P1 issues that should be fixed before merging.

Findings

  • P1 — Requests issued before room readiness can still time out before they are ever sent. In packages/@dcl/sdk/src/network/events/requests.ts:232-234, the sweeper checks deadlineMs for every pending entry, including entries marked waitingForRoom. Since Room.send() queues while disconnected, a boot-time request can be rejected before onReady(true) has a chance to rebase the deadline. When the room later flushes its queue, the handler may still run even though the caller already observed a timeout and the response will be ignored. Skip sweeping waitingForRoom entries (or only assign/start the deadline once the request is actually sent), and add a regression test where engine ticks continue past the timeout before the room becomes ready.

  • P1 — Server-side request() without options.to broadcasts and accepts the first client response. RequestOptions.to is documented as required for server-initiated requests, but request() stores expectFrom: '' and calls room.send(..., undefined) when to is omitted (requests.ts:347 and :355). On a server room that means the request is broadcast to every client, and the response listener accepts whichever client answers first. This can leak player-specific request payloads and lets an unintended client settle the server's request. Please enforce the runtime contract: server-initiated requests should require a target, and targeted requests should reject responses that lack/mismatch context.from.

Additional notes

  • Public API impact: this is additive (registerRequests, createRequests, request types/errors), so I did not find a backward-incompatible consumer impact.
  • Security review: no hardcoded secrets or injection issues found; the server broadcast/first-response behavior above is the relevant security concern.
  • CI: all checks are passing.

Reviewed by Jarvis 🤖 · Requested by Lautaro Petaccio (<@U025WCHLMN3>) via Slack

room.send / room.onMessage are fire-and-forget, so a scene that needs an answer
("load my save", "buy this, tell me the new balance") builds the correlation by
hand: a requestId threaded through both payloads, a requester field so each
client can tell whether a broadcast reply was meant for it, a per-handler
reason string for failures, and a retry loop for when no answer arrives.

Adds registerRequests to @dcl/sdk/network:

  export const rpc = registerRequests({
    loadFarm: { request: Schemas.Map({}), response: FarmStateSchema }
  })

  rpc.handle('loadFarm', async (_data, ctx) => toPayload(await store.load(ctx.from)))
  const farm = await rpc.request('loadFarm', {})

No protocol change: each method derives two ordinary registered events
(@dcl/req:<m>, @dcl/res:<m>) that wrap the declared schemas with a correlation
id, so the event envelope and the wire format are untouched.

The layer owns four things scenes were getting wrong:

- Addressing. The reply goes out with { to: [context.from] }, so one player's
  payload never lands in another player's client.
- Errors. Throw RequestError for anything the caller should read and its message
  is forwarded verbatim; any other throw becomes internal_error and is logged
  server-side, so a crash cannot leak storage keys or stack traces over the wire.
  A missing reply rejects with RequestTimeoutError, which is separately
  catchable so retry logic can tell it apart from a business rejection.
- Timeouts. Swept from an engine system rather than setTimeout, which the server
  runtime does not guarantee. Default 20s, overridable per call.
- Pre-connection sends. room.send queues until the room is ready, so deadlines
  are re-based from the moment it connects instead of expiring in the queue.

The response body is always serialized rather than wrapped in Schemas.Optional,
which drops falsy values and would silently turn a legitimate 0 / false / ''
response into a schema default.

registerRequests returns its own typed object instead of adding methods to Room:
Room is generic over the message registry and requests need a different registry
shape, so folding them together would mean either a second type parameter on
Room — changing getRoom<T>() and registerMessages typing for every existing
scene — or losing type safety on one side.
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.

2 participants