Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
55636f4
fix(connection): stop a disconnected socket from connecting or settli…
szuperaz Sep 25, 2026
96d8bbe
fix(client): keep the next user's token when disconnectUser's deferre…
szuperaz Sep 28, 2026
4764d62
feat: reintroduce WS fallback
szuperaz Sep 28, 2026
342a02f
fix: review fixes
szuperaz Sep 28, 2026
3f8a4d6
refactor: move WSFallback connect to WSConnection
szuperaz Sep 29, 2026
a5fbba1
refactor: use generated longPoll endpoint
szuperaz Sep 29, 2026
54aa92b
fix: review fixes
szuperaz Sep 29, 2026
e2f8d90
fix: empty token fallback
szuperaz Sep 29, 2026
a937e3c
refactor: remove unnecessary comments
szuperaz Sep 29, 2026
fc444ad
refactor: remove more comments
szuperaz Sep 29, 2026
f2d38f8
fix: wait for token in api client
szuperaz Sep 29, 2026
391cb41
refactor: move WSFallback class to wsConnection folder
szuperaz Sep 30, 2026
ef2196c
refactor: rename ConnectionState to WSFallbackConnectionState
szuperaz Sep 30, 2026
bd4dbc1
refactor: remove UR type
szuperaz Sep 30, 2026
f6e2d79
fix: mark methods as internal or private; move private methods to end…
szuperaz Sep 30, 2026
aa4560d
refactor: removed unused isHealthy getter
szuperaz Sep 30, 2026
6a75c58
refactor: remove connectionID from WS fallback
szuperaz Sep 30, 2026
f0b8c3b
refactor: reword misplaced comment
szuperaz Sep 30, 2026
5e55432
fix: set local state before notifying subscribers
szuperaz Sep 30, 2026
93b9957
fix: small fix
szuperaz Sep 30, 2026
d14921e
refactor: rename isStatusDerivedFromSocket to usesDefaultWSConnection…
szuperaz Sep 30, 2026
7fbf7fc
feat!: rename transport.changed to connection.fallback_activated
szuperaz Sep 30, 2026
92e3035
docs: add warning for enableWSFallback option
szuperaz Sep 30, 2026
9cc90c6
fix: remove isDisconnect fix
szuperaz Sep 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions CLAUDE.md

Large diffs are not rendered by default.

46 changes: 35 additions & 11 deletions docs/network-connection.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ it.
### Everywhere else, install one — and read this if you do not

