Read This First01
Two independent things changed, and it is worth separating them:
The docs were wrong. Earlier revisions of these pages described
Managersignatures 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 as the reference.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 Changes02
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.
// 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.
// 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 Changes03
| 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 Behaviour04
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 |
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.
Endpoint Changes05
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 Configuration06
| 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 Client07
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:
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.