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
61 changes: 33 additions & 28 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import type {
AddUserGroupMembersResponse,
APIErrorResponse,
APIResponse,
AppIdentifier,
AppSettings,
AppSettingsAPIResponse,
BannedUsersFilters,
Expand Down Expand Up @@ -394,6 +395,8 @@ export class StreamChat {
defaultWSTimeout: number;
sdkIdentifier?: SdkIdentifier;
deviceIdentifier?: DeviceIdentifier;
appIdentifier?: AppIdentifier;
private cachedUserAgent?: string;
readonly messageComposerCache: FixedSizeQueueCache<string, MessageComposer>;
private nextRequestAbortController: AbortController | null = null;
/**
Expand Down Expand Up @@ -3709,40 +3712,42 @@ export class StreamChat {
);
}

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/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
8 changes: 8 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4537,6 +4537,14 @@ export type SdkIdentifier = {
*/
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 ({@link SdkIdentifier}) and device
* ({@link DeviceIdentifier}) identifiers.
*/
export type AppIdentifier = { name: string; version?: string };

export type DraftResponse = {
channel_cid: string;
created_at: string;
Expand Down
35 changes: 35 additions & 0 deletions test/unit/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1802,13 +1802,48 @@ describe('X-Stream-Client header', () => {
);
});

it('SDK integration with appIdentifier', () => {
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).toMatchInlineSnapshot(
`"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|client_bundle=browser-esm"`,
);
});

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

expect(userAgent).toMatchInlineSnapshot(
`"stream-chat-js-v1.2.3-node|app=Acme|client_bundle=browser-esm"`,
);
});

it('setUserAgent is now deprecated', () => {
client.setUserAgent('deprecated');
const userAgent = client.getUserAgent();

expect(userAgent).toMatchInlineSnapshot(`"deprecated"`);
});

it('memoizes the result permanently and ignores inputs set after the first call', () => {
const first = client.getUserAgent();
expect(first).toMatchInlineSnapshot(
`"stream-chat-js-v1.2.3-node|client_bundle=browser-esm"`,
);

// Inputs mutated after the first call must be ignored - the user agent is
// computed once and the cached value is returned 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()).toBe(first);
});

describe('getHookEvents', () => {
let clientGetSpy;

Expand Down