A host that cannot answer the question gets a stand-in that **mirrors this client's WebSocket**, so an
integration that forgets the setup has a signal rather than nothing at all.
integration that forgets the setup has a signal rather than nothing at all. (It reads the WebSocket's
status store, so after an [`enableWSFallback`](#long-poll-fallback) switch it mirrors the long-poll.)

It is a safety net, not a measurement, and it has one consequence you must know about. Under it, the
device's status is the socket's status, so `isOnline === false` whenever the socket dies **for its own
Expand Down Expand Up @@ -176,7 +177,7 @@ client.wsConnection.state.getLatestValue();
// { isHealthy: boolean, lastHealthyAt: Date | null, lastUnhealthyAt: Date | null }
```

Two things about this store are worth knowing.
A few things about this store are worth knowing.

**It records every transition**, including `disconnect()` — what `client.closeConnection()` calls, the
documented mobile backgrounding path — and the socket's internal error paths. There is nothing it
Expand All @@ -193,13 +194,30 @@ awaits for anything that watches a channel or subscribes to presence — so thos
reconnect instead of going out keyed to a connection the server has closed. You rarely need to read
it; `client.connectionIdManager.connectionId` is there if you do.

### Long-poll fallback

With `enableWSFallback` on, a WebSocket that fails to connect with a network error — reported once
`connectTimeoutMs` runs out — makes the client switch to HTTP long-polling against `/api/v2/longpoll`.
It dispatches `connection.fallback_activated` with `mode: 'longpoll'` and stays on long-poll for the
rest of the client's life, `disconnectUser()` included. The switch is skipped when a network reporter
says the device is offline; the stand-in's "offline" does not count, since it only means the socket
is down.

After the switch, **this same store describes the long-poll**: `client.wsConnection.isHealthy` and
`state` report whether it is up, `connection.recovered` follows its reconnects, and
`client.connectionIdManager` holds its connection id. The long-poll follows `client.networkConnection`
too — it closes when the device goes offline and reconnects when it comes back — except while
`closeConnection()` or `disconnectUser()` has closed it, until it is reconnected. v9 stopped listening
for good at its first disconnect.

### Timing and transport settings

```ts
client.config.set({
client: {
wsConnection: {
connectTimeoutMs: 15000, // how long connect() waits for the server's hello
enableWSFallback: false, // long-poll when the WebSocket cannot connect — see above
pingIntervalMs: 25000, // how often a health-check ping goes out — 25s is also the maximum
healthCheckGracePeriodMs: 10000, // extra room before the socket is declared dead
offlineNotificationDisplayDelayMs: 5000, // how long a UI holds a drop before reporting it
Expand Down Expand Up @@ -227,21 +245,27 @@ client.on('connection.recovered', () => {
});
```

With `enableWSFallback` on there is also `connection.fallback_activated`, dispatched once, when the
client switches to long-polling. It reports the transport, not its status.

There is no `connection.changed`. Connectivity used to be published twice, as the stores and as that
event, and the two disagreed: the event was silent on `closeConnection()` and two error paths, and
held a drop for five seconds. Subscribe to whichever store you mean instead.

## Migration

| Before | Now |
| ----------------------------------------------------------- | ----------------------------------------------------------------------------- |
| `client.wsConnection.onlineStatusChanged(fakeDomEvent)` | `client.networkConnection.setStatus(isOnline)` |
| `client.on('connection.changed', …)` | `client.wsConnection.state` or `client.networkConnection.state`, subscribed |
| `connection.recovered`'s `connection` field | gone; the event reports the socket and carries no payload |
| `NetworkStatusListenerRegistrar`, `statusListenerRegistrar` | `NetworkStatusReporter`, `statusReporter` |
| `client.threads.state.lastConnectionDropAt` | `client.wsConnection.state.lastUnhealthyAt` |
| `client.defaultWSTimeout = 5000` | `client.config.set({ client: { wsConnection: { connectTimeoutMs: 5000 } } })` |
| `new StreamChat(key, { WebSocketImpl, wsUrlParams })` | `wsConnection` config: `webSocketImpl`, `urlParams` |
| Before | Now |
| ----------------------------------------------------------- | -------------------------------------------------------------------------------- |
| `client.wsConnection.onlineStatusChanged(fakeDomEvent)` | `client.networkConnection.setStatus(isOnline)` |
| `client.on('connection.changed', …)` | `client.wsConnection.state` or `client.networkConnection.state`, subscribed |
| `connection.recovered`'s `connection` field | gone; the event reports the socket and carries no payload |
| `NetworkStatusListenerRegistrar`, `statusListenerRegistrar` | `NetworkStatusReporter`, `statusReporter` |
| `client.threads.state.lastConnectionDropAt` | `client.wsConnection.state.lastUnhealthyAt` |
| `client.defaultWSTimeout = 5000` | `client.config.set({ client: { wsConnection: { connectTimeoutMs: 5000 } } })` |
| `new StreamChat(key, { WebSocketImpl, wsUrlParams })` | `wsConnection` config: `webSocketImpl`, `urlParams` |
| `new StreamChat(key, { enableWSFallback: true })` | `wsConnection` config: `enableWSFallback` |
| `client.on('transport.changed', …)` | `client.on('connection.fallback_activated', …)`; same `mode: 'longpoll'` payload |
| `client.defaultWSTimeoutWithFallback` | gone; the switch waits `connectTimeoutMs` |

`utils.ts` also had an internal `isOnline()` helper and an `addConnectionEventListeners()` pair. None
was exported from the package, so there is nothing to migrate — they are gone, and
Expand Down
49 changes: 25 additions & 24 deletions src/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ const logger = chatLoggerSystem.getLogger('api-client');

const MULTIPART_CONTENT_TYPE = 'multipart/form-data';

/** The WebSocket fallback's endpoint, which a local API serves on its own port. */
const LONG_POLL_PATH = '/api/v2/longpoll';

/**
* Upload requests must not inherit the axios instance timeout (3s by default) or the size
* caps - either would abort a large or slow upload.
Expand Down Expand Up @@ -62,8 +65,8 @@ export class ApiClient {
params: queryParams,
headers: { 'Content-Type': requestContentType },
...(isMultipart ? UPLOAD_REQUEST_DEFAULTS : {}),
// Keep this last so a caller-supplied signal wins - and keep it returning only the keys
// it owns, so it can never clobber the upload defaults above.
// Keep this last so a caller-supplied signal and timeout win - and keep it returning only
// the keys it owns, so it can never clobber the upload defaults above.
...toAxiosRequestConfig(options),
});
}
Expand Down Expand Up @@ -107,7 +110,12 @@ export class ApiClient {
}
}
if (resolved.startsWith('/')) {
resolved = this.client.baseURL + resolved;
const baseURL =
resolved === LONG_POLL_PATH
? // replace port if present for testing with local API
this.client.baseURL?.replace(':3030', ':8900')

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.

Why writing code for test purposes? Is there a purpose beyond fitting the test suite expectations?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We have the same thing for WS URL too: https://github.com/GetStream/stream-chat-js/blob/release-v10/src/client.ts#L516 - we need these swaps to run the SDK with a local backend. Both are existing concepts in master too.

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.

Anyways looks smelly to hard-code this stuff. I think this belongs to the config service rather than hard-code some ports in the SDK code.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yeah, we can do that, but then you have config params just for testing with local backend, because integrators would never need to set this. In any case, I don't think this PR is related. We can track it in Linear if we want to, but I don't think we should fix it here.

: this.client.baseURL;
resolved = baseURL + resolved;
}
return resolved;
}
Expand Down Expand Up @@ -185,6 +193,7 @@ export class ApiClient {
};
}

await this.client.tokenManager.tokenReady();
const initialRequestConfig = this.populateRequestConfigWithDefaults(additionalConfig);

const clientRequestId = initialRequestConfig.headers?.[
Expand Down Expand Up @@ -265,45 +274,33 @@ export class ApiClient {

/**
* Whether a request registers a server-side subscription, and so must not be sent before the
* handshake has produced a connection id.
* handshake has produced a connection id: one with a `watch` or `presence` flag set.
*
* The server keys watches and presence by that id and answers `200` while registering nothing when it
* is missing, so a request that races the handshake yields a channel that never receives an event.
*
* The two generated operations that declare `connection_id` without a flag set it themselves:
* `stopWatchingChannel` through `StreamChat`'s override, and `longPoll`'s endpoint through the
* long-poll fallback. `test/unit/codegen/connectionIdEndpoints.test.ts` pins that set, so a new one
* surfaces there.
*/
export const requiresConnectionId = (
params: Record<string, unknown> | undefined,
body: unknown,
) => {
const payload = params?.payload as Record<string, unknown> | undefined;
// Guarded rather than `body ?? undefined`: the `in` checks below throw on a string body.
const requestBody = (typeof body === 'object' && body !== null ? body : undefined) as
| Record<string, unknown>
| undefined;

if (
return Boolean(
params?.watch ||
params?.presence ||
payload?.watch ||
payload?.presence ||
requestBody?.watch ||
requestBody?.presence
) {
return true;
}

// A flag that is present and false is a deliberate "do not subscribe", so it must not fall through
// to the parameter check below — that is what made an explicit `watch: false` wait for a socket.
if (
[params, payload, requestBody].some(
(source) => source && ('watch' in source || 'presence' in source),
)
) {
return false;
}

// Some operations subscribe without carrying a flag — stop-watching and long polling — and are
// recognised by the generated `connection_id` parameter instead.
return Boolean(params && 'connection_id' in params);
requestBody?.presence,
);
};

/**
Expand All @@ -323,11 +320,15 @@ const isUsableAbortSignal = (signal: unknown): signal is AbortSignal =>
const toAxiosRequestConfig = ({
signal,
onUploadProgress,
timeout,
}: StreamRequestOptions = {}): AxiosRequestConfig => ({
signal: isUsableAbortSignal(signal) ? signal : undefined,
// Same reasoning as `isUsableAbortSignal`: an options object revived from a persisted
// offline-db task payload has lost its functions.
onUploadProgress: typeof onUploadProgress === 'function' ? onUploadProgress : undefined,
// Only when set: an explicit `undefined` would clobber the upload defaults' `timeout: 0`, and
// axios would fall back to the instance timeout.
...(typeof timeout === 'number' ? { timeout } : {}),
});

const errorIsApiError = (error: unknown): error is AxiosError<APIError> => {
Expand Down
Loading
Loading