Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/unit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node: [16, 17, 18]
node: [20, 22]
steps:
- uses: actions/checkout@v3

Expand Down
57 changes: 33 additions & 24 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
import {
APIErrorResponse,
APIResponse,
AppIdentifier,
AppSettings,
AppSettingsAPIResponse,
BannedUsersFilters,
Expand Down Expand Up @@ -283,6 +284,8 @@ export class StreamChat<StreamChatGenerics extends ExtendableGenerics = DefaultG
defaultWSTimeout: number;
sdkIdentifier?: SdkIdentifier;
deviceIdentifier?: DeviceIdentifier;
appIdentifier?: AppIdentifier;
private cachedUserAgent?: string;
private nextRequestAbortController: AbortController | null = null;

/**
Expand Down Expand Up @@ -2945,36 +2948,42 @@ export class StreamChat<StreamChatGenerics extends ExtendableGenerics = DefaultG
);
}

getUserAgent() {
getUserAgent = (): string => {
// An explicit override (deprecated `setUserAgent`) always wins and is never cached.
if (this.userAgent) {
return this.userAgent;
}

const version = process.env.PKG_VERSION;
const clientBundle = process.env.CLIENT_BUNDLE;

let userAgentString = '';
if (this.sdkIdentifier) {
userAgentString = `stream-chat-${this.sdkIdentifier.name}-v${this.sdkIdentifier.version}-llc-v${version}`;
} else {
userAgentString = `stream-chat-js-v${version}-${this.node ? 'node' : 'browser'}`;
// Computed once, then memoized for the client's lifetime - inputs read on
// the first call (sdk/app/device identifiers, build-time env) are not re-read.
if (!this.cachedUserAgent) {
const version = process.env.PKG_VERSION;
const { name: sdkName, version: sdkVersion } = this.sdkIdentifier ?? {};
const { name: appName, version: appVersion } = this.appIdentifier ?? {};
const { os, model: deviceModel } = this.deviceIdentifier ?? {};

const head = sdkName
? `stream-chat-${sdkName}-v${sdkVersion}-llc-v${version}`
: `stream-chat-js-v${version}-${this.node ? 'node' : 'browser'}`;

this.cachedUserAgent = [
head,
// reports the host app, the device OS, the device model, and the picked
// exports bundle, each only when a value is present
...Object.entries({
app: appName,
app_version: appVersion,
os,
device_model: deviceModel,
client_bundle: process.env.CLIENT_BUNDLE,
})
.filter(([, value]) => value && value.length > 0)
.map(([key, value]) => `${key}=${value}`),
].join('|');
}

const { os, model } = this.deviceIdentifier ?? {};

return ([
// reports the device OS, if provided
['os', os],
// reports the device model, if provided
['device_model', model],
// reports which bundle is being picked from the exports
['client_bundle', clientBundle],
] as const).reduce(
(withArguments, [key, value]) =>
value && value.length > 0 ? withArguments.concat(`|${key}=${value}`) : withArguments,
userAgentString,
);
}
return this.cachedUserAgent;
};

/**
* @deprecated use sdkIdentifier instead
Expand Down
7 changes: 7 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3861,6 +3861,13 @@ export type SdkIdentifier = { name: 'react' | 'react-native' | 'expo' | 'angular
*/
export type DeviceIdentifier = { os: string; model?: string };

/**
* An identifier containing information about the downstream application integrating
* stream-chat, if available. `name` is reported as `app` and `version` as `app_version`
* in the user agent. Distinct from the SDK and device identifiers.
*/
export type AppIdentifier = { name: string; version?: string };

export type DraftResponse<StreamChatGenerics extends ExtendableGenerics = DefaultGenerics> = {
channel_cid: string;
created_at: string;
Expand Down
34 changes: 34 additions & 0 deletions test/unit/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,40 @@ describe('X-Stream-Client header', () => {
expect(userAgent).to.be.equal('stream-chat-react-native-v2.3.4-llc-v1.2.3|os=iOS 15.0|device_model=iPhone17,4');
});

it('SDK integration with appIdentifier', () => {
delete process.env.CLIENT_BUNDLE;
client.sdkIdentifier = { name: 'react-native', version: '2.3.4' };
client.appIdentifier = { name: 'Acme', version: '2.1.0' };
client.deviceIdentifier = { os: 'iOS 15.0', model: 'iPhone17,4' };
const userAgent = client.getUserAgent();

// app / app_version are emitted right after the head, before os / device_model.
expect(userAgent).to.be.equal(
'stream-chat-react-native-v2.3.4-llc-v1.2.3|app=Acme|app_version=2.1.0|os=iOS 15.0|device_model=iPhone17,4',
);
});

it('appIdentifier with name only omits the app_version segment', () => {
delete process.env.CLIENT_BUNDLE;
client.appIdentifier = { name: 'Acme' };
const userAgent = client.getUserAgent();

expect(userAgent).to.be.equal('stream-chat-js-v1.2.3-node|app=Acme');
});

it('memoizes the result permanently and ignores inputs set after the first call', () => {
delete process.env.CLIENT_BUNDLE;
const first = client.getUserAgent();
expect(first).to.be.equal('stream-chat-js-v1.2.3-node');

// Inputs mutated after the first call must be ignored - the user agent is
// computed once and cached for the client's lifetime.
client.sdkIdentifier = { name: 'react', version: '2.3.4' };
client.deviceIdentifier = { os: 'iOS 15.0', model: 'iPhone17,4' };

expect(client.getUserAgent()).to.be.equal(first);
});

it('SDK integration with process.env.CLIENT_BUNDLE', () => {
process.env.CLIENT_BUNDLE = 'browser';
client.sdkIdentifier = { name: 'react', version: '2.3.4' };
Expand Down
Loading