From 7e03a3d70011d9b1b0e941348427e9430c8aecd6 Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Tue, 11 Aug 2026 17:30:19 +0200 Subject: [PATCH 1/5] Initial commit --- package.json | 16 +- scripts/bundle.mjs | 2 +- src/base64.ts | 78 ------- src/client.ts | 6 +- src/connection.ts | 44 ++-- src/index.ts | 1 - src/signing.ts | 316 +------------------------- src/token_manager.ts | 33 +-- src/types.ts | 6 + test/unit/client.construction.test.ts | 13 +- test/unit/connection.test.js | 108 +++++++-- test/unit/signing.test.js | 35 ++- yarn.lock | 165 +------------- 13 files changed, 149 insertions(+), 674 deletions(-) delete mode 100644 src/base64.ts diff --git a/package.json b/package.json index de9e1c1492..f1e9582606 100644 --- a/package.json +++ b/package.json @@ -28,13 +28,6 @@ "default": "./dist/esm/index.mjs" } }, - "browser": { - "crypto": false, - "https": false, - "jsonwebtoken": false, - "ws": false, - "zlib": false - }, "license": "SEE LICENSE IN LICENSE", "keywords": [ "chat", @@ -51,15 +44,8 @@ ], "dependencies": { "@stream-io/logger": "^2.0.0", - "@types/jsonwebtoken": "^9.0.8", - "@types/ws": "^8.18.1", "axios": "^1.16.1", - "base64-js": "^1.5.1", - "form-data": "^4.0.5", - "isomorphic-ws": "^5.0.0", - "jsonwebtoken": "^9.0.3", - "linkifyjs": "^4.3.3", - "ws": "^8.20.1" + "linkifyjs": "^4.3.3" }, "devDependencies": { "@commitlint/cli": "^21.0.1", diff --git a/scripts/bundle.mjs b/scripts/bundle.mjs index 5fa496dbe5..c22b71dfd0 100755 --- a/scripts/bundle.mjs +++ b/scripts/bundle.mjs @@ -21,7 +21,7 @@ const modules = Object.keys({ // do not externalize modules that are ignored in browser field // externalizing them will cause esbuild to not replace the imports // in the bundles -const browserIgnoreModules = Object.keys(packageJson.browser); +const browserIgnoreModules = []; // Object.keys(packageJson.browser); const browserExternal = modules.filter( (module) => !browserIgnoreModules.includes(module), ); diff --git a/src/base64.ts b/src/base64.ts deleted file mode 100644 index 472ba73aec..0000000000 --- a/src/base64.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { fromByteArray } from 'base64-js'; - -function isString(arrayOrString: string | T[]): arrayOrString is string { - return typeof (arrayOrString as string) === 'string'; -} - -type MapGenericCallback = (value: T, index: number, array: T[]) => U; -type MapStringCallback = (value: string, index: number, string: string) => U; - -function isMapStringCallback( - arrayOrString: string | T[], - callback: MapGenericCallback | MapStringCallback, -): callback is MapStringCallback { - return !!callback && isString(arrayOrString); -} - -// source - https://github.com/beatgammit/base64-js/blob/master/test/convert.js#L72 -function map(array: T[], callback: MapGenericCallback): U[]; -function map(string: string, callback: MapStringCallback): U[]; -function map( - arrayOrString: string | T[], - callback: MapGenericCallback | MapStringCallback, -): U[] { - const res = []; - - if (isString(arrayOrString) && isMapStringCallback(arrayOrString, callback)) { - for (let k = 0, len = arrayOrString.length; k < len; k++) { - if (arrayOrString.charAt(k)) { - const kValue = arrayOrString.charAt(k); - const mappedValue = callback(kValue, k, arrayOrString); - res[k] = mappedValue; - } - } - } else if (!isString(arrayOrString) && !isMapStringCallback(arrayOrString, callback)) { - for (let k = 0, len = arrayOrString.length; k < len; k++) { - if (k in arrayOrString) { - const kValue = arrayOrString[k]; - const mappedValue = callback(kValue, k, arrayOrString); - res[k] = mappedValue; - } - } - } - - return res; -} - -export const encodeBase64 = (data: string): string => - fromByteArray(new Uint8Array(map(data, (char) => char.charCodeAt(0)))); - -// base-64 decoder throws exception if encoded string is not padded by '=' to make string length -// in multiples of 4. So gonna use our own method for this purpose to keep backwards compatibility -// https://github.com/beatgammit/base64-js/blob/master/index.js#L26 -export const decodeBase64 = (s: string): string => { - const e = {} as { [key: string]: number }, - w = String.fromCharCode, - L = s.length; - let i, - b = 0, - c, - x, - l = 0, - a, - r = ''; - const A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; - for (i = 0; i < 64; i++) { - e[A.charAt(i)] = i; - } - for (x = 0; x < L; x++) { - c = e[s.charAt(x)]; - b = (b << 6) + c; - l += 6; - while (l >= 8) { - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - ((a = (b >>> (l -= 8)) & 0xff) || x < L - 2) && (r += w(a)); - } - } - return r; -}; diff --git a/src/client.ts b/src/client.ts index be680e1fd8..e00be4ae2d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -3,7 +3,6 @@ import type { AxiosInstance, AxiosRequestConfig } from 'axios'; import axios from 'axios'; -import https from 'https'; import { Channel } from './channel'; import { ClientState } from './client_state'; @@ -248,7 +247,7 @@ export class StreamChat extends ChatApi { * @param options.logLevel - Minimum log level for the default sink (optional, defaults to `'info'`). * @param options.logOptions - Per-scope sink/level overrides for `chatLoggerSystem` (optional). * @param options.timeout - Request timeout (optional, defaults to `3000`). - * @param options.httpsAgent - Custom `httpsAgent` (optional, in Node defaults to `https.agent()`). + * @param options.httpsAgent - Custom `httpsAgent` (optional). */ constructor(key: string, options: StreamChatOptions = {}) { // generated client requires ApiClient right away @@ -290,9 +289,6 @@ export class StreamChat extends ChatApi { this.axiosInstance = axios.create({ timeout: 3000, withCredentials: false, - httpsAgent: this.node - ? new https.Agent({ keepAlive: true, keepAliveMsecs: 3000 }) - : undefined, ...this.options.axiosRequestConfig, paramsSerializer: axiosParamsSerializer, }); diff --git a/src/connection.ts b/src/connection.ts index 6ad1de09c0..69f0d2be86 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -1,4 +1,3 @@ -import WebSocket from 'isomorphic-ws'; import { addConnectionEventListeners, chatCodes, @@ -24,12 +23,22 @@ const logger = chatLoggerSystem.getLogger('connection'); // Type guards to check WebSocket error type const isCloseEvent = ( - res: WebSocket.CloseEvent | WebSocket.Data | WebSocket.ErrorEvent, -): res is WebSocket.CloseEvent => (res as WebSocket.CloseEvent).code !== undefined; + res: CloseEvent | MessageEvent | ErrorEvent | Event, +): res is CloseEvent => (res as CloseEvent).code !== undefined; const isErrorEvent = ( - res: WebSocket.CloseEvent | WebSocket.Data | WebSocket.ErrorEvent, -): res is WebSocket.ErrorEvent => (res as WebSocket.ErrorEvent).error !== undefined; + res: CloseEvent | MessageEvent | ErrorEvent | Event, +): res is ErrorEvent => (res as ErrorEvent).error !== undefined; + +class WSCloseError extends Error { + public reason?: string; + public wasClean?: boolean; + public code?: number; + public target?: EventTarget | null; + constructor(message?: string, errorOptions?: ErrorOptions) { + super(message, errorOptions); + } +} /** * A WS connection that reconnects upon failure. @@ -252,11 +261,6 @@ export class StableWSConnection { this.isHealthy = false; - // remove ws handlers... - if (this.ws && this.ws.removeAllListeners) { - this.ws.removeAllListeners(); - } - let isClosedPromise: Promise; // and finally close... // Assigning to local here because we will remove it from this before the @@ -264,7 +268,7 @@ export class StableWSConnection { const { ws } = this; if (ws && ws.close && ws.readyState === ws.OPEN) { isClosedPromise = new Promise((resolve) => { - const onclose = (event: WebSocket.CloseEvent) => { + const onclose = (event: CloseEvent) => { logger .withExtraTags('disconnect') .debug( @@ -337,7 +341,10 @@ export class StableWSConnection { wsURL, requestID: this.requestID, }); - this.ws = new WebSocket(wsURL); + + const WS = this.client.options.WebSocketImpl ?? WebSocket; + this.ws = new WS(wsURL); + this.ws.onopen = this.onopen.bind(this, this.wsID); this.ws.onclose = this.onclose.bind(this, this.wsID); this.ws.onerror = this.onerror.bind(this, this.wsID); @@ -503,7 +510,7 @@ export class StableWSConnection { }); }; - onmessage = (wsId: number, event: WebSocket.MessageEvent) => { + onmessage = (wsId: number, event: MessageEvent) => { if (this.wsID !== wsId) return; logger.withExtraTags('onmessage').trace('WebSocket onmessage callback fired.', { @@ -539,7 +546,7 @@ export class StableWSConnection { this.scheduleConnectionCheck(); }; - onclose = (wsId: number, event: WebSocket.CloseEvent) => { + onclose = (wsId: number, event: CloseEvent) => { if (this.wsID !== wsId) return; logger @@ -552,9 +559,7 @@ export class StableWSConnection { if (event.code === chatCodes.WS_CLOSED_SUCCESS) { // this is a permanent error raised by stream.. // usually caused by invalid auth details - const error = new Error( - `WS connection reject with error ${event.reason}`, - ) as Error & WebSocket.CloseEvent; + const error = new WSCloseError(`WS connection reject with error ${event.reason}`); error.reason = event.reason; error.code = event.code; @@ -584,7 +589,7 @@ export class StableWSConnection { } }; - onerror = (wsId: number, event: WebSocket.ErrorEvent) => { + onerror = (wsId: number, event: Event) => { if (this.wsID !== wsId) return; this.consecutiveFailures += 1; @@ -630,7 +635,7 @@ export class StableWSConnection { * @returns A normalized error describing the WS failure. */ _errorFromWSEvent = ( - event: WebSocket.CloseEvent | WebSocket.Data | WebSocket.ErrorEvent, + event: CloseEvent | MessageEvent | ErrorEvent | Event, isWSFailure = true, ) => { let code; @@ -679,7 +684,6 @@ export class StableWSConnection { this.wsID += 1; try { - this?.ws?.removeAllListeners(); this?.ws?.close(); } catch (e) { // we don't care diff --git a/src/index.ts b/src/index.ts index 38ae430cb0..c4fc236194 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,3 @@ -export * from './base64'; export * from './client'; export * from './client_state'; export * from './channel'; diff --git a/src/signing.ts b/src/signing.ts index 35fc296d52..71abe411fa 100644 --- a/src/signing.ts +++ b/src/signing.ts @@ -1,71 +1,3 @@ -import jwt from 'jsonwebtoken'; -import crypto from 'crypto'; -import zlib from 'zlib'; -import { decodeBase64, encodeBase64 } from './base64'; -import type { UR } from './types'; -import type { WSEvent } from './gen/models'; - -/** - * Creates the JWT token that can be used for a user session. - * - * @param apiSecret - API secret key. - * @param userId - The `user_id` key in the JWT payload. - * @param extraData - Extra data that should be part of the JWT token (optional, defaults to `{}`). - * @param jwtOptions - Options that can be passed to `jwt.sign` (optional, defaults to `{}`). - * @returns The signed JWT token. - */ -export function JWTUserToken( - apiSecret: jwt.Secret, - userId: string, - extraData: UR = {}, - jwtOptions: jwt.SignOptions = {}, -) { - if (typeof userId !== 'string') { - throw new TypeError('userId should be a string'); - } - - const payload: { user_id: string } & UR = { - user_id: userId, - ...extraData, - }; - - // make sure we return a clear error when the JWT module is shimmed (i.e. browser build) - if (jwt == null || jwt.sign == null) { - throw Error( - `Unable to find jwt crypto, if you are getting this error is probably because you are trying to generate tokens on browser or React Native (or other environment where crypto functions are not available). Please Note: token should only be generated server-side.`, - ); - } - - const opts: jwt.SignOptions = Object.assign( - { algorithm: 'HS256', noTimestamp: true }, - jwtOptions, - ); - - if (payload.iat) { - opts.noTimestamp = false; - } - return jwt.sign(payload, apiSecret, opts); -} - -/** - * Creates the JWT token that can be used for a server-side session. - * - * @param apiSecret - API secret key. - * @param jwtOptions - Options that can be passed to `jwt.sign` (optional, defaults to `{}`). - * @returns The signed JWT token. - */ -export function JWTServerToken(apiSecret: jwt.Secret, jwtOptions: jwt.SignOptions = {}) { - const payload = { - server: true, - }; - - const opts: jwt.SignOptions = Object.assign( - { algorithm: 'HS256', noTimestamp: true }, - jwtOptions, - ); - return jwt.sign(payload, apiSecret, opts); -} - /** * Decodes a JWT token and returns the embedded `user_id`. * @@ -78,253 +10,7 @@ export function UserFromToken(token: string) { return ''; } const b64Payload = fragments[1]; - const payload = decodeBase64(b64Payload); + const payload = atob(b64Payload); const data = JSON.parse(payload); return data.user_id as string; } - -/** - * Generates a development token for the given user. - * - * Development tokens are unsigned and must only be used in environments where token validation is disabled. - * - * @param userId - The ID of the user. - * @returns The development token. - */ -export function DevToken(userId: string) { - return [ - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9', //{"alg": "HS256", "typ": "JWT"} - encodeBase64(JSON.stringify({ user_id: userId })), - 'devtoken', // hardcoded signature - ].join('.'); -} - -/** - * Constant-time HMAC-SHA256 verification of `signature` against the digest of `body` using `secret` - * as the key. The signature is always computed over the **uncompressed** JSON bytes, so callers that - * decoded a gzipped or base64-wrapped payload must pass the inflated bytes here. - * - * The legacy `client.verifyWebhook` helper wraps this function, so callers that have already - * migrated to {@link verifyAndParseWebhook}, {@link parseSqs}, or {@link parseSns} rarely need to - * invoke this directly. - * - * @param body - The uncompressed payload bytes that Stream signed. - * @param signature - The HMAC-SHA256 signature delivered alongside the payload. - * @param secret - Your app's API secret used as the HMAC key. - * @returns `true` when the signature matches the digest of `body`, otherwise `false`. - */ -export function verifySignature( - body: string | Buffer, - signature: string, - secret: string, -): boolean { - const key = Buffer.from(secret, 'utf8'); - const hash = crypto.createHmac('sha256', key).update(body).digest('hex'); - try { - return crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(signature)); - } catch { - return false; - } -} - -/** - * Verifies an HMAC-SHA256 signature with the legacy parameter order. - * - * @param body - The uncompressed payload bytes that Stream signed. - * @param secret - Your app's API secret used as the HMAC key. - * @param signature - The HMAC-SHA256 signature delivered alongside the payload. - * @returns `true` when the signature matches the digest of `body`, otherwise `false`. - * @deprecated Use {@link verifySignature} instead — same logic, parameters reordered to match the - * cross-SDK contract (`verifySignature(body, signature, secret)`). - */ -export function CheckSignature(body: string | Buffer, secret: string, signature: string) { - return verifySignature(body, signature, secret); -} - -/** - * Canonical failure-mode messages for {@link InvalidWebhookError}. - * - * Customers that prefer exact-match filtering (security logging, retry - * policy) over substring matches can compare `err.message` to these - * constants instead of pattern-matching free-form text. - */ -export const InvalidWebhookErrorMessages = { - signatureMismatch: 'signature mismatch', - invalidBase64: 'invalid base64 encoding', - gzipFailed: 'gzip decompression failed', - invalidJson: 'invalid JSON payload', -} as const; - -/** - * Thrown by {@link verifyAndParseWebhook} when the supplied `x-signature` does not - * match the HMAC of the uncompressed payload, and by all webhook helpers (including - * {@link parseSqs} / {@link parseSns}) when a gzip / base64 / JSON envelope is malformed. - * - * The message identifies which failure mode fired. See - * {@link InvalidWebhookErrorMessages} for the canonical strings. - */ -export class InvalidWebhookError extends Error { - public name = 'InvalidWebhookError'; - - constructor(message: string = InvalidWebhookErrorMessages.signatureMismatch) { - super(message); - } -} - -/** - * Returns `body` as a `Buffer`, gzip-decompressed when its first two bytes match the gzip magic - * (`1f 8b`, per RFC 1952). When the body is plain JSON (no compression, or middleware already - * decompressed), the bytes are returned unchanged. - * - * Magic-byte detection (rather than relying on a header) keeps the same handler correct when - * middleware — Express, Next.js, AWS Lambda — auto-decompresses the request before your code sees it. - * - * @param rawBody - The raw HTTP request body, either as a string or a `Buffer`. - * @returns The uncompressed payload bytes. - * @throws {@link InvalidWebhookError} when the gzip envelope is malformed. - */ -export function gunzipPayload(rawBody: string | Buffer): Buffer { - const GZIP_MAGIC = Buffer.from([0x1f, 0x8b]); - - const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody); - if (body.length >= 2 && body.subarray(0, 2).equals(GZIP_MAGIC)) { - try { - return zlib.gunzipSync(body); - } catch { - throw new InvalidWebhookError(InvalidWebhookErrorMessages.gzipFailed); - } - } - return body; -} - -/** - * Reverses the SQS firehose envelope: the message `Body` is base64-decoded, then the result is - * gzip-decompressed when it begins with the gzip magic. Returns the raw JSON `Buffer` Stream signed. - * - * SQS bodies are always base64-encoded so they remain valid UTF-8 over the queue. The same call - * works whether or not Stream is currently compressing payloads for this app. - * - * @param body - The base64-encoded SQS message body. - * @returns The decoded (and decompressed, when gzipped) payload bytes. - * @throws {@link InvalidWebhookError} when the body is not canonical base64 or the gzip envelope is malformed. - */ -export function decodeSqsPayload(body: string): Buffer { - // Reject anything that isn't canonical base64 up front. Node's base64 - // decoder is permissive (silently strips characters outside the - // alphabet, accepts both standard and URL-safe variants), so we have - // to be strict here to avoid silently corrupting the body before the - // signature check runs. - if (!/^[A-Za-z0-9+/]*={0,2}$/.test(body) || body.length % 4 !== 0) { - throw new InvalidWebhookError(InvalidWebhookErrorMessages.invalidBase64); - } - const decoded = Buffer.from(body, 'base64'); - if (decoded.toString('base64').length !== body.length) { - throw new InvalidWebhookError(InvalidWebhookErrorMessages.invalidBase64); - } - return gunzipPayload(decoded); -} - -/** - * Reverses an SNS HTTP notification envelope. When `notificationBody` is a JSON envelope - * (`{"Type":"Notification","MessageRequest":"..."}`), the inner `MessageRequest` field is extracted and run - * through the SQS pipeline (base64-decode, then gzip-if-magic). When the input is not a JSON - * envelope it is treated as the already-extracted `MessageRequest` string, so call sites that pre-unwrap - * continue to work. - * - * @param notificationBody - The raw SNS notification body, or a pre-extracted `MessageRequest` string. - * @returns The decoded (and decompressed, when gzipped) payload bytes. - * @throws {@link InvalidWebhookError} when the body is not canonical base64 or the gzip envelope is malformed. - */ -export function decodeSnsPayload(notificationBody: string): Buffer { - const inner = extractSnsMessage(notificationBody); - return decodeSqsPayload(inner ?? notificationBody); -} - -function extractSnsMessage(notificationBody: string): string | null { - const trimmed = notificationBody.replace(/^[\s\uFEFF]+/, ''); - if (!trimmed.startsWith('{')) { - return null; - } - let parsed: unknown; - try { - parsed = JSON.parse(trimmed); - } catch { - return null; - } - if ( - parsed === null || - typeof parsed !== 'object' || - Array.isArray(parsed) || - typeof (parsed as { MessageRequest?: unknown }).MessageRequest !== 'string' - ) { - return null; - } - return (parsed as { MessageRequest: string }).MessageRequest; -} - -/** - * Parses a JSON-encoded webhook event into a typed {@link WSEvent}. New event types Stream - * introduces still parse successfully — the runtime shape is the JSON Stream sent and the `type` - * field stays preserved. - * - * @param payload - The raw event payload bytes or string. - * @returns The parsed WebSocket event. - * @throws {@link InvalidWebhookError} when the payload is not valid JSON. - */ -export function parseEvent(payload: Buffer | string): WSEvent { - const text = Buffer.isBuffer(payload) ? payload.toString('utf8') : payload; - try { - return JSON.parse(text) as WSEvent; - } catch { - throw new InvalidWebhookError(InvalidWebhookErrorMessages.invalidJson); - } -} - -function verifyAndParse(payload: Buffer, signature: string, secret: string): WSEvent { - if (!verifySignature(payload, signature, secret)) { - throw new InvalidWebhookError(InvalidWebhookErrorMessages.signatureMismatch); - } - return parseEvent(payload); -} - -/** - * Decompress (when gzipped), verify the HMAC `signature`, and return the parsed {@link WSEvent}. - * - * @param rawBody - Raw HTTP request body bytes Stream signed. - * @param signature - Value of the `X-Signature` header. - * @param secret - Your app's API secret. - * @returns The parsed WebSocket event. - * @throws {@link InvalidWebhookError} when the signature does not match or the gzip envelope is malformed. - */ -export function verifyAndParseWebhook( - rawBody: string | Buffer, - signature: string, - secret: string, -): WSEvent { - return verifyAndParse(gunzipPayload(rawBody), signature, secret); -} - -/** - * Decodes the SQS message `Body` (base64, then gzip-if-magic) and returns the parsed {@link WSEvent}. - * Stream does not attach an application-level HMAC to SQS deliveries — use - * {@link verifyAndParseWebhook} for HTTP webhooks. - * - * @param messageBody - The base64-encoded SQS message body. - * @returns The parsed WebSocket event. - * @throws {@link InvalidWebhookError} when the body is malformed. - */ -export function parseSqs(messageBody: string): WSEvent { - return parseEvent(decodeSqsPayload(messageBody)); -} - -/** - * Decodes an SNS notification (unwraps the JSON envelope when needed; same inner format as SQS). - * No application-level HMAC verification. - * - * @param notificationBody - The raw SNS notification body, or a pre-extracted `MessageRequest` string. - * @returns The parsed WebSocket event. - * @throws {@link InvalidWebhookError} when the body is malformed. - */ -export function parseSns(notificationBody: string): WSEvent { - return parseEvent(decodeSnsPayload(notificationBody)); -} diff --git a/src/token_manager.ts b/src/token_manager.ts index 9af2fb74e7..3d1e4e5638 100644 --- a/src/token_manager.ts +++ b/src/token_manager.ts @@ -1,7 +1,5 @@ -import type jwt from 'jsonwebtoken'; - import { chatLoggerSystem } from './logger'; -import { JWTServerToken, JWTUserToken, UserFromToken } from './signing'; +import { UserFromToken } from './signing'; import { isFunction } from './utils'; import type { TokenOrProvider } from './types'; @@ -16,27 +14,16 @@ export type TokenManagerMinimalUser = { id: string; anon?: boolean }; export class TokenManager { loadTokenPromise: Promise | null; type: 'static' | 'provider'; - secret?: jwt.Secret; token?: string; tokenProvider?: TokenOrProvider; user?: TokenManagerMinimalUser; /** - * Initializes the token manager, optionally with a server-side API secret used to mint tokens - * locally. - * - * @param secret - Optional API secret. When provided, the manager will sign server tokens locally. + * Initializes the token manager. */ - constructor(secret?: jwt.Secret) { + constructor() { this.loadTokenPromise = null; - if (secret) { - this.secret = secret; - } this.type = 'static'; - - if (this.secret) { - this.token = JWTServerToken(this.secret); - } } /** @@ -63,11 +50,6 @@ export class TokenManager { this.type = 'static'; } - if (!tokenOrProvider && this.user && this.secret) { - this.token = JWTUserToken(this.secret, user.id, {}, {}); - this.type = 'static'; - } - await this.loadToken(); }; @@ -88,11 +70,6 @@ export class TokenManager { // allow empty token for anon user if (user && user.anon && !tokenOrProvider) return; - // Don't allow empty token for non-server side client. - if (!this.secret && !tokenOrProvider) { - throw new Error('User token can not be empty'); - } - if ( tokenOrProvider && typeof tokenOrProvider !== 'string' && @@ -158,10 +135,6 @@ export class TokenManager { return this.token; } - if (this.secret) { - return JWTServerToken(this.secret); - } - throw new Error( `Both secret and user tokens are not set. Either client.connectUser wasn't called or client.disconnect was called`, ); diff --git a/src/types.ts b/src/types.ts index 2dedec94b2..cff6228f6f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -417,6 +417,12 @@ export type StreamChatOptions = { * should not be used in production apps. */ wsConnection?: StableWSConnection; + /** + * Overrides the `WebSocket` constructor used by `StableWSConnection`. Intended purely for + * testing so a mock/drivable WebSocket can be swapped in; production code should leave this + * unset and rely on the platform's global `WebSocket`. + */ + WebSocketImpl?: typeof WebSocket; /** * Sets a suffix to the wsUrl when it is being built in `wsConnection`. Is meant to be * used purely in testing suites and should not be used in production apps. diff --git a/test/unit/client.construction.test.ts b/test/unit/client.construction.test.ts index 5ef9d55da6..d531f55921 100644 --- a/test/unit/client.construction.test.ts +++ b/test/unit/client.construction.test.ts @@ -1,7 +1,6 @@ import axios from 'axios'; -import https from 'https'; import sinon from 'sinon'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ClientState } from '../../src/client_state'; import { FixedSizeQueueCache } from '../../src/utils/FixedSizeQueueCache'; @@ -221,15 +220,13 @@ describe('StreamChat construction', () => { }); describe('httpsAgent', () => { - it('auto-creates a keep-alive https.Agent in node mode', () => { + it('does not auto-create an httpsAgent in node mode', () => { const client = new StreamChat(API_KEY, { browser: false }); - const httpsAgent = client.axiosInstance.defaults.httpsAgent as https.Agent; - expect(httpsAgent).to.be.instanceOf(https.Agent); - expect(httpsAgent.keepAlive).to.equal(true); + expect(client.axiosInstance.defaults.httpsAgent).to.be.undefined; }); - it('lets axiosRequestConfig.httpsAgent override the auto-created agent', () => { - const customAgent = new https.Agent({ keepAlive: false }); + it('honors an axiosRequestConfig.httpsAgent supplied by the caller', () => { + const customAgent = vi.fn(); const client = new StreamChat(API_KEY, { browser: false, axiosRequestConfig: { httpsAgent: customAgent }, diff --git a/test/unit/connection.test.js b/test/unit/connection.test.js index 6c5923ed2d..19ba4a8f7f 100644 --- a/test/unit/connection.test.js +++ b/test/unit/connection.test.js @@ -1,6 +1,5 @@ import sinon from 'sinon'; import url from 'url'; -import { Server as WsServer } from 'isomorphic-ws'; import { StableWSConnection } from '../../src/connection'; import { StreamChat } from '../../src/client'; @@ -8,14 +7,92 @@ import { TokenManager } from '../../src/token_manager'; import { sleep } from '../../src/utils'; import { InsightMetrics } from '../../src/insights'; -import { describe, expect, it, afterAll } from 'vitest'; +import { describe, expect, it } from 'vitest'; + +// A test-only WebSocket that immediately opens and pushes the canned +// `health.check` frame the real Stream backend sends on the first message. +// Used with `client.options.WebSocketImpl` so tests never touch the network. +const HEALTH_CHECK_PAYLOAD = + '{"type":"health.check","connection_id":"61112366-0a15-3891-0000-000000000009","cid":"*","me":{"id":"amin","role":"user","created_at":"2021-07-27T13:18:23.293696Z","updated_at":"2021-07-27T13:20:08.047284Z","last_active":"2021-08-11T10:42:44.213510048Z","banned":false,"online":true,"invisible":false,"devices":[],"mutes":[],"channel_mutes":[],"unread_count":98,"total_unread_count":98,"unread_channels":18,"language":"","image":"https://cdn.fakercloud.com/avatars/Shriiiiimp_128.jpg","name":"amin"},"created_at":"2021-08-11T10:42:44.222203145Z"}'; + +class MockWebSocket { + static CONNECTING = 0; + static OPEN = 1; + static CLOSING = 2; + static CLOSED = 3; + + CONNECTING = 0; + OPEN = 1; + CLOSING = 2; + CLOSED = 3; + + onopen = null; + onclose = null; + onerror = null; + onmessage = null; + + constructor(url) { + this.url = url; + this.readyState = MockWebSocket.CONNECTING; + + queueMicrotask(() => { + this.readyState = MockWebSocket.OPEN; + this.onopen?.({ type: 'open' }); + this.onmessage?.({ data: HEALTH_CHECK_PAYLOAD }); + }); + } + + send() {} + + close() { + this.readyState = MockWebSocket.CLOSED; + this.onclose?.({ code: 1000, reason: '', wasClean: true }); + } +} + +// A test-only WebSocket that fails to connect — mirrors a real WebSocket +// against an unreachable host (fires an error, then a code 1006 close). +class FailingWebSocket { + static CONNECTING = 0; + static OPEN = 1; + static CLOSING = 2; + static CLOSED = 3; + + CONNECTING = 0; + OPEN = 1; + CLOSING = 2; + CLOSED = 3; + + onopen = null; + onclose = null; + onerror = null; + onmessage = null; + + constructor(url) { + this.url = url; + this.readyState = FailingWebSocket.CONNECTING; + + queueMicrotask(() => { + this.readyState = FailingWebSocket.CLOSED; + this.onerror?.({ type: 'error' }); + this.onclose?.({ code: 1006, reason: '', wasClean: false }); + }); + } + + send() {} + + close() { + this.readyState = FailingWebSocket.CLOSED; + } +} describe('connection', function () { - const wsBaseURL = 'http://localhost:9999'; - const tokenManager = new TokenManager('secret'); + const wsBaseURL = 'ws://localhost:9999'; + const tokenManager = new TokenManager(); + tokenManager.token = 't.oke.n'; const user = { name: 'amin', id: 'amin' }; const newStreamChat = () => { - const client = new StreamChat('key'); + const client = new StreamChat('key', { WebSocketImpl: MockWebSocket }); client.wsBaseURL = wsBaseURL; client.tokenManager = tokenManager; client._user = user; @@ -28,20 +105,11 @@ describe('connection', function () { return client; }; - // dummy server to use instead of actual Stream API - const wss = new WsServer({ port: 9999 }); - wss.on('connection', (ws) => - ws.send( - '{"type":"health.check","connection_id":"61112366-0a15-3891-0000-000000000009","cid":"*","me":{"id":"amin","role":"user","created_at":"2021-07-27T13:18:23.293696Z","updated_at":"2021-07-27T13:20:08.047284Z","last_active":"2021-08-11T10:42:44.213510048Z","banned":false,"online":true,"invisible":false,"devices":[],"mutes":[],"channel_mutes":[],"unread_count":98,"total_unread_count":98,"unread_channels":18,"language":"","image":"https://cdn.fakercloud.com/avatars/Shriiiiimp_128.jpg","name":"amin"},"created_at":"2021-08-11T10:42:44.222203145Z"}', - ), - ); - - afterAll(() => wss.close()); - describe('Connection tokenProvider', () => { it('should handle token provider rejection ', async () => { const client = new StreamChat('apiKey', { allowServerSideConnect: true, + WebSocketImpl: MockWebSocket, }); client.defaultWSTimeout = 20; const tokenProvider = () => Promise.reject(new Error('network failure')); @@ -110,7 +178,7 @@ describe('connection', function () { it('should set isResolved', async () => { const c = new StableWSConnection({ client: newStreamChat() }); expect(c.isResolved).to.be.false; - const res = await c.connect(); + await c.connect(); expect(c.isResolved).to.be.true; }); @@ -180,6 +248,7 @@ describe('connection', function () { it('should set and unset the flag correctly without opening WS', async () => { const client = newStreamChat(); + client.options.WebSocketImpl = FailingWebSocket; client.wsBaseURL = 'https://stream-dummy-test.com'; const c = new StableWSConnection({ client }); @@ -214,6 +283,7 @@ describe('connection', function () { const client = new StreamChat('apiKey', { allowServerSideConnect: true, baseURL: 'http://localhost:1111', // invalid base url + WebSocketImpl: FailingWebSocket, }); client.defaultWSTimeout = 2000; @@ -225,7 +295,8 @@ describe('connection', function () { it('should retry until connection is established', async function () { const client = new StreamChat('apiKey', { allowServerSideConnect: true, - baseURL: 'http://localhost:1111', // invalid base url + baseURL: 'http://localhost:1111', + WebSocketImpl: FailingWebSocket, }); client.defaultWSTimeout = 5000; @@ -234,7 +305,8 @@ describe('connection', function () { expect(health.type).to.be.equal('health.check'); }), sleep(1000).then(() => { - // set the correct url after connectUser failed and is trying to connect + // swap in the healthy mock so the retrying connect will succeed + client.options.WebSocketImpl = MockWebSocket; client.setBaseURL(wsBaseURL); client.wsConnection.wsBaseURL = client.wsBaseURL; }), diff --git a/test/unit/signing.test.js b/test/unit/signing.test.js index 17300579a5..10780f5c38 100644 --- a/test/unit/signing.test.js +++ b/test/unit/signing.test.js @@ -1,30 +1,25 @@ -import { CheckSignature } from '../../src'; +import { UserFromToken } from '../../src'; -import { describe, it, expect } from 'vitest'; - -const MOCK_SECRET = 'porewqKAFDSAKZssecretsercretfads'; -const MOCK_TEXT = 'text'; -const MOCK_JSON_BODY = { a: 1 }; -const MOCK_TEXT_SHA256 = - 'd0b770e93a56adc3ee9ac5734533cc0acd71eea8e5e8204a28042ca0f60de1f3'; -const MOCK_JSON_SHA256 = - 'e527a6ad4993a4c9a30680c8be4b3eda1c36ab104f1f7d39c744bd27016a9624'; +import { describe, expect, it } from 'vitest'; describe('Signing', () => { - describe('CheckSignature', () => { - it('validates correct text body and signature', () => { - const rawBody = Buffer.from(MOCK_TEXT); - expect(CheckSignature(rawBody, MOCK_SECRET, MOCK_TEXT_SHA256)).to.be.true; + describe('UserFromToken', () => { + it('extracts the user_id from a valid JWT payload', () => { + // payload: {"user_id":"amin"} + const token = '_.eyJ1c2VyX2lkIjoiYW1pbiJ9._'; + expect(UserFromToken(token)).to.equal('amin'); }); - it('validates correct json body and signature', () => { - const rawBody = Buffer.from(JSON.stringify(MOCK_JSON_BODY)); - expect(CheckSignature(rawBody, MOCK_SECRET, MOCK_JSON_SHA256)).to.be.true; + it('returns an empty string for a token that is not three fragments', () => { + expect(UserFromToken('not-a-token')).to.equal(''); + expect(UserFromToken('only.two')).to.equal(''); + expect(UserFromToken('too.many.fragments.here')).to.equal(''); }); - it('refutes incorrect json body', () => { - const rawBody = Buffer.from(JSON.stringify({ ...MOCK_JSON_BODY, b: 2 })); - expect(CheckSignature(rawBody, MOCK_SECRET, MOCK_JSON_SHA256)).to.be.false; + it('returns undefined when the payload has no user_id', () => { + // payload: {"foo":"bar"} + const token = 'eyJhbGciOiJIUzI1NiJ9.eyJmb28iOiJiYXIifQ.sig'; + expect(UserFromToken(token)).to.be.undefined; }); }); }); diff --git a/yarn.lock b/yarn.lock index 67774b5e7a..7a81c1fe68 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1563,24 +1563,7 @@ __metadata: languageName: node linkType: hard -"@types/jsonwebtoken@npm:^9.0.8": - version: 9.0.8 - resolution: "@types/jsonwebtoken@npm:9.0.8" - dependencies: - "@types/ms": "npm:*" - "@types/node": "npm:*" - checksum: 10c0/dd3ccea05115ad9c4458d0c2a487179b1abf8886eaa8cc77d64031a8ef47abd3ff038cdde26e76893372238c8612e4f5fd578b3400e525b65c0e8d3abefe5282 - languageName: node - linkType: hard - -"@types/ms@npm:*": - version: 2.1.0 - resolution: "@types/ms@npm:2.1.0" - checksum: 10c0/5ce692ffe1549e1b827d99ef8ff71187457e0eb44adbae38fdf7b9a74bae8d20642ee963c14516db1d35fa2652e65f47680fdf679dcbde52bbfadd021f497225 - languageName: node - linkType: hard - -"@types/node@npm:*, @types/node@npm:^22.19.19": +"@types/node@npm:^22.19.19": version: 22.19.19 resolution: "@types/node@npm:22.19.19" dependencies: @@ -1605,15 +1588,6 @@ __metadata: languageName: node linkType: hard -"@types/ws@npm:^8.18.1": - version: 8.18.1 - resolution: "@types/ws@npm:8.18.1" - dependencies: - "@types/node": "npm:*" - checksum: 10c0/61aff1129143fcc4312f083bc9e9e168aa3026b7dd6e70796276dcfb2c8211c4292603f9c4864fae702f2ed86e4abd4d38aa421831c2fd7f856c931a481afbab - languageName: node - linkType: hard - "@typescript-eslint/eslint-plugin@npm:8.59.4": version: 8.59.4 resolution: "@typescript-eslint/eslint-plugin@npm:8.59.4" @@ -2200,13 +2174,6 @@ __metadata: languageName: node linkType: hard -"base64-js@npm:^1.5.1": - version: 1.5.1 - resolution: "base64-js@npm:1.5.1" - checksum: 10c0/f23823513b63173a001030fae4f2dabe283b99a9d324ade3ad3d148e218134676f1ee8568c877cd79ec1c53158dcf2d2ba527a97c606618928ba99dd930102bf - languageName: node - linkType: hard - "before-after-hook@npm:^4.0.0": version: 4.0.0 resolution: "before-after-hook@npm:4.0.0" @@ -2278,13 +2245,6 @@ __metadata: languageName: node linkType: hard -"buffer-equal-constant-time@npm:^1.0.1": - version: 1.0.1 - resolution: "buffer-equal-constant-time@npm:1.0.1" - checksum: 10c0/fb2294e64d23c573d0dd1f1e7a466c3e978fe94a4e0f8183937912ca374619773bef8e2aceb854129d2efecbbc515bbd0cc78d2734a3e3031edb0888531bbc8e - languageName: node - linkType: hard - "cacache@npm:^20.0.0, cacache@npm:^20.0.1, cacache@npm:^20.0.3": version: 20.0.3 resolution: "cacache@npm:20.0.3" @@ -2924,15 +2884,6 @@ __metadata: languageName: node linkType: hard -"ecdsa-sig-formatter@npm:1.0.11": - version: 1.0.11 - resolution: "ecdsa-sig-formatter@npm:1.0.11" - dependencies: - safe-buffer: "npm:^5.0.1" - checksum: 10c0/ebfbf19d4b8be938f4dd4a83b8788385da353d63307ede301a9252f9f7f88672e76f2191618fd8edfc2f24679236064176fab0b78131b161ee73daa37125408c - languageName: node - linkType: hard - "emoji-regex@npm:^10.3.0": version: 10.3.0 resolution: "emoji-regex@npm:10.3.0" @@ -4722,15 +4673,6 @@ __metadata: languageName: node linkType: hard -"isomorphic-ws@npm:^5.0.0": - version: 5.0.0 - resolution: "isomorphic-ws@npm:5.0.0" - peerDependencies: - ws: "*" - checksum: 10c0/a058ac8b5e6efe9e46252cb0bc67fd325005d7216451d1a51238bc62d7da8486f828ef017df54ddf742e0fffcbe4b1bcc2a66cc115b027ed0180334cd18df252 - languageName: node - linkType: hard - "issue-parser@npm:^7.0.0": version: 7.0.1 resolution: "issue-parser@npm:7.0.1" @@ -4914,24 +4856,6 @@ __metadata: languageName: node linkType: hard -"jsonwebtoken@npm:^9.0.3": - version: 9.0.3 - resolution: "jsonwebtoken@npm:9.0.3" - dependencies: - jws: "npm:^4.0.1" - lodash.includes: "npm:^4.3.0" - lodash.isboolean: "npm:^3.0.3" - lodash.isinteger: "npm:^4.0.4" - lodash.isnumber: "npm:^3.0.3" - lodash.isplainobject: "npm:^4.0.6" - lodash.isstring: "npm:^4.0.1" - lodash.once: "npm:^4.0.0" - ms: "npm:^2.1.1" - semver: "npm:^7.5.4" - checksum: 10c0/6ca7f1e54886ea3bde7146a5a22b53847c46e25453c7f7307a69818b9a6ad48c390b2e59d5690fcfd03c529b01960060cc4bb0c686991d6edae2285dfd30f4ba - languageName: node - linkType: hard - "just-diff-apply@npm:^5.2.0": version: 5.5.0 resolution: "just-diff-apply@npm:5.5.0" @@ -4953,27 +4877,6 @@ __metadata: languageName: node linkType: hard -"jwa@npm:^2.0.1": - version: 2.0.1 - resolution: "jwa@npm:2.0.1" - dependencies: - buffer-equal-constant-time: "npm:^1.0.1" - ecdsa-sig-formatter: "npm:1.0.11" - safe-buffer: "npm:^5.0.1" - checksum: 10c0/ab3ebc6598e10dc11419d4ed675c9ca714a387481466b10e8a6f3f65d8d9c9237e2826f2505280a739cf4cbcf511cb288eeec22b5c9c63286fc5a2e4f97e78cf - languageName: node - linkType: hard - -"jws@npm:^4.0.1": - version: 4.0.1 - resolution: "jws@npm:4.0.1" - dependencies: - jwa: "npm:^2.0.1" - safe-buffer: "npm:^5.0.1" - checksum: 10c0/6be1ed93023aef570ccc5ea8d162b065840f3ef12f0d1bb3114cade844de7a357d5dc558201d9a65101e70885a6fa56b17462f520e6b0d426195510618a154d0 - languageName: node - linkType: hard - "keyv@npm:^4.5.4": version: 4.5.4 resolution: "keyv@npm:4.5.4" @@ -5342,34 +5245,6 @@ __metadata: languageName: node linkType: hard -"lodash.includes@npm:^4.3.0": - version: 4.3.0 - resolution: "lodash.includes@npm:4.3.0" - checksum: 10c0/7ca498b9b75bf602d04e48c0adb842dfc7d90f77bcb2a91a2b2be34a723ad24bc1c8b3683ec6b2552a90f216c723cdea530ddb11a3320e08fa38265703978f4b - languageName: node - linkType: hard - -"lodash.isboolean@npm:^3.0.3": - version: 3.0.3 - resolution: "lodash.isboolean@npm:3.0.3" - checksum: 10c0/0aac604c1ef7e72f9a6b798e5b676606042401dd58e49f051df3cc1e3adb497b3d7695635a5cbec4ae5f66456b951fdabe7d6b387055f13267cde521f10ec7f7 - languageName: node - linkType: hard - -"lodash.isinteger@npm:^4.0.4": - version: 4.0.4 - resolution: "lodash.isinteger@npm:4.0.4" - checksum: 10c0/4c3e023a2373bf65bf366d3b8605b97ec830bca702a926939bcaa53f8e02789b6a176e7f166b082f9365bfec4121bfeb52e86e9040cb8d450e64c858583f61b7 - languageName: node - linkType: hard - -"lodash.isnumber@npm:^3.0.3": - version: 3.0.3 - resolution: "lodash.isnumber@npm:3.0.3" - checksum: 10c0/2d01530513a1ee4f72dd79528444db4e6360588adcb0e2ff663db2b3f642d4bb3d687051ae1115751ca9082db4fdef675160071226ca6bbf5f0c123dbf0aa12d - languageName: node - linkType: hard - "lodash.isplainobject@npm:^4.0.6": version: 4.0.6 resolution: "lodash.isplainobject@npm:4.0.6" @@ -5391,13 +5266,6 @@ __metadata: languageName: node linkType: hard -"lodash.once@npm:^4.0.0": - version: 4.1.1 - resolution: "lodash.once@npm:4.1.1" - checksum: 10c0/46a9a0a66c45dd812fcc016e46605d85ad599fe87d71a02f6736220554b52ffbe82e79a483ad40f52a8a95755b0d1077fba259da8bfb6694a7abbf4a48f1fc04 - languageName: node - linkType: hard - "lodash.uniqby@npm:^4.7.0": version: 4.7.0 resolution: "lodash.uniqby@npm:4.7.0" @@ -6947,13 +6815,6 @@ __metadata: languageName: node linkType: hard -"safe-buffer@npm:^5.0.1": - version: 5.2.1 - resolution: "safe-buffer@npm:5.2.1" - checksum: 10c0/6501914237c0a86e9675d4e51d89ca3c21ffd6a31642efeba25ad65720bce6921c9e7e974e5be91a786b25aa058b5303285d3c15dbabf983a919f5f630d349f3 - languageName: node - linkType: hard - "safe-buffer@npm:~5.1.0, safe-buffer@npm:~5.1.1": version: 5.1.2 resolution: "safe-buffer@npm:5.1.2" @@ -7043,7 +6904,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:^7.1.1, semver@npm:^7.1.2, semver@npm:^7.3.2, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.5.2, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.7.2, semver@npm:^7.7.3": +"semver@npm:^7.1.1, semver@npm:^7.1.2, semver@npm:^7.3.2, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.5.2, semver@npm:^7.5.3, semver@npm:^7.6.0, semver@npm:^7.7.2, semver@npm:^7.7.3": version: 7.7.3 resolution: "semver@npm:7.7.3" bin: @@ -7410,13 +7271,10 @@ __metadata: "@semantic-release/changelog": "npm:^6.0.3" "@semantic-release/git": "npm:^10.0.1" "@stream-io/logger": "npm:^2.0.0" - "@types/jsonwebtoken": "npm:^9.0.8" "@types/node": "npm:^22.19.19" "@types/sinon": "npm:^10.0.6" - "@types/ws": "npm:^8.18.1" "@vitest/coverage-v8": "npm:^4.1.7" axios: "npm:^1.16.1" - base64-js: "npm:^1.5.1" concurrently: "npm:^9.2.1" conventional-changelog-conventionalcommits: "npm:^9.3.1" dotenv: "npm:^17.4.2" @@ -7425,11 +7283,8 @@ __metadata: eslint-plugin-import: "npm:^2.32.0" eslint-plugin-jsdoc: "npm:^63.0.7" eslint-plugin-unused-imports: "npm:^4.4.1" - form-data: "npm:^4.0.5" globals: "npm:^17.6.0" husky: "npm:^9.1.7" - isomorphic-ws: "npm:^5.0.0" - jsonwebtoken: "npm:^9.0.3" linkifyjs: "npm:^4.3.3" lint-staged: "npm:^17.0.5" prettier: "npm:^3.8.3" @@ -7438,7 +7293,6 @@ __metadata: typescript: "npm:^6.0.3" typescript-eslint: "npm:^8.59.4" vitest: "npm:^4.1.7" - ws: "npm:^8.20.1" dependenciesMeta: esbuild: built: true @@ -8444,21 +8298,6 @@ __metadata: languageName: node linkType: hard -"ws@npm:^8.20.1": - version: 8.20.1 - resolution: "ws@npm:8.20.1" - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: ">=5.0.2" - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - checksum: 10c0/ce162433218399cdedeb76fd33363d4d86a7d910058d4e3c679dce08cea65d6da6b39f11baa4d7808d024cf46ed88f6a05c17611621aaad8fc5e62edacc30c5d - languageName: node - linkType: hard - "xtend@npm:~4.0.1": version: 4.0.2 resolution: "xtend@npm:4.0.2" From 138ddc9a45114caffcf15698425d625002e81557 Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Tue, 11 Aug 2026 18:17:50 +0200 Subject: [PATCH 2/5] form-data package cleanup --- package.json | 2 +- src/api-client.ts | 6 +-- src/channel.ts | 10 +--- src/client.ts | 12 ++--- src/messageComposer/attachmentManager.ts | 3 +- src/utils.ts | 44 ++-------------- .../MessageComposer/attachmentManager.test.ts | 52 ++++++------------- tsconfig.json | 3 +- yarn.lock | 10 ++-- 9 files changed, 37 insertions(+), 105 deletions(-) diff --git a/package.json b/package.json index f1e9582606..8a76914b76 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "@eslint/js": "^9.39.4", "@semantic-release/changelog": "^6.0.3", "@semantic-release/git": "^10.0.1", - "@types/node": "^22.19.19", + "@types/node": "^22", "@types/sinon": "^10.0.6", "@vitest/coverage-v8": "^4.1.7", "concurrently": "^9.2.1", diff --git a/src/api-client.ts b/src/api-client.ts index 3cb41fdc9a..528fd03324 100644 --- a/src/api-client.ts +++ b/src/api-client.ts @@ -82,17 +82,15 @@ export class ApiClient { sendFile( url: string, - uri: string | NodeJS.ReadableStream | Buffer | File, + uri: string | File, name?: string, - contentType?: string, user?: UserResponse, axiosRequestConfig?: AxiosRequestConfig, ) { - const data = addFileToFormData(uri, name, contentType || 'multipart/form-data'); + const data = addFileToFormData(uri, name); if (user != null) data.append('user', JSON.stringify(user)); return this._doRequest('post', url, data, { - headers: data.getHeaders ? data.getHeaders() : {}, timeout: 0, maxContentLength: Infinity, maxBodyLength: Infinity, diff --git a/src/channel.ts b/src/channel.ts index 0a023e8ecb..dfe30f32f0 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -497,15 +497,13 @@ export class Channel extends ChannelApi { * * @param uri - File source: URL string, `File`, `Buffer`, or readable stream (Node). * @param name - File name sent in the multipart body (optional). - * @param contentType - MIME type; defaults are applied when omitted (optional). * @param user - User payload appended to the form as JSON (optional). * @param axiosRequestConfig - Axios per-request config, merged after upload defaults, e.g. `onUploadProgress`, `signal` from `AbortController` (optional). * @returns A promise resolving to `{ file: string, ... }` with the CDN URL. */ sendFile( - uri: string | NodeJS.ReadableStream | Buffer | File, + uri: string | File, name?: string, - contentType?: string, user?: UserResponse, axiosRequestConfig?: AxiosRequestConfig, ) { @@ -513,7 +511,6 @@ export class Channel extends ChannelApi { `${this._channelURL()}/file`, uri, name, - contentType, user, axiosRequestConfig, ); @@ -524,15 +521,13 @@ export class Channel extends ChannelApi { * * @param uri - Image source: URL string, `File`, or readable stream (Node). For `Buffer` uploads, use `sendFile` toward the channel file endpoint instead. * @param name - File name sent in the multipart body (optional). - * @param contentType - MIME type (optional). * @param user - User payload appended to the form as JSON (optional). * @param axiosRequestConfig - Axios per-request config, merged after upload defaults, e.g. `onUploadProgress`, `signal` (optional). * @returns A promise resolving to `{ file: string, ... }` with the CDN URL. */ sendImage( - uri: string | NodeJS.ReadableStream | File, + uri: string | File, name?: string, - contentType?: string, user?: UserResponse, axiosRequestConfig?: AxiosRequestConfig, ) { @@ -540,7 +535,6 @@ export class Channel extends ChannelApi { `${this._channelURL()}/image`, uri, name, - contentType, user, axiosRequestConfig, ); diff --git a/src/client.ts b/src/client.ts index e00be4ae2d..b31c25013b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -158,7 +158,7 @@ export class StreamChat extends ChatApi { axiosInstance: AxiosInstance; baseURL?: string; browser: boolean; - cleaningIntervalRef?: NodeJS.Timeout; + cleaningIntervalRef?: ReturnType; clientId?: string; key: string; listeners: Map>; @@ -2279,15 +2279,13 @@ export class StreamChat extends ChatApi { * * @param uri - The file to upload. * @param name - The name of the file (optional). - * @param contentType - The content type of the file (optional). * @param user - User information (optional). * @param axiosRequestConfig - Axios config, e.g. `onUploadProgress` for progress tracking (optional). * @returns Response containing the file URL. */ uploadFile_( - uri: string | NodeJS.ReadableStream | Buffer | File, + uri: string | File, name?: string, - contentType?: string, user?: UserResponse, axiosRequestConfig?: AxiosRequestConfig, ) { @@ -2295,7 +2293,6 @@ export class StreamChat extends ChatApi { `${this.baseURL}/uploads/file`, uri, name, - contentType, user, axiosRequestConfig, ); @@ -2306,15 +2303,13 @@ export class StreamChat extends ChatApi { * * @param uri - The image to upload. * @param name - The name of the image (optional). - * @param contentType - The content type of the image (optional). * @param user - User information (optional). * @param axiosRequestConfig - Axios config, e.g. `onUploadProgress` for progress tracking (optional). * @returns Response containing the image URL. */ uploadImage_( - uri: string | NodeJS.ReadableStream | File, + uri: string | File, name?: string, - contentType?: string, user?: UserResponse, axiosRequestConfig?: AxiosRequestConfig, ) { @@ -2322,7 +2317,6 @@ export class StreamChat extends ChatApi { `${this.baseURL}/uploads/image`, uri, name, - contentType, user, axiosRequestConfig, ); diff --git a/src/messageComposer/attachmentManager.ts b/src/messageComposer/attachmentManager.ts index 42c07dc835..86a9912470 100644 --- a/src/messageComposer/attachmentManager.ts +++ b/src/messageComposer/attachmentManager.ts @@ -551,7 +551,6 @@ export class AttachmentManager { return this.channel[isImageFile(fileLike) ? 'sendImage' : 'sendFile']( fileLike.uri, fileLike.name, - fileLike.type, undefined, axiosUploadConfig, ); @@ -567,7 +566,7 @@ export class AttachmentManager { const { duration: _duration, ...result } = await this.channel[ isImageFile(fileLike) ? 'sendImage' : 'sendFile' - ](file, undefined, undefined, undefined, axiosUploadConfig); + ](file, undefined, undefined, axiosUploadConfig); return result; }; diff --git a/src/utils.ts b/src/utils.ts index 48ef780c4e..2804cc0cb5 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,3 @@ -import FormData from 'form-data'; import type { LocalMessage, MessageRequest, @@ -48,30 +47,6 @@ export const chatCodes = { WS_CLOSED_SUCCESS: 1000, }; -function isReadableStream(obj: unknown): obj is NodeJS.ReadStream { - return ( - obj !== null && - typeof obj === 'object' && - ((obj as NodeJS.ReadStream).readable || - typeof (obj as NodeJS.ReadStream)._read === 'function') - ); -} - -function isBuffer(obj: unknown): obj is Buffer { - return ( - obj != null && - (obj as Buffer).constructor != null && - // @ts-expect-error expected - typeof obj.constructor.isBuffer === 'function' && - // @ts-expect-error expected - obj.constructor.isBuffer(obj) - ); -} - -function isFileWebAPI(uri: unknown): uri is File { - return typeof window !== 'undefined' && 'File' in window && uri instanceof File; -} - export function isOwnUser( user?: OwnUserResponse | UserResponse, ): user is OwnUserResponse { @@ -125,23 +100,14 @@ export const channelTracksReadLocally = (channel?: Channel) => export const userHasReadReceipts = (client: StreamChat) => client.user?.privacy_settings?.read_receipts?.enabled ?? true; -export function addFileToFormData( - uri: string | NodeJS.ReadableStream | Buffer | File, - name?: string, - contentType?: string, -) { +export function addFileToFormData(uri: string | Blob, name?: string) { const data = new FormData(); - if (isReadableStream(uri) || isBuffer(uri) || isFileWebAPI(uri) || isBlobWebAPI(uri)) { + if (isBlobWebAPI(uri)) { if (name) data.append('file', uri, name); else data.append('file', uri); } else { - data.append('file', { - uri, - name: name || (uri as string).split('/').reverse()[0], - contentType: contentType || undefined, - type: contentType || undefined, - }); + data.append('file', uri); } return data; @@ -709,7 +675,7 @@ export const debounce = any>( timeout = 0, { leading = false, trailing = true }: { leading?: boolean; trailing?: boolean } = {}, ): DebouncedFunc => { - let runningTimeout: null | NodeJS.Timeout = null; + let runningTimeout: null | ReturnType = null; let argsForTrailingExecution: Parameters | null = null; let lastResult: ReturnType | undefined; @@ -835,7 +801,7 @@ export const isBlockedMessage = (message: LocalMessage) => export const isBouncedMessage = (message: LocalMessage) => message.type === 'error' && message?.moderation?.action === 'bounce'; -export const getEnv = (envKey: keyof NodeJS.ProcessEnv) => { +export const getEnv = (envKey: string) => { if ( typeof process !== 'undefined' && (Object.hasOwn(process, 'env') || 'env' in process) diff --git a/test/unit/MessageComposer/attachmentManager.test.ts b/test/unit/MessageComposer/attachmentManager.test.ts index 60ee8d9ee1..bdd6b121d0 100644 --- a/test/unit/MessageComposer/attachmentManager.test.ts +++ b/test/unit/MessageComposer/attachmentManager.test.ts @@ -1493,13 +1493,9 @@ describe('AttachmentManager', () => { await attachmentManager.fileToLocalUploadAttachment(file), ); - expect(mockChannel.sendImage).toHaveBeenCalledWith( - file, - undefined, - undefined, - undefined, - { signal: expect.any(AbortSignal) }, - ); + expect(mockChannel.sendImage).toHaveBeenCalledWith(file, undefined, undefined, { + signal: expect.any(AbortSignal), + }); }); it('when false, omits uploadProgress on attachment while upload is in flight', async () => { @@ -1615,7 +1611,7 @@ describe('AttachmentManager', () => { expect(mockChannel.sendImage).toHaveBeenCalled(); }); - const axiosOpts = mockChannel.sendImage.mock.calls[0][4] as { + const axiosOpts = mockChannel.sendImage.mock.calls[0][3] as { signal?: AbortSignal; }; expect(axiosOpts?.signal).toBeInstanceOf(AbortSignal); @@ -1716,13 +1712,9 @@ describe('AttachmentManager', () => { expect(mockChannel.sendImage).toHaveBeenCalledTimes(1); expect(mockChannel.sendFile).not.toHaveBeenCalled(); - expect(mockChannel.sendImage).toHaveBeenCalledWith( - file, - undefined, - undefined, - undefined, - { onUploadProgress: expect.any(Function) }, - ); + expect(mockChannel.sendImage).toHaveBeenCalledWith(file, undefined, undefined, { + onUploadProgress: expect.any(Function), + }); }); it('passes onUploadProgress to sendFile when onProgress is provided (File)', async () => { @@ -1737,13 +1729,9 @@ describe('AttachmentManager', () => { expect(mockChannel.sendFile).toHaveBeenCalledTimes(1); expect(mockChannel.sendImage).not.toHaveBeenCalled(); - expect(mockChannel.sendFile).toHaveBeenCalledWith( - file, - undefined, - undefined, - undefined, - { onUploadProgress: expect.any(Function) }, - ); + expect(mockChannel.sendFile).toHaveBeenCalledWith(file, undefined, undefined, { + onUploadProgress: expect.any(Function), + }); }); it('passes onUploadProgress to sendImage when onProgress is provided (FileReference)', async () => { @@ -1764,7 +1752,6 @@ describe('AttachmentManager', () => { expect(mockChannel.sendImage).toHaveBeenCalledWith( fileRef.uri, fileRef.name, - fileRef.type, undefined, { onUploadProgress: expect.any(Function) }, ); @@ -1788,7 +1775,6 @@ describe('AttachmentManager', () => { expect(mockChannel.sendFile).toHaveBeenCalledWith( fileRef.uri, fileRef.name, - fileRef.type, undefined, { onUploadProgress: expect.any(Function) }, ); @@ -1810,14 +1796,12 @@ describe('AttachmentManager', () => { undefined, undefined, undefined, - undefined, ); expect(mockChannel.sendFile).toHaveBeenCalledWith( pdf, undefined, undefined, undefined, - undefined, ); }); @@ -1833,13 +1817,9 @@ describe('AttachmentManager', () => { abortSignal: controller.signal, }); - expect(mockChannel.sendImage).toHaveBeenCalledWith( - file, - undefined, - undefined, - undefined, - { signal: controller.signal }, - ); + expect(mockChannel.sendImage).toHaveBeenCalledWith(file, undefined, undefined, { + signal: controller.signal, + }); }); it('maps lengthComputable upload progress to rounded percent for sendImage', async () => { @@ -1852,7 +1832,7 @@ describe('AttachmentManager', () => { await attachmentManager.doDefaultUploadRequest(file, { onProgress }); - const axiosOpts = vi.mocked(mockChannel.sendImage).mock.calls[0][4] as { + const axiosOpts = vi.mocked(mockChannel.sendImage).mock.calls[0][3] as { onUploadProgress: (e: { loaded: number; total?: number; @@ -1884,7 +1864,7 @@ describe('AttachmentManager', () => { await attachmentManager.doDefaultUploadRequest(file, { onProgress }); - const axiosOpts = vi.mocked(mockChannel.sendFile).mock.calls[0][4] as { + const axiosOpts = vi.mocked(mockChannel.sendFile).mock.calls[0][3] as { onUploadProgress: (e: { loaded: number; total?: number; @@ -1909,7 +1889,7 @@ describe('AttachmentManager', () => { await attachmentManager.doDefaultUploadRequest(file, { onProgress }); - const axiosOpts = vi.mocked(mockChannel.sendImage).mock.calls[0][4] as { + const axiosOpts = vi.mocked(mockChannel.sendImage).mock.calls[0][3] as { onUploadProgress: (e: { loaded: number; total?: number; diff --git a/tsconfig.json b/tsconfig.json index 46b3ff41e8..7814655730 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,7 +26,8 @@ "lib": ["ES2022", "DOM", "ES2022.Error"], "moduleResolution": "bundler", "module": "Preserve", - "target": "ES2020" + "target": "ES2020", + "types": ["node"] }, "include": ["./src/**/*"] } diff --git a/yarn.lock b/yarn.lock index 7a81c1fe68..ccc713b995 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1563,12 +1563,12 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^22.19.19": - version: 22.19.19 - resolution: "@types/node@npm:22.19.19" +"@types/node@npm:^22": + version: 22.20.1 + resolution: "@types/node@npm:22.20.1" dependencies: undici-types: "npm:~6.21.0" - checksum: 10c0/402e0f088c94cabda3cd721546bd8e4e75e098e0b342f6e03b90ca1e19c28986f9650112c64fcfd09fc8cebc0f8b20291a513153e90489331cf666e1e5503e16 + checksum: 10c0/f2ba54d3d1fb92e1c57c78d32c3a17655b1e87363b707136f55c422b4838d4054901ce5d27f75bb0e5ecb7ebfee3804e0987822d22b473473008091857a09353 languageName: node linkType: hard @@ -7271,7 +7271,7 @@ __metadata: "@semantic-release/changelog": "npm:^6.0.3" "@semantic-release/git": "npm:^10.0.1" "@stream-io/logger": "npm:^2.0.0" - "@types/node": "npm:^22.19.19" + "@types/node": "npm:^22" "@types/sinon": "npm:^10.0.6" "@vitest/coverage-v8": "npm:^4.1.7" axios: "npm:^1.16.1" From dd957833d29f776000abdc8614830404af77d42e Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Wed, 12 Aug 2026 10:19:05 -0500 Subject: [PATCH 3/5] fix: file upload on RN --- src/api-client.ts | 3 +- src/channel.ts | 6 ++ src/client.ts | 6 ++ src/messageComposer/attachmentManager.ts | 3 +- src/utils.ts | 21 ++++++- .../MessageComposer/attachmentManager.test.ts | 60 ++++++++++++++----- 6 files changed, 79 insertions(+), 20 deletions(-) diff --git a/src/api-client.ts b/src/api-client.ts index 528fd03324..ee24d824a7 100644 --- a/src/api-client.ts +++ b/src/api-client.ts @@ -84,10 +84,11 @@ export class ApiClient { url: string, uri: string | File, name?: string, + contentType?: string, user?: UserResponse, axiosRequestConfig?: AxiosRequestConfig, ) { - const data = addFileToFormData(uri, name); + const data = addFileToFormData(uri, name, contentType || 'multipart/form-data'); if (user != null) data.append('user', JSON.stringify(user)); return this._doRequest('post', url, data, { diff --git a/src/channel.ts b/src/channel.ts index dfe30f32f0..889d61e0a3 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -497,6 +497,7 @@ export class Channel extends ChannelApi { * * @param uri - File source: URL string, `File`, `Buffer`, or readable stream (Node). * @param name - File name sent in the multipart body (optional). + * @param contentType - MIME type; required for React Native URI uploads (optional). * @param user - User payload appended to the form as JSON (optional). * @param axiosRequestConfig - Axios per-request config, merged after upload defaults, e.g. `onUploadProgress`, `signal` from `AbortController` (optional). * @returns A promise resolving to `{ file: string, ... }` with the CDN URL. @@ -504,6 +505,7 @@ export class Channel extends ChannelApi { sendFile( uri: string | File, name?: string, + contentType?: string, user?: UserResponse, axiosRequestConfig?: AxiosRequestConfig, ) { @@ -511,6 +513,7 @@ export class Channel extends ChannelApi { `${this._channelURL()}/file`, uri, name, + contentType, user, axiosRequestConfig, ); @@ -521,6 +524,7 @@ export class Channel extends ChannelApi { * * @param uri - Image source: URL string, `File`, or readable stream (Node). For `Buffer` uploads, use `sendFile` toward the channel file endpoint instead. * @param name - File name sent in the multipart body (optional). + * @param contentType - MIME type; required for React Native URI uploads (optional). * @param user - User payload appended to the form as JSON (optional). * @param axiosRequestConfig - Axios per-request config, merged after upload defaults, e.g. `onUploadProgress`, `signal` (optional). * @returns A promise resolving to `{ file: string, ... }` with the CDN URL. @@ -528,6 +532,7 @@ export class Channel extends ChannelApi { sendImage( uri: string | File, name?: string, + contentType?: string, user?: UserResponse, axiosRequestConfig?: AxiosRequestConfig, ) { @@ -535,6 +540,7 @@ export class Channel extends ChannelApi { `${this._channelURL()}/image`, uri, name, + contentType, user, axiosRequestConfig, ); diff --git a/src/client.ts b/src/client.ts index b31c25013b..1ad7e207bf 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2279,6 +2279,7 @@ export class StreamChat extends ChatApi { * * @param uri - The file to upload. * @param name - The name of the file (optional). + * @param contentType - MIME type; required for React Native URI uploads (optional). * @param user - User information (optional). * @param axiosRequestConfig - Axios config, e.g. `onUploadProgress` for progress tracking (optional). * @returns Response containing the file URL. @@ -2286,6 +2287,7 @@ export class StreamChat extends ChatApi { uploadFile_( uri: string | File, name?: string, + contentType?: string, user?: UserResponse, axiosRequestConfig?: AxiosRequestConfig, ) { @@ -2293,6 +2295,7 @@ export class StreamChat extends ChatApi { `${this.baseURL}/uploads/file`, uri, name, + contentType, user, axiosRequestConfig, ); @@ -2303,6 +2306,7 @@ export class StreamChat extends ChatApi { * * @param uri - The image to upload. * @param name - The name of the image (optional). + * @param contentType - MIME type; required for React Native URI uploads (optional). * @param user - User information (optional). * @param axiosRequestConfig - Axios config, e.g. `onUploadProgress` for progress tracking (optional). * @returns Response containing the image URL. @@ -2310,6 +2314,7 @@ export class StreamChat extends ChatApi { uploadImage_( uri: string | File, name?: string, + contentType?: string, user?: UserResponse, axiosRequestConfig?: AxiosRequestConfig, ) { @@ -2317,6 +2322,7 @@ export class StreamChat extends ChatApi { `${this.baseURL}/uploads/image`, uri, name, + contentType, user, axiosRequestConfig, ); diff --git a/src/messageComposer/attachmentManager.ts b/src/messageComposer/attachmentManager.ts index 86a9912470..42c07dc835 100644 --- a/src/messageComposer/attachmentManager.ts +++ b/src/messageComposer/attachmentManager.ts @@ -551,6 +551,7 @@ export class AttachmentManager { return this.channel[isImageFile(fileLike) ? 'sendImage' : 'sendFile']( fileLike.uri, fileLike.name, + fileLike.type, undefined, axiosUploadConfig, ); @@ -566,7 +567,7 @@ export class AttachmentManager { const { duration: _duration, ...result } = await this.channel[ isImageFile(fileLike) ? 'sendImage' : 'sendFile' - ](file, undefined, undefined, axiosUploadConfig); + ](file, undefined, undefined, undefined, axiosUploadConfig); return result; }; diff --git a/src/utils.ts b/src/utils.ts index 2804cc0cb5..11059bc3f5 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -100,14 +100,31 @@ export const channelTracksReadLocally = (channel?: Channel) => export const userHasReadReceipts = (client: StreamChat) => client.user?.privacy_settings?.read_receipts?.enabled ?? true; -export function addFileToFormData(uri: string | Blob, name?: string) { +export function addFileToFormData( + uri: string | Blob, + name?: string, + contentType?: string, +) { const data = new FormData(); if (isBlobWebAPI(uri)) { if (name) data.append('file', uri, name); else data.append('file', uri); } else { - data.append('file', uri); + // React Native has no Blob-backed uploads: files are referenced by local URI + // (`file://`, `content://`, `ph://`) and its FormData polyfill expects a + // `{ uri, name, type }` part descriptor. Appending the bare URI string instead produces + // a *text* form field, which the API rejects with `400 http: no such file`. + // + // The MIME type cannot be recovered from the URI at this layer either: Android aborts the + // whole request when a `uri` part carries no content-type header, and `content://` / + // `ph://` URIs have no file extension for the platform to guess from. + data.append('file', { + uri, + name: name || uri.split('/').reverse()[0], + contentType: contentType || undefined, + type: contentType || undefined, + } as unknown as Blob); } return data; diff --git a/test/unit/MessageComposer/attachmentManager.test.ts b/test/unit/MessageComposer/attachmentManager.test.ts index bdd6b121d0..ca39cd7df9 100644 --- a/test/unit/MessageComposer/attachmentManager.test.ts +++ b/test/unit/MessageComposer/attachmentManager.test.ts @@ -1493,9 +1493,15 @@ describe('AttachmentManager', () => { await attachmentManager.fileToLocalUploadAttachment(file), ); - expect(mockChannel.sendImage).toHaveBeenCalledWith(file, undefined, undefined, { - signal: expect.any(AbortSignal), - }); + expect(mockChannel.sendImage).toHaveBeenCalledWith( + file, + undefined, + undefined, + undefined, + { + signal: expect.any(AbortSignal), + }, + ); }); it('when false, omits uploadProgress on attachment while upload is in flight', async () => { @@ -1611,7 +1617,7 @@ describe('AttachmentManager', () => { expect(mockChannel.sendImage).toHaveBeenCalled(); }); - const axiosOpts = mockChannel.sendImage.mock.calls[0][3] as { + const axiosOpts = mockChannel.sendImage.mock.calls[0][4] as { signal?: AbortSignal; }; expect(axiosOpts?.signal).toBeInstanceOf(AbortSignal); @@ -1712,9 +1718,15 @@ describe('AttachmentManager', () => { expect(mockChannel.sendImage).toHaveBeenCalledTimes(1); expect(mockChannel.sendFile).not.toHaveBeenCalled(); - expect(mockChannel.sendImage).toHaveBeenCalledWith(file, undefined, undefined, { - onUploadProgress: expect.any(Function), - }); + expect(mockChannel.sendImage).toHaveBeenCalledWith( + file, + undefined, + undefined, + undefined, + { + onUploadProgress: expect.any(Function), + }, + ); }); it('passes onUploadProgress to sendFile when onProgress is provided (File)', async () => { @@ -1729,9 +1741,15 @@ describe('AttachmentManager', () => { expect(mockChannel.sendFile).toHaveBeenCalledTimes(1); expect(mockChannel.sendImage).not.toHaveBeenCalled(); - expect(mockChannel.sendFile).toHaveBeenCalledWith(file, undefined, undefined, { - onUploadProgress: expect.any(Function), - }); + expect(mockChannel.sendFile).toHaveBeenCalledWith( + file, + undefined, + undefined, + undefined, + { + onUploadProgress: expect.any(Function), + }, + ); }); it('passes onUploadProgress to sendImage when onProgress is provided (FileReference)', async () => { @@ -1752,6 +1770,7 @@ describe('AttachmentManager', () => { expect(mockChannel.sendImage).toHaveBeenCalledWith( fileRef.uri, fileRef.name, + fileRef.type, undefined, { onUploadProgress: expect.any(Function) }, ); @@ -1775,6 +1794,7 @@ describe('AttachmentManager', () => { expect(mockChannel.sendFile).toHaveBeenCalledWith( fileRef.uri, fileRef.name, + fileRef.type, undefined, { onUploadProgress: expect.any(Function) }, ); @@ -1796,12 +1816,14 @@ describe('AttachmentManager', () => { undefined, undefined, undefined, + undefined, ); expect(mockChannel.sendFile).toHaveBeenCalledWith( pdf, undefined, undefined, undefined, + undefined, ); }); @@ -1817,9 +1839,15 @@ describe('AttachmentManager', () => { abortSignal: controller.signal, }); - expect(mockChannel.sendImage).toHaveBeenCalledWith(file, undefined, undefined, { - signal: controller.signal, - }); + expect(mockChannel.sendImage).toHaveBeenCalledWith( + file, + undefined, + undefined, + undefined, + { + signal: controller.signal, + }, + ); }); it('maps lengthComputable upload progress to rounded percent for sendImage', async () => { @@ -1832,7 +1860,7 @@ describe('AttachmentManager', () => { await attachmentManager.doDefaultUploadRequest(file, { onProgress }); - const axiosOpts = vi.mocked(mockChannel.sendImage).mock.calls[0][3] as { + const axiosOpts = vi.mocked(mockChannel.sendImage).mock.calls[0][4] as { onUploadProgress: (e: { loaded: number; total?: number; @@ -1864,7 +1892,7 @@ describe('AttachmentManager', () => { await attachmentManager.doDefaultUploadRequest(file, { onProgress }); - const axiosOpts = vi.mocked(mockChannel.sendFile).mock.calls[0][3] as { + const axiosOpts = vi.mocked(mockChannel.sendFile).mock.calls[0][4] as { onUploadProgress: (e: { loaded: number; total?: number; @@ -1889,7 +1917,7 @@ describe('AttachmentManager', () => { await attachmentManager.doDefaultUploadRequest(file, { onProgress }); - const axiosOpts = vi.mocked(mockChannel.sendImage).mock.calls[0][3] as { + const axiosOpts = vi.mocked(mockChannel.sendImage).mock.calls[0][4] as { onUploadProgress: (e: { loaded: number; total?: number; From c985b280fccb72c84e96f7571649f690d470897e Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Wed, 12 Aug 2026 10:21:37 -0500 Subject: [PATCH 4/5] fix: remove unnecessary comment --- src/utils.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index 11059bc3f5..97452f37a2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -111,14 +111,7 @@ export function addFileToFormData( if (name) data.append('file', uri, name); else data.append('file', uri); } else { - // React Native has no Blob-backed uploads: files are referenced by local URI - // (`file://`, `content://`, `ph://`) and its FormData polyfill expects a - // `{ uri, name, type }` part descriptor. Appending the bare URI string instead produces - // a *text* form field, which the API rejects with `400 http: no such file`. - // - // The MIME type cannot be recovered from the URI at this layer either: Android aborts the - // whole request when a `uri` part carries no content-type header, and `content://` / - // `ph://` URIs have no file extension for the platform to guess from. + // React Native path data.append('file', { uri, name: name || uri.split('/').reverse()[0], From b2c81d121e995ef542467a00320daaf6a149cc9f Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Thu, 13 Aug 2026 14:29:00 -0500 Subject: [PATCH 5/5] chore: update migration guide --- CLAUDE.md | 10 +- docs/fileUpload.md | 57 ++-- docs/webhooks.md | 246 ++---------------- ...v10-migration-guide-client-construction.md | 33 ++- v9-to-v10-migration-guide-methods.md | 41 ++- v9-to-v10-migration-guide-other.md | 24 +- v9-to-v10-migration-guide-server-side.md | 143 +++++++++- 7 files changed, 289 insertions(+), 265 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 40e9fc2d0b..765abf733c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,10 +36,10 @@ Single test runs use Vitest's CLI directly: `yarn test-unit path/to/file.test.ts 1. `tsc` — emits **declarations only** (`emitDeclarationOnly: true`) to `dist/types`. `rootDir` is `src/`. 2. `scripts/bundle.mjs` (esbuild) — produces three bundles: - `dist/cjs/index.node.js` (Node CJS, externalizes deps + Node builtins) - - `dist/cjs/index.browser.js` (browser CJS, externalizes deps except those zeroed in `package.json#browser`) + - `dist/cjs/index.browser.js` (browser CJS) - `dist/esm/index.mjs` (browser ESM) -`package.json#exports` routes consumers to the right bundle by condition: `node` → node-cjs, `browser`/`react-native` → browser-cjs (require) or esm (import), default → esm. The `browser` field zeroes Node-only deps (`crypto`, `https`, `jsonwebtoken`, `ws`, `zlib`) so they tree-shake out of browser/RN builds. If you add a dep that's Node-only, add it to `browser` so it doesn't leak into browser/RN bundles. +`package.json#exports` routes consumers to the right bundle by condition: `node` → node-cjs, `browser`/`react-native` → browser-cjs (require) or esm (import), default → esm. There is **no `package.json#browser` field** — it used to zero Node-only deps (`crypto`, `https`, `jsonwebtoken`, `ws`, `zlib`) for browser/RN builds, but the SDK no longer imports any of them (`src/index.ts` is platform-agnostic: global `WebSocket`, global `FormData`, global `atob`). `scripts/bundle.mjs` keeps a `browserIgnoreModules` hook, currently an empty array, for the day that changes. Prefer a platform global or a browser-safe dep over reintroducing a Node-only one. esbuild `define` injects two compile-time constants: `process.env.PKG_VERSION` (read from `package.json`) and `process.env.CLIENT_BUNDLE` (one of `node-cjs`, `browser-cjs`, `browser-esm`). Both are consumed by `StreamChat.getUserAgent()` to produce a bundle-aware UA string. **`tsc`-only code paths do not get this substitution** — these env vars only resolve in the esbuild bundles, so don't gate runtime logic on them in code that callers might import directly via `src/`. @@ -56,9 +56,9 @@ This is a single-package SDK with **no monorepo**. The public surface is everyth - **`ChannelManager.ts`** — channel _lists_. Holds one or more `ChannelPaginator`s (`state.paginators`), keeps them in sync with WS events through an `EventHandlerPipeline` per event type, and arbitrates ownership when a channel matches several lists (`ownershipResolver` / `createPriorityOwnershipResolver`). Replaced the old `channel_manager.ts` (single hand-sorted `state.channels` list with named handler overrides) in v10 — see `v9-to-v10-migration-guide-methods.md`. Filtering and ordering are the paginator's job: `matchesFilter()` runs the filter compiler over `Channel` field resolvers and ordering comes from a comparator compiled from `sort`. The manager is instantiated by the `StreamChat` constructor and lives as long as the client (`client.channelManager`) — it is not configurable through the client options; register lists with `insertPaginator({ paginator, index? })`, detach them with `removePaginator(paginatorOrId)` and set cross-list ownership with `setOwnershipResolver(resolverOrPriorityIds?)`. `setPaginators(paginators)` is the primitive the other two build on — use it (or `clearPaginators()`) for batches, since it publishes one state update instead of one per paginator, and skips the update entirely when the set is unchanged. Registration and loaded data have different owners: `disconnectUser` calls `resetPaginatorStates()`, which discards each list's channels (they belong to the user going away) while leaving the lists themselves registered, since which lists exist is the integrator's configuration. Event handling stays customizable: `ChannelManagerOptions.eventHandlers` replaces the default map wholesale at construction (start from `getDefaultHandlers()` to enrich it instead), and `addEventHandler` / `setEventHandlers` / `removeEventHandlers` adjust the pipelines afterwards — which is the only route for `client.channelManager`, since the client constructs it without options. The exported `ignoreEventsForUnknownChannels` handler, inserted at `index: 0`, is how a list opts out of pulling in channels it has not loaded. - **`connection.ts` (`StableWSConnection`) + `connection_fallback.ts` (`WSConnectionFallback`)** — realtime transport. Primary WS implementation does its own 25s ping / 35s health-check loop and reconnects on close/error/offline events; the fallback long-polls over HTTP. The client picks between them based on first-connect outcome; both emit `connection.changed` / `transport.changed` events into the client's local event bus. - **`store.ts` — `StateStore`.** Reactive primitive (see "State and subscription patterns" below). -- **`signing.ts` — webhook + token helpers.** Server-side primitives `verifyAndParseWebhook`, `parseSqs`, `parseSns`, `verifySignature` (recent CHA-3071 added compressed-payload support). These are re-exported through `client.ts`. **The HMAC is always computed over the uncompressed JSON bytes** — gzip detection uses the `1f 8b` magic bytes, not headers, so the same handler works whether your platform middleware auto-decompressed or not. `CheckSignature` is deprecated in favor of `verifySignature` purely to fix parameter order; new code should use `verifySignature(body, signature, secret)`. +- **`signing.ts` — one function, `UserFromToken`.** Decodes a JWT payload with the global `atob` and returns `user_id`. Everything else this module used to hold was server-side (JWT minting via `jsonwebtoken`, webhook/SQS/SNS verification via `crypto` + `zlib`) and was removed along with those deps — see `v9-to-v10-migration-guide-server-side.md`. Do not reintroduce secret-holding or HMAC code here; that surface lives in `@stream-io/node-sdk`. - **`middleware.ts`** — `MiddlewareExecutor` (see "Middleware pipelines" below). Used by composer pipelines, not by client request lifecycle. -- **`token_manager.ts`** — handles static tokens and async token providers. Tracks a `loadTokenPromise` so concurrent calls await the same fetch. Server-side clients (constructed with a `secret`) sign their own JWTs locally via `JWTServerToken` / `JWTUserToken`. +- **`token_manager.ts`** — handles static tokens and async token providers. Tracks a `loadTokenPromise` so concurrent calls await the same fetch. The constructor takes no arguments: there is no `secret` and no local JWT signing — every token comes from the caller (a string or a `TokenProvider`). Anonymous users may have no token at all; anyone else without one now fails at `getToken()` rather than at `setTokenOrProvider()`. - **`events.ts` — `EVENT_MAP`.** Single source of truth for known event types (used by `EventTypes` in `types.ts`). Adding a new event type requires an entry here. Note the "local events" section: `channels.queried`, `connection.changed`, `transport.changed`, `capabilities.changed`, `live_location_sharing.*` are dispatched client-side only and never come over the wire. - **`insights.ts` — `InsightMetrics` + `postInsights`.** WS-health telemetry sent to `https://chat-insights.getstream.io`. This is internal; do not call from end-user code paths. The fields captured by `buildWsBaseInsight` include token and connection metadata — treat changes here as security-sensitive. - **`uploadManager.ts` / `LiveLocationManager.ts` / `CooldownTimer.ts`** — feature controllers, each owns its own `StateStore` slice. @@ -171,7 +171,7 @@ Release branches (`.releaserc.json`): - `yarn types` passes. - `yarn test` green. - If you touched `src/index.ts` or any re-exported type, you've considered the public-API/semver impact. -- If you added a dependency: it doesn't need to run lifecycle scripts (or is added to `dependenciesMeta`), and Node-only deps are listed in `package.json#browser`. +- If you added a dependency: it doesn't need to run lifecycle scripts (or is added to `dependenciesMeta`), and it is not Node-only — the SDK is client-side only and carries no `package.json#browser` shim list anymore, so a Node-only import would break browser/RN bundles outright. - If you added a new event type, it's registered in `src/events.ts#EVENT_MAP` (otherwise `EventTypes` won't include it). - If you extended composer behavior, you inserted middleware rather than forking `MessageComposerMiddlewareExecutor`. - If you added long-lived subscriptions on a manager, `registerSubscriptions` is idempotent and `unregisterSubscriptions` calls `super`. diff --git a/docs/fileUpload.md b/docs/fileUpload.md index 7d7208ad7e..633b39a61c 100644 --- a/docs/fileUpload.md +++ b/docs/fileUpload.md @@ -1,41 +1,62 @@ # File Upload -Stream JS client supports uploading files in both browser and Node.js environment. +`stream-chat` uploads files from the browser and from React Native. The upload +methods accept `string | File`: + +- **Browser** — a `File` or `Blob` (typically from an ``). +- **React Native** — a local URI `string`, in which case you must also pass + `contentType`, since there is nothing to infer the MIME type from. ## Token -You can get your API key and API secret in [Stream Dashboard](https://getsream.io/dashboard/). -User token can be generated using your API Secret and any random User ID using [Stream Token Generator](https://getstream.io/chat/docs/javascript/token_generator/). +You can get your API key in the [Stream Dashboard](https://getstream.io/dashboard/). +A user token can be generated for testing with the +[Stream Token Generator](https://getstream.io/chat/docs/javascript/token_generator/); +in production, mint it on your backend with +[`@stream-io/node-sdk`](https://github.com/GetStream/stream-node) and never ship +the API secret to a client. ```js const apiKey = 'swde2zgm3549'; -const apiSecret = 'YOUR_SUPER_SECRET_TOKEN'; const userId = 'dawn-union-6'; const userToken = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiZGF3bi11bmlvbi02In0.mpf8pgxn5r02EqsChMaw6SdCFCyBBl7VJhyleTqEwho'; ``` -## Node.js +## Node.js — not supported -In order to upload a file, you first need to create an instance of stream client, and a channel to send the files to it. +There is no Node upload path. `stream-chat` v9 accepted a `Buffer` or a +readable stream because it bundled the `form-data` package; v10 dropped that +dependency in favor of the platform's global `FormData`, so `Buffer` and +stream sources are gone and the `Blob` branch only runs where `window` exists. -```js -const fs = require('fs'); -const { StreamChat } = require('stream-chat'); +Upload from your backend with `@stream-io/node-sdk` instead: -const user = { id: 'user_id' }; -const apiKey = 'swde2zgm3549'; // use your app key -const apiSecret = 'YOUR_SUPER_SECRET_TOKEN'; // use your app secret -const client = StreamChat.getInstance(apiKey, apiSecret); +```js +const { readFile } = require('node:fs/promises'); +const { File } = require('node:buffer'); +const { StreamClient } = require('@stream-io/node-sdk'); -const channel = client.channel('messaging', 'channel_id', { created_by: user }); -await channel.create(); // if channel does not exist yet +const client = new StreamClient(process.env.STREAM_KEY, process.env.STREAM_SECRET); +const buffer = await readFile('./helloworld.txt'); -const file = fs.createReadStream('./helloworld.txt'); -const response = await channel.sendFile(file, 'helloworld.txt', 'text/plain', user); +const response = await client.uploadFile({ + file: new File([buffer], 'helloworld.txt', { type: 'text/plain' }), + user: { id: 'user_id' }, +}); console.log('file url: ', response.file); ``` +## React Native + +```js +const response = await channel.sendFile( + localUri, // e.g. 'file:///.../IMG_0001.HEIC' from the image picker + 'IMG_0001.HEIC', + 'image/heic', // required — pass the MIME type explicitly +); +``` + ## Browser ```html @@ -76,7 +97,7 @@ console.log('file url: ', response.file); Channel uploads use Axios under the hood. Both **`channel.sendFile`** and **`channel.sendImage`** accept an optional **fifth argument** `axiosRequestConfig` (`AxiosRequestConfig` from axios). The same optional argument exists on **`client.uploadFile`** and **`client.uploadImage`**. -The client merges your config **after** its upload defaults (`timeout: 0`, large `maxContentLength` / `maxBodyLength`, and multipart headers from the form data). Any property you set can override or extend those defaults. +The client merges your config **after** its upload defaults (`timeout: 0`, large `maxContentLength` / `maxBodyLength`). Any property you set can override or extend those defaults. Multipart headers — including the boundary — are set by axios from the `FormData` body; the SDK no longer computes them itself (v9 took them from `form-data`'s `getHeaders()`). Typical uses: diff --git a/docs/webhooks.md b/docs/webhooks.md index 8bcf95a789..8727862088 100644 --- a/docs/webhooks.md +++ b/docs/webhooks.md @@ -1,234 +1,44 @@ # Webhooks -Stream chat can deliver real-time events to your backend over HTTP webhooks -or via SQS / SNS firehose. HTTP webhook payloads are signed with -HMAC-SHA256 using your app's API secret so you can verify they actually -came from Stream. SQS / SNS deliveries ride AWS-internal transports -(IAM-authenticated queues, AWS-signed SNS notifications) and are not -HMAC-signed by Stream — the AWS transport itself is the auth layer. - -The SDK exposes three transport-specific helpers: - -- `verifyAndParseWebhook` — gunzips, verifies the `X-Signature` header, - and returns the parsed `Event` for HTTP webhooks. -- `parseSqs` — base64-decodes (and gunzips, if needed) an SQS message - body and returns the parsed `Event`. No HMAC step. -- `parseSns` — same as `parseSqs`, but also unwraps the SNS HTTP - notification envelope when given the full envelope JSON. No HMAC step. - -Each helper exists both as a method on `StreamChat` and as a standalone -function (useful in serverless handlers where you don't keep a client -around). - -## Verifying an HTTP webhook (legacy boolean helper) - -The classic `verifyWebhook` helper takes the raw HTTP request body plus the -`x-signature` header and returns a boolean. Use it when you already parse -the JSON yourself and just want to confirm the request is authentic. - -```js -const { StreamChat } = require('stream-chat'); - -const client = new StreamChat('api_key', 'api_secret'); - -app.post('/webhooks/stream', express.raw({ type: '*/*' }), (req, res) => { - const valid = client.verifyWebhook(req.body, req.headers['x-signature']); - if (!valid) return res.sendStatus(401); - const event = JSON.parse(req.body.toString('utf8')); - // ...handle the event - res.sendStatus(200); -}); -``` - -## Compressed webhook bodies - -GZIP compression can be enabled for hooks payloads from the Dashboard. -Enabling compression reduces the payload size significantly (often 70–90% -smaller) reducing your bandwidth usage on Stream. The computation overhead -introduced by the decompression step is usually negligible and offset by -the much smaller payload. - -When payload compression is enabled, webhook HTTP requests will include the -`Content-Encoding: gzip` header and the request body will be compressed -with GZIP. Some HTTP servers and middleware (Rails, Django, Laravel, Spring -Boot, ASP.NET) handle this transparently and strip the header before your -handler runs — in that case the body you see is already raw JSON. - -The SDK detects compression from the **first two bytes of the body** -(`1f 8b`, the gzip magic per RFC 1952) rather than the `Content-Encoding` -header, so the same handler stays correct whether or not your framework -auto-decompresses the request. - -Before enabling compression, make sure that: - -- Your backend integration is using a recent version of our official SDKs - with compression support -- If you don't use an official SDK, make sure that your code supports - receiving compressed payloads -- The payload signature check is done on the **uncompressed** payload - -## `verifyAndParseWebhook` - -`verifyAndParseWebhook` is the recommended helper for HTTP webhooks. It -gunzips the body when needed, verifies the HMAC signature, parses the JSON, -and returns the typed `Event`. Every failure mode (signature mismatch, -malformed gzip, malformed base64 on the SQS/SNS variants, invalid JSON) -is surfaced through a single unified error class - `InvalidWebhookError` - -so a single `catch` arm covers all of them. Use `err.message` (or the -exported `InvalidWebhookErrorMessages` constants) when you need to -distinguish between failure modes. +**`stream-chat` no longer has a webhook surface.** As of v10 the SDK is +client-side only, and webhook verification needs the API secret that a +client-side SDK must never hold. Every helper this page used to document — +`client.verifyWebhook`, `client.verifyAndParseWebhook`, `client.parseSqs`, +`client.parseSns`, and the standalone `verifySignature`, `CheckSignature`, +`verifyAndParseWebhook`, `parseSqs`, `parseSns`, `gunzipPayload`, +`decodeSqsPayload`, `decodeSnsPayload`, `parseEvent`, `InvalidWebhookError`, +`InvalidWebhookErrorMessages` — has been removed, together with the +`jsonwebtoken` / `zlib` / `crypto` code paths that backed them. + +Handle webhooks with the server SDK instead: +[`@stream-io/node-sdk`](https://github.com/GetStream/stream-node). It keeps +the v9 names on the client and reads the secret from construction, so the +handler body barely changes: ```js -const { StreamChat, InvalidWebhookError } = require('stream-chat'); +const { StreamClient } = require('@stream-io/node-sdk'); -const client = new StreamChat('api_key', 'api_secret'); +const client = new StreamClient(process.env.STREAM_KEY, process.env.STREAM_SECRET); -// Use `express.raw` so `req.body` stays as a Buffer. -app.post('/webhooks/stream', express.raw({ type: '*/*' }), (req, res) => { +// Use express.raw so req.body stays a Buffer — the HMAC is computed over +// the uncompressed JSON bytes Stream signed. +app.post('/webhook', express.raw({ type: '*/*' }), (req, res) => { try { const event = client.verifyAndParseWebhook(req.body, req.headers['x-signature']); - // ...handle the event (event.type, event.message, etc.) + handle(event); res.sendStatus(200); } catch (err) { - if (err instanceof InvalidWebhookError) { - return res.sendStatus(401); - } - throw err; + res.sendStatus(400); } }); ``` -The same helper is also exported as a standalone, stateless function that -takes the secret explicitly: - -```js -const { verifyAndParseWebhook } = require('stream-chat'); - -const event = verifyAndParseWebhook(rawBody, signature, apiSecret); -``` - -## SQS / SNS firehose delivery - -Stream can also fan webhook events out through Amazon SQS or SNS. Both -transports require valid UTF-8 message bodies, so the JSON (or its gzipped -bytes when compression is enabled) is base64-encoded before being placed in -the message. - -Stream does not include an `X-Signature` on SQS or SNS deliveries — those -rely on the AWS transport's own authentication (IAM-authenticated queues -for SQS, AWS-signed SNS notifications). `parseSqs` and `parseSns` are -decode-and-parse helpers; there is no HMAC step. The HTTP webhook path -(`verifyAndParseWebhook`) keeps signature verification because that's the -only surface where `X-Signature` actually arrives. - -Use `parseSqs` for SQS messages. It base64-decodes the body, gunzips when -the decoded bytes start with the gzip magic, and returns the parsed -`Event`. - -```js -const { StreamChat, InvalidWebhookError } = require('stream-chat'); - -const client = new StreamChat('api_key', 'api_secret'); - -async function handleSqsMessage(message) { - try { - const event = client.parseSqs(message.Body); - // ...handle the event - } catch (err) { - if (err instanceof InvalidWebhookError) { - // drop the message or move it to a dead-letter queue - return; - } - throw err; - } -} -``` - -For SNS, pass either the full notification envelope JSON or the -pre-extracted `Message` field to `parseSns`: - -```js -const { StreamChat, InvalidWebhookError } = require('stream-chat'); - -const client = new StreamChat('api_key', 'api_secret'); - -async function handleSnsNotification(envelopeBody) { - // `envelopeBody` is the JSON SNS posts to your HTTPS endpoint, or the - // record you pull off SQS-via-SNS. - const event = client.parseSns(envelopeBody); - // ...handle the event -} -``` - -`parseSqs` and `parseSns` are also exported as standalone, stateless -functions: +`client.verifyWebhook`, `client.parseSqs`, and `client.parseSns` are +available on the same client with their v9 shapes, and +`InvalidWebhookError` is re-exported for `instanceof` checks. -```js -const { parseSqs, parseSns } = require('stream-chat'); - -const event = parseSqs(messageBody); -``` - -## Lower-level building blocks - -If you need finer control (for example, to verify a signature without -parsing the JSON, or to inflate a payload yourself), the SDK also exports: - -- `gunzipPayload(body)` — returns the raw body as a `Buffer`, gunzipping - it when the first two bytes match the gzip magic. Plain bodies pass - through unchanged. -- `decodeSqsPayload(body)` / `decodeSnsPayload(body)` — base64-decodes - the SQS/SNS body and then gunzips if needed. Throws - `InvalidWebhookError` on malformed base64. -- `parseEvent(payload)` — `JSON.parse` plus the `Event` type cast. -- `verifySignature(body, signature, secret)` — constant-time HMAC-SHA256 - comparison. The signature must be computed over the uncompressed, - base64-decoded JSON. - -## API reference +Migration details, including the full v9 → node-sdk mapping table: +[`v9-to-v10-migration-guide-server-side.md`](../v9-to-v10-migration-guide-server-side.md#webhook--sns--sqs). -| Method | Returns | Throws | -| ------------------------------------------------------------ | --------- | ----------------------------------------------------------------------------------- | -| `client.verifyWebhook(body, sig)` | `boolean` | never | -| `client.verifyAndParseWebhook(rawBody, sig)` | `Event` | `InvalidWebhookError` for signature mismatch, missing secret, or bad gzip envelope | -| `client.parseSqs(messageBody)` | `Event` | `InvalidWebhookError` for bad base64 / gzip or invalid JSON | -| `client.parseSns(notificationBody)` | `Event` | `InvalidWebhookError` for bad base64 / gzip or invalid JSON | -| `verifyAndParseWebhook(rawBody, sig, secret)` _(standalone)_ | `Event` | `InvalidWebhookError` for signature mismatch or bad gzip envelope | -| `parseSqs(messageBody)` _(standalone)_ | `Event` | `InvalidWebhookError` for bad base64 / gzip or invalid JSON | -| `parseSns(notificationBody)` _(standalone)_ | `Event` | `InvalidWebhookError` for bad base64 / gzip or invalid JSON | -| `verifySignature(body, sig, secret)` | `boolean` | never | -| `gunzipPayload(body)` | `Buffer` | `InvalidWebhookError` when the body starts with the gzip magic but fails to inflate | -| `decodeSqsPayload(body)` / `decodeSnsPayload(body)` | `Buffer` | `InvalidWebhookError` for malformed base64 or bad gzip bytes | -| `parseEvent(payload)` | `Event` | `InvalidWebhookError` when the payload is not valid JSON | - -`parseSqs` and `parseSns` take a single argument (the message body or SNS -envelope / pre-extracted message). They never accept a signature: Stream -does not ship an `X-Signature` on SQS or SNS deliveries — those rely on -the AWS transport's own authentication (IAM-authenticated queues, -AWS-signed SNS notifications). The HTTP webhook path -(`verifyAndParseWebhook`) keeps signature verification because that's the -only surface where `X-Signature` actually arrives. - -`InvalidWebhookError` (and the `InvalidWebhookErrorMessages` constants) is -exported from the package root and from `stream-chat/dist/types/signing`. -Every webhook verification + parsing path in this SDK terminates at this -single error class, so a single `catch` arm is enough to convert auth and -format failures into a `401` / `403` response (HTTP) or a drop / -dead-letter decision (SQS / SNS). Filter on `err.message` when you need -mode-specific behaviour: - -```js -const { InvalidWebhookError, InvalidWebhookErrorMessages } = require('stream-chat'); - -try { - const event = client.verifyAndParseWebhook(req.body, req.headers['x-signature']); -} catch (err) { - if (err instanceof InvalidWebhookError) { - if (err.message === InvalidWebhookErrorMessages.signatureMismatch) { - return res.sendStatus(401); - } - return res.sendStatus(400); - } - throw err; -} -``` +Product documentation for configuring webhooks, SQS, and SNS: +. diff --git a/v9-to-v10-migration-guide-client-construction.md b/v9-to-v10-migration-guide-client-construction.md index a27d447d5e..7bbc671dd1 100644 --- a/v9-to-v10-migration-guide-client-construction.md +++ b/v9-to-v10-migration-guide-client-construction.md @@ -7,7 +7,8 @@ - `secret` is gone. The constructor and `getInstance` no longer accept it. **v10 does not support server-side use.** - The constructor and `getInstance` are now a single signature: `(key, options?)`. The `(key, secret, options?)` overload has been removed. - `StreamChatOptions` no longer extends `AxiosRequestConfig`. Axios-level fields (`timeout`, `httpsAgent`, `withCredentials`, headers, etc.) must now be passed via the dedicated `axiosRequestConfig` property. -- The same axios defaults (`timeout: 3000`, `withCredentials: false`, keep-alive `httpsAgent` in node) are still applied, but in v10 they can be overridden through `axiosRequestConfig`. In v9 they could not be — `axiosRequestConfig` only affected per-request calls. +- The remaining axios defaults (`timeout: 3000`, `withCredentials: false`) are still applied, but in v10 they can be overridden through `axiosRequestConfig`. In v9 they could not be — `axiosRequestConfig` only affected per-request calls. +- **The implicit keep-alive `httpsAgent` in node is gone.** v9 created an `https.Agent({ keepAlive: true, keepAliveMsecs: 3000 })` whenever node was detected; v10 no longer imports `node:https` at all. If you relied on connection reuse, pass your own agent — see [`httpsAgent` is no longer defaulted in node](#httpsagent-is-no-longer-defaulted-in-node). - `paramsSerializer` cannot be overridden. Any `paramsSerializer` passed in `axiosRequestConfig` is ignored; the client always uses its internal `axiosParamsSerializer`. ## Server-side users — stop here @@ -96,7 +97,7 @@ client.axiosInstance.defaults.timeout; // 9999 client.axiosInstance.defaults.withCredentials; // true ``` -The defaults (`timeout: 3000`, `withCredentials: false`, keep-alive `https.Agent` in node) still apply when `axiosRequestConfig` does not set them. +The defaults (`timeout: 3000`, `withCredentials: false`) still apply when `axiosRequestConfig` does not set them. ### `httpsAgent` location moved @@ -111,7 +112,30 @@ new StreamChat(API_KEY, { }); ``` -In both versions, node mode (`browser: false` or auto-detected) auto-creates a keep-alive `https.Agent` when none is supplied. Browser mode does not. +### `httpsAgent` is no longer defaulted in node + +In v9, node mode (`browser: false` or auto-detected) auto-created a keep-alive agent when none was supplied: + +```ts +// v9 / v10-rc — removed from src/client.ts +httpsAgent: this.node + ? new https.Agent({ keepAlive: true, keepAliveMsecs: 3000 }) + : undefined, +``` + +v10 drops the `node:https` import along with the rest of the server-side surface, so axios falls back to Node's default agent — **a fresh TCP connection and TLS handshake per request**. Browser mode is unaffected (it never had an agent). + +Most client-side integrations do not care. If you run `stream-chat` under node — a bot, a worker, an SSR process, a test suite that makes many sequential requests — and want the old behavior back, supply the agent yourself: + +```ts +import https from 'node:https'; + +new StreamChat(API_KEY, { + axiosRequestConfig: { + httpsAgent: new https.Agent({ keepAlive: true, keepAliveMsecs: 3000 }), + }, +}); +``` ### `paramsSerializer` is fixed @@ -132,7 +156,7 @@ These are intentionally listed so agents don't "fix" them during migration: - `new StreamChat(key)` still works with no options. - `StreamChat.getInstance(key)` still returns the same cached instance on repeated calls and ignores the `key`/`options` of subsequent calls. -- All non-axios options are unchanged: `allowServerSideConnect`, `baseURL`, `browser`, `device`, `disableCache`, `enableInsights`, `enableWSFallback`, `notifications`, `persistUserOnConnectionFailure`, `recoverStateOnReconnect`, `warmUp`, `wsConnection`, `wsUrlParams`. +- All non-axios options are unchanged: `allowServerSideConnect`, `baseURL`, `browser`, `device`, `disableCache`, `enableInsights`, `enableWSFallback`, `notifications`, `persistUserOnConnectionFailure`, `recoverStateOnReconnect`, `warmUp`, `wsConnection`, `wsUrlParams`. One option is **new**: `WebSocketImpl?: typeof WebSocket`, which overrides the constructor `StableWSConnection` instantiates. It exists because v10 dropped the `isomorphic-ws` / `ws` dependency in favor of the platform's global `WebSocket`; it is meant for test doubles, and for node runtimes older than 22 that have no global `WebSocket` — see [`v9-to-v10-migration-guide-server-side.md`](./v9-to-v10-migration-guide-server-side.md#running-the-ws-client-under-node). Browser and React-Native apps should leave it unset. - `STREAM_LOCAL_TEST_RUN` / `STREAM_LOCAL_TEST_HOST` env-var overrides on `baseURL` still work the same way. - `browser` auto-detection (`typeof window !== 'undefined'`) and the `browser: true | false` override still work the same way. - The subsystem managers constructed on the client (`state`, `notifications`, `uploadManager`, `moderation`, `tokenManager`, `threads`, `polls`, `reminders`, `messageDeliveryReporter`, `messageComposerCache`, `insightMetrics`) are identical in v10. @@ -147,3 +171,4 @@ These are intentionally listed so agents don't "fix" them during migration: 3. For each option key in the `options` object, check whether it's an axios field (`timeout`, `withCredentials`, `httpsAgent`, `headers`, `adapter`, `proxy`, `responseType`, etc. — anything from `AxiosRequestConfig`). If yes, move it under a new `axiosRequestConfig` sub-object. 4. Remove any reads of `client.secret` and any branches gated on `client._isUsingServerAuth()`. 5. Drop any custom `paramsSerializer` you were passing — it has no effect in v10. +6. If the client runs under node and you depended on HTTP keep-alive, add `axiosRequestConfig.httpsAgent` explicitly — v10 no longer creates one for you. diff --git a/v9-to-v10-migration-guide-methods.md b/v9-to-v10-migration-guide-methods.md index fc2e74850e..59a23ab2bb 100644 --- a/v9-to-v10-migration-guide-methods.md +++ b/v9-to-v10-migration-guide-methods.md @@ -30,7 +30,7 @@ The `secret` parameter, `client.secret`, `client._isUsingServerAuth()`, and all The following `StreamChat` methods no longer exist. All were server-side or admin-only. Rewrites should either delete the call site or move it to the server SDK: -`updateAppSettings`, `revokeUserToken`, `revokeUsersToken`, `testPushSettings`, `testSQSSettings`, `testSNSSettings`, `createToken`, `devToken`, user-groups mutations (`createUserGroup` / `getUserGroup` / `searchUserGroups` / `updateUserGroup` / `deleteUserGroup` / `addUserGroupMembers` / `removeUserGroupMembers`) — the read path is renamed, see below, `upsertPushProvider`, `deletePushProvider`, `listPushProviders`, `setPushPreferences`, `_queryFlags`, `_queryFlagReports`, `_reviewFlagReport`, `queryFutureChannelBans`-write paths, `getHookEvents`, `partialUpdateUser`, `deleteUser`, `restoreUsers`, `reactivateUser`, `reactivateUsers`, `deactivateUser`, `deactivateUsers`, `exportUser`, `getSharedLocations`, `translate`, `translateMessage`, `updateFlags`, `queryCampaigns`, `_createImportURL`, `_createImport`, `_getImport`, `_listImports`, `commitMessage`, `queryTeamUsageStats`, `updateLocation`, `updateChannelsBatch`, `deletePredefinedFilter`, `setRetentionPolicy`, `deleteRetentionPolicy`, `getRetentionPolicy`, `getRetentionPolicyRuns`, hand-rolled reminder client methods (`createReminder`/`updateReminder`/`deleteReminder` — see note under `Reminder` handling; the inherited `queryReminders` from `ChatApi` remains but with the generated request shape, not the v9 `QueryRemindersOptions`), `createCommand`/`getCommand`/`updateCommand`/`deleteCommand`/`listCommands`/`createChannelType`/`getChannelType`/`updateChannelType`/`deleteChannelType`/`listChannelTypes`/`exportChannel`/`exportChannels`/`exportUsers`/`getExportChannelStatus`/`getTask`/`enrichURL`/`sendUserCustomEvent`, `deleteChannels`, `deleteUsers`, `createRole`/`listRoles`/`deleteRole` (only `searchRoles` remains, inherited), `getPermission`/`createPermission`/`updatePermission`/`deletePermission`/`listPermissions`, `getBlockList` (only `listBlockLists`/`createBlockList`/`updateBlockList`/`deleteBlockList` remain, inherited), `verifyWebhook`, `verifyAndParseWebhook`, `parseSqs`, `parseSns` (moved — see below), `campaign`, `segment`, `channelBatchUpdater`, `validateServerSideAuth`, `createSegment`, `createUserSegment`, `createChannelSegment`, `getSegment`, `updateSegment`, `addSegmentTargets`, `querySegmentTargets`, `removeSegmentTargets`, `querySegments`, `deleteSegment`, `segmentTargetExists`, `createCampaign`, `getCampaign`, `startCampaign`, `updateCampaign`, `deleteCampaign`, `stopCampaign`, `_normalizeDate`. Note: `queryDrafts`, `queryPolls`, `queryPollVotes`, `queryMessageFlags`, and `markChannelsDelivered` — all of which were hand-rolled in v9 — now come from `ChatApi` inheritance with generated request shapes; they still exist on `client`. +`updateAppSettings`, `revokeUserToken`, `revokeUsersToken`, `testPushSettings`, `testSQSSettings`, `testSNSSettings`, `createToken`, `devToken`, user-groups mutations (`createUserGroup` / `getUserGroup` / `searchUserGroups` / `updateUserGroup` / `deleteUserGroup` / `addUserGroupMembers` / `removeUserGroupMembers`) — the read path is renamed, see below, `upsertPushProvider`, `deletePushProvider`, `listPushProviders`, `setPushPreferences`, `_queryFlags`, `_queryFlagReports`, `_reviewFlagReport`, `queryFutureChannelBans`-write paths, `getHookEvents`, `partialUpdateUser`, `deleteUser`, `restoreUsers`, `reactivateUser`, `reactivateUsers`, `deactivateUser`, `deactivateUsers`, `exportUser`, `getSharedLocations`, `translate`, `translateMessage`, `updateFlags`, `queryCampaigns`, `_createImportURL`, `_createImport`, `_getImport`, `_listImports`, `commitMessage`, `queryTeamUsageStats`, `updateLocation`, `updateChannelsBatch`, `deletePredefinedFilter`, `setRetentionPolicy`, `deleteRetentionPolicy`, `getRetentionPolicy`, `getRetentionPolicyRuns`, hand-rolled reminder client methods (`createReminder`/`updateReminder`/`deleteReminder` — see note under `Reminder` handling; the inherited `queryReminders` from `ChatApi` remains but with the generated request shape, not the v9 `QueryRemindersOptions`), `createCommand`/`getCommand`/`updateCommand`/`deleteCommand`/`listCommands`/`createChannelType`/`getChannelType`/`updateChannelType`/`deleteChannelType`/`listChannelTypes`/`exportChannel`/`exportChannels`/`exportUsers`/`getExportChannelStatus`/`getTask`/`enrichURL`/`sendUserCustomEvent`, `deleteChannels`, `deleteUsers`, `createRole`/`listRoles`/`deleteRole` (only `searchRoles` remains, inherited), `getPermission`/`createPermission`/`updatePermission`/`deletePermission`/`listPermissions`, `getBlockList` (only `listBlockLists`/`createBlockList`/`updateBlockList`/`deleteBlockList` remain, inherited), `verifyWebhook`, `verifyAndParseWebhook`, `parseSqs`, `parseSns` (removed outright — see below), `campaign`, `segment`, `channelBatchUpdater`, `validateServerSideAuth`, `createSegment`, `createUserSegment`, `createChannelSegment`, `getSegment`, `updateSegment`, `addSegmentTargets`, `querySegmentTargets`, `removeSegmentTargets`, `querySegments`, `deleteSegment`, `segmentTargetExists`, `createCampaign`, `getCampaign`, `startCampaign`, `updateCampaign`, `deleteCampaign`, `stopCampaign`, `_normalizeDate`. Note: `queryDrafts`, `queryPolls`, `queryPollVotes`, `queryMessageFlags`, and `markChannelsDelivered` — all of which were hand-rolled in v9 — now come from `ChatApi` inheritance with generated request shapes; they still exist on `client`. ### Renamed / signature-changed @@ -396,6 +396,22 @@ client.uploadImage_(uri, name?, contentType?, user?, axiosRequestConfig?); `uploadFile_` and `uploadImage_` are the direct replacements for v9 code that passed positional args (uri + name + contentType + user + axios config). Ports should prefer these unless the caller wants to switch to the request-object shape. +The **type of `uri` narrowed**, on both these methods and their `Channel` counterparts: + +```ts +// v9 +uploadFile(uri: string | NodeJS.ReadableStream | Buffer | File, ...) +uploadImage(uri: string | NodeJS.ReadableStream | File, ...) + +// v10 +uploadFile_(uri: string | File, ...) +uploadImage_(uri: string | File, ...) +``` + +v10 dropped the `form-data` dependency for the platform's global `FormData`, and with it every node-only input: `Buffer` and readable streams are no longer accepted, and there is no supported node upload path at all (the `Blob` branch is gated on `typeof window !== 'undefined'`, so a cast does not help). Backend uploads move to `@stream-io/node-sdk` — see [`v9-to-v10-migration-guide-server-side.md`](./v9-to-v10-migration-guide-server-side.md#uploads-from-node-are-gone). + +Browser `File` / `Blob` uploads are unchanged. On the React-Native path (a URI string), `contentType` is no longer inferred for you — pass it explicitly. + #### `client.deleteFile` / `client.deleteImage` ```ts @@ -528,27 +544,26 @@ client.updateBlockList(request); client.deleteBlockList(request); ``` -#### Webhook / SNS / SQS helpers +#### Webhook / SNS / SQS helpers — removed outright -Moved off the client to module-level exports (`src/signing.ts`): +An intermediate v10 release candidate moved these off the client to module-level exports on `src/signing.ts`. **The final v10 removes them entirely** — there is no webhook, SNS, or SQS surface left in `stream-chat`: ```ts -// v9 +// v9 — client methods, used client.secret implicitly client.verifyWebhook(requestBody, xSignature); client.verifyAndParseWebhook(rawBody, signature); client.parseSqs(messageBody); client.parseSns(notificationBody); -// v10 — module exports; return WSEvent +// v10-rc — module exports (do not migrate to this; it no longer resolves) import { verifySignature, verifyAndParseWebhook, parseSqs, parseSns } from 'stream-chat'; -verifySignature(body, signature, secret); -verifyAndParseWebhook(rawBody, signature, secret); -parseSqs(messageBody); // SQS deliveries carry no application-level HMAC — decode-only -parseSns(notificationBody); // SNS deliveries carry no application-level HMAC — decode-only +// v10 — nothing to import. Move the handler to @stream-io/node-sdk. ``` -The v9 `verifyWebhook` / `verifyAndParseWebhook` reused `client.secret` implicitly; the v10 module-level replacements require the secret to be passed in. `parseSqs` / `parseSns` do not take a `secret` — Stream never attaches an application-level HMAC to SQS/SNS deliveries; use `verifyAndParseWebhook` for HTTP webhooks when you need signature verification. +Also gone from `stream-chat`, from the same module: `verifySignature`, `CheckSignature`, `gunzipPayload`, `decodeSqsPayload`, `decodeSnsPayload`, `parseEvent`, `InvalidWebhookError`, and `InvalidWebhookErrorMessages`. `signing.ts` now exports exactly one function, `UserFromToken` — the client-side JWT payload decoder. The JWT minting helpers (`JWTUserToken`, `JWTServerToken`, `DevToken`) are gone too. + +Webhook verification is inherently server-side work: it needs the API secret, which v10 refuses to hold. Port the handler to [`@stream-io/node-sdk`](https://github.com/GetStream/stream-node), which keeps the v9 method names on the client (`client.verifyWebhook`, `client.verifyAndParseWebhook`, `client.parseSqs`, `client.parseSns`) and takes the secret from construction. See [`v9-to-v10-migration-guide-server-side.md`](./v9-to-v10-migration-guide-server-side.md#webhook--sns--sqs) for the mapping table. --- @@ -888,7 +903,11 @@ channel.off(callback: EventHandler): void; Callers that imported `EventTypes` need to switch to `EventType` (`EventType = Event['type'] | 'all'`). The `CustomEventTypes` interface is still exported — augment it to add custom event-type keys, same as v9. -#### `channel.sendFile` / `channel.sendImage` / `channel.deleteFile` / `channel.deleteImage` / `channel.getPinnedMessages` / `channel.getMessagesById` / `channel.lastRead` / `channel.countUnread` / `channel.countUnreadMentions` / `channel.lastMessage` / `channel.watch` / `channel.query` +#### `channel.sendFile` / `channel.sendImage` + +Argument list unchanged; the **first parameter's type narrowed** to `string | File` (v9: `string | NodeJS.ReadableStream | Buffer | File` for `sendFile`, `string | NodeJS.ReadableStream | File` for `sendImage`). Same reason and same remedy as [`client.uploadFile` / `client.uploadImage`](#clientuploadfile--clientuploadimage) above: `form-data` is gone, node sources are not accepted, and `contentType` must be passed explicitly on the React-Native URI path. + +#### `channel.deleteFile` / `channel.deleteImage` / `channel.getPinnedMessages` / `channel.getMessagesById` / `channel.lastRead` / `channel.countUnread` / `channel.countUnreadMentions` / `channel.lastMessage` / `channel.watch` / `channel.query` Signatures unchanged. diff --git a/v9-to-v10-migration-guide-other.md b/v9-to-v10-migration-guide-other.md index 8f96c88f66..e5f9e762f9 100644 --- a/v9-to-v10-migration-guide-other.md +++ b/v9-to-v10-migration-guide-other.md @@ -12,7 +12,7 @@ ## TL;DR - **Server-side is gone.** If you construct with a `secret` or call server-only admin endpoints, switch to `@stream-io/node-sdk`. The construction guide has the full list — every feature module below that was server-only is dropped for the same reason. -- One barrel removed from the package root, one added: **`./events` is gone; `./logger` is new.** The `./campaign`, `./channel_batch_updater`, and `./segment` barrels are still exported but the modules are emptied (they contain only a comment pointing at the server SDK) — importing anything by name from them will fail. +- Two barrels removed from the package root, one added: **`./events` and `./base64` are gone; `./logger` is new.** `./signing` survives with exactly one export left, `UserFromToken`. The `./campaign`, `./channel_batch_updater`, and `./segment` barrels are still exported but the modules are emptied (they contain only a comment pointing at the server SDK) — importing anything by name from them will fail. - `Event` (type name) is kept, but its shape widened: `Event = WSEvent | LocalEvent | keyof CustomEventTypes`. `EventPayload<''>` narrows to a specific event. - `EventTypes` (plural) renamed to `EventType` (singular). `CustomEventTypes` interface is unchanged — augment it to add custom event-type keys, same as v9. - Filter payloads now carry **per-endpoint operator constraints** (inline `Filters<{ … }>` on each request type) — previously-permissive filter objects may stop type-checking. Only one operator per field is allowed, and `null` is no longer a valid `$in` element. `QueryPollsFilters`, `QueryVotesFilters`, and `ReminderFilters` were the last hand-written holdouts and now derive from their request types too. @@ -27,9 +27,10 @@ `src/index.ts` barrel changes: -| Removed export barrel | Reason | -| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| `export * from './events'` | `src/events.ts` deleted along with `EVENT_MAP`. Event-type set is now derived from the generated event decoders, no longer a hand-rolled map. | +| Removed export barrel | Reason | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `export * from './events'` | `src/events.ts` deleted along with `EVENT_MAP`. Event-type set is now derived from the generated event decoders, no longer a hand-rolled map. | +| `export * from './base64'` | `src/base64.ts` deleted along with the `base64-js` dependency. `encodeBase64` / `decodeBase64` are gone; `UserFromToken` now decodes through the global `atob`. Take base64 helpers from a package of your own if you were importing these. | | Emptied module (barrel still present, no named exports) | Reason | | ------------------------------------------------------- | ------------------------------------------------------------- | @@ -41,7 +42,13 @@ | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `export * from './logger'` | `chatLoggerSystem`, `LogLevel`, `LogLevelEnum`, `Sink`, `ScopedLogger`, `ChatLoggerScope`, `ConfigureLoggersOptions`. See logging guide. | -Any consumer doing `import { Campaign, Segment, ChannelBatchUpdater, EVENT_MAP } from 'stream-chat'` will fail to resolve. Delete those imports; there is no drop-in replacement in this SDK. `CustomEventTypes` is still exported from `stream-chat` and its interface is unchanged — augment it to declare custom event-type keys the same way as in v9. +Any consumer doing `import { Campaign, Segment, ChannelBatchUpdater, EVENT_MAP, encodeBase64, decodeBase64 } from 'stream-chat'` will fail to resolve. Delete those imports; there is no drop-in replacement in this SDK. `CustomEventTypes` is still exported from `stream-chat` and its interface is unchanged — augment it to declare custom event-type keys the same way as in v9. + +### `./signing` is down to one export + +The barrel is still there, but it holds a **single** function: `UserFromToken`. Everything else it used to carry — `JWTUserToken`, `JWTServerToken`, `DevToken`, `verifySignature`, `CheckSignature`, `verifyAndParseWebhook`, `parseSqs`, `parseSns`, `gunzipPayload`, `decodeSqsPayload`, `decodeSnsPayload`, `parseEvent`, `InvalidWebhookError`, `InvalidWebhookErrorMessages` — needed the API secret or a Node builtin, and went out with the server-side surface. See [`v9-to-v10-migration-guide-server-side.md`](./v9-to-v10-migration-guide-server-side.md). + +`UserFromToken` itself changed implementation: it decodes the JWT payload with the global `atob` instead of the removed `base64-js` helpers. It runs on the `connectUser` path, so older React Native / Hermes targets — Hermes only gained `atob` / `btoa` around React Native 0.74 — must install a base64 polyfill before the first `connectUser`, or connecting throws `ReferenceError: atob is not defined`. Verify with `typeof atob` on the target rather than by version number; browsers, Node 16+, Bun, and Deno all have it natively. --- @@ -67,7 +74,7 @@ Beyond the individual server-side methods listed in the methods guide, entire su | **App-settings mutations** | `updateAppSettings`, `testPushSettings`, `testSQSSettings`, `testSNSSettings`, `translate`, `translateMessage`, `getHookEvents` | removed. `getAppSettings` remains. | | **User admin** | `partialUpdateUser`, `deleteUser`, `restoreUsers`, `reactivateUser(s)`, `deactivateUser(s)`, `exportUser`, `revokeUserToken`, `revokeUsersToken`, `sendUserCustomEvent`, `deleteUsers` | removed | | **Flag admin** | `_queryFlags`, `_queryFlagReports`, `_reviewFlagReport`, `updateFlags` | removed. `queryMessageFlags` remains via `ChatApi` inheritance (generated request shape). User/message flagging by the connected user remains via `client.flagMessage` / `client.flagUser`. | -| **Webhook / SQS / SNS helpers** | `client.verifyWebhook`, `client.verifyAndParseWebhook`, `client.parseSqs`, `client.parseSns` (used `client.secret` implicitly) | Moved to module exports on `./signing`, `secret` now required explicitly. See methods guide for signatures. | +| **Webhook / SQS / SNS helpers** | `client.verifyWebhook`, `client.verifyAndParseWebhook`, `client.parseSqs`, `client.parseSns` (used `client.secret` implicitly) | removed. An intermediate v10 rc moved them to module exports on `./signing`; the final v10 drops them entirely, along with `verifySignature` / `CheckSignature` / `InvalidWebhookError`. Port the handler to `@stream-io/node-sdk`. | | **Misc.** | `commitMessage`, `undeleteMessage`, `getSharedLocations`, `updateLocation`, `getUnreadCountBatch`, `getBlockList`, `enrichURL`, `_normalizeDate`, `validateServerSideAuth`, `_setupConnection`, `_enrichAxiosOptions`, `_logApiRequest`, `_logApiError` | removed | If your call site was gated on `client._isUsingServerAuth()` (which is also removed), delete the branch — it was only ever true on the server-side path. @@ -454,7 +461,7 @@ For any of these that survive as a generated shape, the replacement is the gener For each source file that touches the SDK: -1. **Delete removed imports.** `EVENT_MAP`, `Campaign*`, `Segment*`, `ChannelBatchUpdater`, `Role` (rename), `MessageResponseBase`, `FormatMessageResponse`, `PredefinedFilterSort(Param)`, and any of the removed type utilities. `Event` and `CustomEventTypes` are kept — do not delete them. +1. **Delete removed imports.** `EVENT_MAP`, `Campaign*`, `Segment*`, `ChannelBatchUpdater`, `encodeBase64`, `decodeBase64`, the webhook / JWT helpers from `./signing` (`verifyAndParseWebhook`, `parseSqs`, `parseSns`, `verifySignature`, `CheckSignature`, `InvalidWebhookError`, `JWTUserToken`, `JWTServerToken`, `DevToken`), `Role` (rename), `MessageResponseBase`, `FormatMessageResponse`, `PredefinedFilterSort(Param)`, and any of the removed type utilities. `Event`, `CustomEventTypes`, and `UserFromToken` are kept — do not delete them. 2. **Rename `Role` → `RoleName`** at every import + annotation site. 3. **Rewrite event-handler callback types where needed.** `Event` is still valid (its union widened) — prefer `EventPayload<'…'>` for narrowed access. Custom event-type augmentation still goes on `CustomEventTypes`, unchanged from v9. Rename any imports of the plural `EventTypes` to the singular `EventType`. 4. **Guard `channel.state.membership` reads** with `?.` — it's `undefined` on freshly constructed channels. @@ -464,3 +471,6 @@ For each source file that touches the SDK: 8. **Rename `ReminderManager` call-site keys** `messageId` → `message_id`. Same for any place you were shaping a reminder-event body. 9. **Delete any code that used `client.secret`, `client._isUsingServerAuth()`, `client.setAnonymousUser`, `client.markAllRead`, or assigned to `client.userID`.** Move server-side callers to `@stream-io/node-sdk`. 10. **Rewrite `client.revokeTokens(isoString)`** to `client.revokeTokens(new Date(isoString))`. +11. **Fix upload call sites.** `channel.sendFile` / `sendImage` / `client.uploadFile_` / `uploadImage_` take `string | File` now — no `Buffer`, no readable streams. Pass `contentType` explicitly when the source is a React-Native URI string. +12. **Delete bundler shims** added for `stream-chat`'s Node-only deps (`crypto`, `https`, `zlib`, `jsonwebtoken`, `ws`) — `package.json#browser` is gone because nothing imports them anymore. +13. **Polyfill `atob`** if your React Native / Hermes target lacks it (`typeof atob === 'undefined'`); `UserFromToken` depends on it during `connectUser`. diff --git a/v9-to-v10-migration-guide-server-side.md b/v9-to-v10-migration-guide-server-side.md index c536e093d9..9aa240a660 100644 --- a/v9-to-v10-migration-guide-server-side.md +++ b/v9-to-v10-migration-guide-server-side.md @@ -11,8 +11,9 @@ - Instantiation keeps the v9 shape you already know: `new StreamClient(apiKey, secret, options?)`. The `secret` is now on the _node_ client, not on `stream-chat`. - Namespaces on the client: `client` (common), `client.chat`, `client.video`, `client.moderation`, `client.feeds`. Per-resource instances via `client.chat.channel(type, id)` and `client.video.call(type, id)`. - Token helpers moved 1:1 with new names — `createToken` → `generateUserToken`, `createCallToken` → `generateCallToken`, plus a new `generatePermanentUserToken`. The old names still exist as deprecated aliases. -- Webhook helpers keep their v9 names on the node client — `verifyWebhook`, `verifyAndParseWebhook`, `parseSqs`, `parseSns` — and the secret is pulled from the `StreamClient` you constructed (no per-call secret argument). The same helpers were also stripped out of `stream-chat/signing`: if you were still calling `import { verifyAndParseWebhook } from 'stream-chat'` under v10-rc, that import disappears in the upcoming release — route it through `@stream-io/node-sdk`. +- Webhook helpers keep their v9 names on the node client — `verifyWebhook`, `verifyAndParseWebhook`, `parseSqs`, `parseSns` — and the secret is pulled from the `StreamClient` you constructed (no per-call secret argument). The same helpers were stripped out of `stream-chat/signing`: `import { verifyAndParseWebhook } from 'stream-chat'` no longer resolves in v10 — route it through `@stream-io/node-sdk`. - If your backend also listens to WebSocket events, keep `stream-chat@10` alongside `@stream-io/node-sdk` — see [Two-client hybrid pattern](#two-client-hybrid-pattern). `stream-chat` no longer bundles a WebSocket polyfill: on Node 22+ this Just Works via the platform's global `WebSocket`; on Node 18/20 you inject one via the new [`WebSocketImpl`](#running-the-ws-client-under-node) option. +- Three Node-shaped behaviors disappeared with the dependencies that backed them, and they bite even integrations that never touched a secret: [file uploads from Node](#uploads-from-node-are-gone) (`form-data`), the [keep-alive `https.Agent`](#http-keep-alive-is-no-longer-configured-for-you) (`https`), and the `package.json#browser` field your [bundler shims](#bundler-shims-can-be-deleted) were compensating for. ## Are you actually server-side? @@ -24,6 +25,7 @@ Any one of these means yes — this guide applies to you: - You call any admin method: `createToken`, `devToken`, `revokeUserToken(s)`, `updateAppSettings`, `getAppSettings`, `deleteUser`, `partialUpdateUser(s)`, `restoreUsers`, `deactivateUser(s)`, `reactivateUser(s)`, `exportUser(s)`, `createChannelType`, `updateChannelType`, `deleteChannelType`, `listChannelTypes`, `getChannelType`, `createCommand` / `updateCommand` / `deleteCommand` / `listCommands` / `getCommand`, `createRole` / `deleteRole` / `listRoles`, `createPermission` / `updatePermission` / `deletePermission` / `getPermission` / `listPermissions`, `createBlockList` / `updateBlockList` / `deleteBlockList` / `getBlockList` / `listBlockLists` / `importBlockList`, `upsertPushProvider` / `deletePushProvider` / `listPushProviders`, `checkPush` / `checkSNS` / `checkSQS`, `createImport` / `createImportURL` / `getImport` / `listImports`, retention-policy admin (`setRetentionPolicy` / `deleteRetentionPolicy` / `getRetentionPolicy` / `getRetentionPolicyRuns`), moderation config admin, campaign / segment methods. - You call `verifyWebhook`, `verifyAndParseWebhook`, `parseSqs`, `parseSns`, `verifySignature`, or `CheckSignature` — the JWT helpers `JWTUserToken` / `JWTServerToken` / `DevToken` — these were all removed from `stream-chat/signing`. - You import from removed barrel paths: `stream-chat/dist/.../campaign`, `.../segment`, `.../channel_batch_updater`, `.../events`, `.../base64`. +- You upload files from Node — `channel.sendFile(fs.createReadStream(...))`, `client.uploadFile(buffer, ...)`, or anything else that hands a `Buffer` / readable stream to the upload methods. See [Uploads from Node are gone](#uploads-from-node-are-gone). - You pass `user_id` overrides to per-user methods (`banUser`, `blockUser`, `muteUser`, `flagMessage`, `flagUser`) — those overrides are gone from `stream-chat@10` because they only made sense server-side. - Your code runs under Node (Express, Fastify, Lambda, Cloud Run, cron) with a secret and no user-token provider. @@ -308,9 +310,139 @@ await client.uploadFile({ - `client.validateServerSideAuth()` — the node SDK is always server-side. - Per-call `user_id` overrides on `banUser` / `blockUser` / `flagMessage` / `flagUser` — pass `user_id` in the request payload (all node-sdk mutations that act on behalf of a user take an explicit `user_id`). - `JWTUserToken` / `JWTServerToken` / `DevToken` / `verifySignature` / `CheckSignature` / `InvalidWebhookError` / `InvalidWebhookErrorMessages` re-exported from `stream-chat` — all gone. `signing.ts` on `stream-chat` now exposes only `UserFromToken` (a client-side JWT decoder). Consume equivalents from `@stream-io/node-sdk`. -- `stream-chat` runtime deps that used to underwrite the server surface — `jsonwebtoken`, `ws`, `isomorphic-ws`, `base64-js`, `form-data` — have been dropped from `package.json#dependencies`. If a build tool still complains that `stream-chat` imports these, upgrade to the release that lands the removal. +- `stream-chat` runtime deps that used to underwrite the server surface — `jsonwebtoken`, `ws`, `isomorphic-ws`, `base64-js`, `form-data` — have been dropped from `package.json#dependencies`. `stream-chat@10` imports none of them; if a bundler still resolves one out of your tree, it is a stale lockfile, not the SDK. +- `@types/jsonwebtoken` and `@types/ws` were v9 **runtime** dependencies, so their types leaked into your project for free. They are gone. Any of your own code annotated with `jwt.Secret`, `jwt.SignOptions`, `WebSocket.CloseEvent`, `WebSocket.Data` etc. now needs those packages in your own `devDependencies`. +- The keep-alive `https.Agent` that v9 installed on the axios instance in Node — see [HTTP keep-alive is no longer configured for you](#http-keep-alive-is-no-longer-configured-for-you). +- Node-side file uploads (`fs.createReadStream` / `Buffer`) — see [Uploads from Node are gone](#uploads-from-node-are-gone). +- `TokenManager` no longer throws `'User token can not be empty'`. In v9, `setTokenOrProvider(undefined, user)` for a non-anonymous user failed fast because the manager could only fall back to a secret it no longer has. In v10 the call resolves and the failure surfaces later, from `getToken()`, when the connection is opened. If you relied on the early throw as validation, validate the token yourself before calling `connectUser`. - Hand-rolled event bus & `EVENT_MAP` — the node SDK is REST-only. +## Uploads from Node are gone + +v9 shipped the `form-data` package so the upload helpers could accept Node sources. v10 dropped the dependency and builds a global `FormData` instead, so the accepted input narrowed: + +```ts +// v9 signature +sendFile(uri: string | NodeJS.ReadableStream | Buffer | File, ...) +sendImage(uri: string | NodeJS.ReadableStream | File, ...) + +// v10 signature — both methods, on Channel and on StreamChat +sendFile(uri: string | File, ...) +sendImage(uri: string | File, ...) +``` + +This affects `channel.sendFile`, `channel.sendImage`, `client.uploadFile`, and `client.uploadImage`. Every v9 backend upload pattern is now a type error: + +```ts +// v9 — worked on a backend +const file = fs.createReadStream('./doc.pdf'); +await channel.sendFile(file, 'doc.pdf', 'application/pdf', user); + +// also gone: Buffer sources +await client.uploadFile(await fs.promises.readFile('./doc.pdf'), 'doc.pdf'); +``` + +**Casting your way past the type error will not work.** The remaining implementation has two branches: a `Blob`/`File` branch, and a React-Native branch that wraps a URI string into `{ uri, name, type }`. The `Blob` branch is gated on `typeof window !== 'undefined'`, so under Node — where there is no `window` — even a native `File` falls through to the React-Native branch and produces a malformed multipart part. `stream-chat@10` has **no** Node upload path. + +Use the node SDK, which takes a `File` and does its own multipart encoding — see [Uploads notes](#uploads-notes) above: + +```ts +import { readFile } from 'node:fs/promises'; +import { File } from 'buffer'; + +const buffer = await readFile('./doc.pdf'); + +// client-level upload +await client.uploadFile({ + file: new File([buffer], 'doc.pdf', { type: 'application/pdf' }), + user: { id: 'server' }, +}); + +// channel-scoped upload +await client.chat.channel('messaging', channelId).uploadChannelFile({ + file: new File([buffer], 'doc.pdf', { type: 'application/pdf' }), + user: { id: 'server' }, +}); +``` + +One related change for client-side callers: `contentType` used to be optional-with-defaults, and `form-data` derived the part headers itself. On the React-Native URI path there is nothing left to infer from, so **pass `contentType` explicitly** for URI uploads. Browser `File` / `Blob` uploads still carry their own `type`. + +## HTTP keep-alive is no longer configured for you + +v9 (and the v10 release candidates) imported `node:https` and installed a keep-alive agent on the axios instance whenever the client detected Node: + +```ts +// removed from src/client.ts +httpsAgent: this.node + ? new https.Agent({ keepAlive: true, keepAliveMsecs: 3000 }) + : undefined, +``` + +The `https` import is gone, so axios now falls back to Node's default agent — **one TCP connection and TLS handshake per request**. For a short-lived Lambda this is noise; for a long-running bot or worker that polls the REST API it is a measurable latency and file-descriptor regression. If your process depended on socket reuse, pass your own agent: + +```ts +import https from 'node:https'; +import { StreamChat } from 'stream-chat'; + +const ws = new StreamChat(apiKey, { + allowServerSideConnect: true, + axiosRequestConfig: { + httpsAgent: new https.Agent({ keepAlive: true, keepAliveMsecs: 3000 }), + }, +}); +``` + +Note the nesting: `httpsAgent` moved under `axiosRequestConfig` in v10 — see [`v9-to-v10-migration-guide-client-construction.md`](./v9-to-v10-migration-guide-client-construction.md#httpsagent-location-moved). On the `@stream-io/node-sdk` side the equivalent knob is the `agent` option (an undici `Dispatcher`), not `httpsAgent`. + +## Bundler shims can be deleted + +v9 carried a `package.json#browser` field that zeroed out the Node-only deps so they would not leak into browser / React-Native bundles: + +```json +// removed from package.json +"browser": { + "crypto": false, + "https": false, + "jsonwebtoken": false, + "ws": false, + "zlib": false +} +``` + +Nothing in `stream-chat@10` imports any of them, so the field was deleted outright. If you added build config to compensate — because your bundler ignored the `browser` field, or because you were resolving `stream-chat` through a path that bypassed it — you can now remove it: + +- **webpack / Next.js**: `resolve.fallback: { crypto: false, https: false, zlib: false, ... }` entries added for `stream-chat`. +- **Metro / React Native**: `resolver.extraNodeModules` aliases and `node-libs-react-native` / `react-native-crypto` / `stream-browserify` shims installed for `stream-chat`. +- **Vite / Rollup**: `resolve.alias` entries or `rollup-plugin-node-polyfills` configured for the same five modules. +- **Jest / Vitest**: `moduleNameMapper` entries pointing `jsonwebtoken` or `ws` at a stub. + +Leave them in place only if another dependency needs them. They are no longer needed for `stream-chat`, and none of the five modules appears in its dependency tree anymore. + +## `atob` is now a runtime requirement + +`UserFromToken` — the one helper left in `signing.ts` — used to decode the JWT payload through the bundled `base64-js`. It now calls the global `atob`: + +```ts +export function UserFromToken(token: string) { + const fragments = token.split('.'); + if (fragments.length !== 3) return ''; + const payload = atob(fragments[1]); // was decodeBase64(...) from './base64' + return JSON.parse(payload).user_id as string; +} +``` + +`atob` is global in every browser, in Node 16+, in Bun, and in Deno. The exception is **older React Native builds on Hermes** — Hermes only gained `atob` / `btoa` in a 2024 release (React Native ≈0.74), so anything older has neither. Check your own target rather than trusting a version number: `typeof atob` in a debug build settles it. `UserFromToken` runs on the `connectUser` path, so where it is missing the client throws `ReferenceError: atob is not defined` at connect time. Install a polyfill before the first `connectUser`: + +```ts +// polyfills.ts — imported once, before any stream-chat call +import { decode, encode } from 'base-64'; + +if (typeof global.atob === 'undefined') global.atob = decode; +if (typeof global.btoa === 'undefined') global.btoa = encode; +``` + +The `encodeBase64` / `decodeBase64` helpers that `stream-chat` used to export from `./base64` were removed along with the module — if you were importing them for your own use, take them from a base64 package directly. + ## Sort payloads `@stream-io/node-sdk` uses the **same `SortParamRequest[]` shape** v10 `stream-chat` uses: @@ -428,6 +560,8 @@ const ws = new StreamChat(apiKey, { The cast is because `ws` types its constructor with a slightly different `MessageEvent` payload than the DOM lib. It's not a runtime concern — `StableWSConnection` only touches `.data`, `.code`, `.reason`, `.error`, all of which line up. +One internal detail that matters if you inject `ws`: v9 called `ws.removeAllListeners()` during disconnect and teardown, an `EventEmitter` method the DOM `WebSocket` interface does not have. Those calls are gone. Teardown now relies on `close()` plus an internal `wsID` generation guard that makes callbacks from a superseded socket no-ops, so a `WebSocketImpl` only has to implement the four `on*` properties — it does **not** need `removeAllListeners`, `addEventListener`, or `off`. + > **Officially, `WebSocketImpl` is documented as "purely for testing."** In practice it is also the escape hatch for Node <22 until the LTS ships a native `WebSocket`. If you rely on it in production, pin the `ws` version (it's stable, but its lifecycle isn't tied to `stream-chat`'s releases) and keep an eye on the SDK changelog in case the option gains stricter typing. ### Simplifying the hybrid example on Node 22+ @@ -447,3 +581,8 @@ Before shipping the migrated backend: - [ ] If you kept a `stream-chat@10` client for WS: it is constructed **without** a secret, and `allowServerSideConnect: true` is set. - [ ] If you deploy on Node 18/20: `WebSocketImpl` is wired to the `ws` package and `ws` is pinned in `dependencies`. On Node 22+, no `WebSocketImpl` is needed. - [ ] Bot / worker users are upserted with `role: 'admin'` (or a role that grants the endpoints they need) before their WS token is minted. +- [ ] No `fs.createReadStream(...)`, `Buffer`, or `File` reaches `channel.sendFile` / `sendImage` / `client.uploadFile` / `uploadImage` — every backend upload goes through `@stream-io/node-sdk`. +- [ ] If keep-alive mattered, `axiosRequestConfig.httpsAgent` is set explicitly — the implicit Node keep-alive agent is gone. +- [ ] Bundler / test shims added for `crypto`, `https`, `zlib`, `jsonwebtoken`, `ws` on account of `stream-chat` are removed (`resolve.fallback`, Metro `extraNodeModules`, `moduleNameMapper`). +- [ ] `atob` exists in every runtime you ship to (`typeof atob` in a debug build) — on older React Native / Hermes targets, a polyfill is imported before the first `connectUser`. +- [ ] `@types/jsonwebtoken` / `@types/ws` are in your own `devDependencies` if your code still annotates against them.