---
title: Migration
description: Breaking changes in the Streaming extension and what to update
---

## Read This First

Two independent things changed, and it is worth separating them:

1. **The docs were wrong.** Earlier revisions of these pages described `Manager` signatures that never
   existed in any release — `CreateRoom(ctx, options) (Room, error)`, `JoinRoom(ctx, roomID, conn, opts)`,
   `Broadcast(ctx, []byte)`. Code copied from them never compiled, so there is nothing to migrate; use
   [Functions](/docs/forge/extensions/streaming/functions) as the reference.
2. **The extension changed.** Real breaking changes are listed below.

The one to check by hand is the typing-indicator argument order, because it is the only change that
compiles either way.

## Silent Behaviour Changes

### `StartTyping` / `StopTyping` argument order

The signature is `(ctx, userID, roomID)`. Earlier docs showed `(ctx, roomID, userID)`. Both are
strings, so the wrong order compiles cleanly and records typing against a room ID used as a user ID.

```go
// wrong — compiles, silently misfiles the indicator
mgr.StartTyping(ctx, roomID, userID)

// right
mgr.StartTyping(ctx, userID, roomID)
```

### `BroadcastExcept` now excludes users, not connections

The parameter has always been named `excludeUserIDs` on the interface and on both `Room`
implementations; the manager matched it against connection IDs. The ordinary call — "broadcast to the
room, except the sender" — therefore excluded nobody and echoed every message back to its author.

```go
// Now behaves as documented: excludes every connection belonging to these users.
mgr.BroadcastExcept(ctx, msg, []string{senderUserID})
```

### `SendToConnection` no longer returns write errors

Delivery is asynchronous: the call hands the frame to the connection's send queue and a dedicated
writer goroutine performs the socket write. A transport failure cannot be reported through the return
value; the connection tears itself down instead. Check `conn.IsClosed()` or observe the disconnect,
rather than relying on the error.

### Presence and multiple connections

A user is marked offline only when their **last** connection closes. Previously any disconnect marked
them offline, so a user with two tabs went offline when either closed.

## API Changes

| Change | Action |
|---|---|
| `NewExtension` / `NewExtensionWithConfig` return `*Extension` | Remove any `.(*streaming.Extension)` type assertion. Assigning to a `forge.Extension` variable still works |
| `Manager` gained `ProcessInbound` | Custom socket handlers must call it before broadcasting client messages |
| `Manager` gained `Replay` | Optional; needed only for cursor-based reconnect |
| `MessageStore` gained `GetSince` | **Custom backends must implement it.** Return messages with `Sequence > afterSequence`, oldest first, capped at `limit` |
| `Message` gained `Sequence` | Assigned by the store on save for room messages |
| `router.Connection` gained `WriteBinary` | Custom transports must implement it |
| `forge.Stream` gained `SendWithID`, `SendJSONWithID`, `LastEventID` | Custom SSE transports must implement them |

## Newly Enforced Behaviour

These were configurable but inert. Enabling nothing new, they may still change how your deployment
behaves, because the settings finally do what they said.

| Now enforced | Consequence |
|---|---|
| Room and message **authorization** | `JoinRoom` consults a `RoomAuthorizer`; sends are checked against room membership, mutes, and bans. Previously any client could join any room and publish to any room ID it named |
| **Rate limiting and validation** | `ProcessInbound` applies them. Previously constructed, reported in the dashboard, and never called |
| `MaxMessageSize` | Oversize messages rejected with `ErrMessageTooLarge` |
| `PingInterval` / `PongTimeout` | A real heartbeat now runs; idle connections are closed |
| Room and channel **membership writes** | `GetRoomMembers` and the per-user room/channel limits now reflect reality. Both were permanently empty, so the limits could never trip |
| **Identity stamping** | `msg.UserID` is overwritten from the authenticated connection. A client could previously set it to any value |

<Callout type="warn">
If your application relied on clients joining arbitrary rooms, or on the per-user room limit never
firing, that behaviour will now stop. Supply a permissive `RoomAuthorizer` via `WithRoomAuthorizer`
if you genuinely want the old semantics.
</Callout>

## Endpoint Changes

The SSE subscribe and unsubscribe endpoints now require the caller to **own** the connection named in
`conn_id`. Previously they checked only that such a connection existed, so any caller could subscribe
or unsubscribe somebody else's connection. Both "no such connection" and "not yours" return the same
404, so the endpoint cannot be used to probe which connection IDs are live.

## New Configuration

| Setting | Default | Purpose |
|---|---|---|
| `MaxAnonymousConnections` | `1000` | Caps unauthenticated sockets, which no per-user limit can bound |
| `MaxTotalConnections` | `0` (off) | Caps sockets per node regardless of identity |
| `DrainTimeout` | `15s` | How long `Stop` waits for a clean drain |

`WithSessionResumption` and `WithLoadBalancer` are now exported. Both features existed but could not
be enabled through the public options API — only by hand-building a `Config`.

## Web Client

The Go envelope and the TypeScript client disagreed on which field names a frame. `Message.Type`
carries the transport kind (`message`, `system`, `join`, …) and `Message.Event` carries the domain
name (`order.created`); the client's default decoder read `type` first, so every frame decoded as
`"message"`, matched no binding, and was dropped.

Use the shipped decoder:

```ts
import { forgeStreamingDecoder, StreamBinder } from '@forge-go/client-core';

const binder = new StreamBinder({
  cache,
  streams: manifest.streams,
  manager,
  decode: forgeStreamingDecoder(),
});
```

Server-side, build domain frames with `NewEventMessage` so `Event` is never forgotten — a frame that
sets only `Type` is a transport frame by definition and the client drops it silently.
