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
5 changes: 5 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,8 @@ jobs:

- name: Lint
run: yarn lint

# `tsc` over src, the build scripts, and the compile-time assertions in `test/types/` (which pin
# the `TimestampNS` brand and its `new Date` guard). Nothing else in PR CI typechecks.
- name: Types
run: yarn types
9 changes: 8 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Companion docs that apply to all agents: `AGENTS.md` (general agent rules) and `
| Watch dev build | `yarn start` |
| Typecheck only | `yarn types` |
| Typecheck the build scripts only | `yarn types:scripts` |
| Type assertions (src / built dist) | `yarn types:tests` / `yarn types:dist` |
| Lint (prettier + eslint, zero warnings) | `yarn lint` |
| Auto-fix lint/format | `yarn lint-fix` |
| Unit tests (Vitest) | `yarn test` (alias for `yarn test-unit`) |
Expand All @@ -43,6 +44,12 @@ Single test runs use Vitest's CLI directly: `yarn test-unit path/to/file.test.ts
behind `stream-chat/i18n` now ships as **`@stream-io/i18n`** — but adding a new entry point without
declaring its boundary in `ENTRY_BOUNDARIES` is still an error, which is the point of keeping it.

When both finish, `yarn build` runs `types:dist`: the compile-time assertions in `test/types/` checked
against the built `dist/types`, resolved through `package.json#exports` exactly as a consumer would.
It is what fails a build whose published types lost the `DateConstructor` guard
(`src/gen/models/timestamp-guard.ts`, which reaches consumers only through the type-only re-export in
`src/index.ts`). The same files run against `src/` as `types:tests`, part of `yarn types`.

`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 `react-native` + `require` branch must stay pointed at CJS — React Native's Jest runs CJS with `customConditions: ["react-native"]` and does not transform `node_modules`, so an `.mjs` there is a syntax error across every RN suite that touches the module. `typesVersions` mirrors the subpaths for consumers still on `moduleResolution: "node"`. 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.mts` 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/`.
Expand Down Expand Up @@ -199,7 +206,7 @@ GitHub workflows in `.github/workflows/`:

| Workflow | Trigger | What it runs |
| -------------- | ------------------- | -------------------------------------------------------------- |
| `lint.yml` | PR | `yarn lint` |
| `lint.yml` | PR | `yarn lint`, then `yarn types` |
| `unit.yml` | PR | `yarn test-coverage` |
| `size.yml` | PR (excludes tests) | `preactjs/compressed-size-action` — reports bundle-size diff |
| `pr-check.yml` | PR title change | `commitlint` on the PR title |
Expand Down
13 changes: 9 additions & 4 deletions dev/burst-simulator-extension/simulator.js
Original file line number Diff line number Diff line change
Expand Up @@ -342,10 +342,15 @@
return text;
};

// Wire timestamps are unix nanoseconds, as the API sends them (stream-chat v10+). An ISO string here
// never becomes a finite timestamp, so it would break exactly the ordering and formatting paths a
// burst is meant to exercise.
const nowNs = () => Date.now() * 1e6;

const buildHtml = (text) => `<p>${escapeHtml(text).replace(/\n/g, '<br/>')}</p>\n`;

const buildUserPool = (size) => {
const now = new Date().toISOString();
const now = nowNs();
const users = [];
for (let i = 0; i < size; i++) {
const id = `sim_user_${i}`;
Expand Down Expand Up @@ -374,7 +379,7 @@
};

const buildMessage = (channel, user) => {
const now = new Date().toISOString();
const now = nowNs();
const text = generateText();
return {
id: uuid(),
Expand Down Expand Up @@ -410,7 +415,7 @@

const buildMessageNewEvent = (channel, message) => ({
type: 'message.new',
created_at: new Date().toISOString(),
created_at: nowNs(),
cid: channel.cid,
channel_type: channel.type,
channel_id: channel.id,
Expand All @@ -421,7 +426,7 @@
});

const buildReactionNewEvent = (channel, message, user) => {
const now = new Date().toISOString();
const now = nowNs();
const emoji = pick(EMOJIS);
const reactionType = `emoji-${emoji.code}`;

Expand Down
8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,9 @@
"yaml": "^2.8.4"
},
"scripts": {
"build": "rm -rf dist && concurrently 'tsc' './scripts/bundle.mts'",
"build": "rm -rf dist && concurrently 'tsc' './scripts/bundle.mts' && yarn run types:dist",
"start": "concurrently 'tsc --watch' './scripts/bundle.mts --watch'",
"types": "tsc --noEmit && yarn run types:scripts",
"types": "tsc --noEmit && yarn run types:scripts && yarn run types:tests",
"lint": "yarn run prettier && yarn run eslint",
"lint-fix": "yarn run eslint-fix; yarn run prettier-fix",
"prettier": "prettier '**/*.{json,js,mjs,mts,cjs,ts,yml,md}' --check",
Expand All @@ -95,7 +95,9 @@
"postinstall": "node -e \"require('fs').existsSync('scripts/install-husky.mjs') && import('./scripts/install-husky.mjs')\"",
"prepare": "yarn run build",
"generate-client": "./scripts/generate-client.sh",
"types:scripts": "tsc -p tsconfig.scripts.json"
"types:scripts": "tsc -p tsconfig.scripts.json",
"types:tests": "tsc -p tsconfig.types-test.json",
"types:dist": "tsc -p tsconfig.types-test.dist.json"
},
"engines": {
"node": ">=22.18.0"
Expand Down
5 changes: 2 additions & 3 deletions scripts/generate-client.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,8 @@ rm -rf $OUTPUT_DIR

( cd $CHAT_DIR ; make openapi ; make -C projects/chat-manager build; build/chat-manager openapi generate-client --language ts --spec releases/v2/chat-clientside-api.yaml --output $OUTPUT_DIR --opt typed_filters=true --opt with_request_options=true --opt response_dates_as_number=true)

# apply-custom-data-types matches `export interface` / `}` anchored to column 0,
# but the generator emits them indented — format first so it can track which
# interface each `custom` field belongs to.
# apply-custom-data-types matches `export interface` / `}` anchored to column 0.
# The generator emits them there already; formatting first keeps that true.
yarn exec prettier --write $OUTPUT_DIR/models/index.ts

node ./scripts/apply-custom-data-types.mts -i $OUTPUT_DIR/models/index.ts
Expand Down
10 changes: 5 additions & 5 deletions src/CooldownTimer.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { StateStore } from '@stream-io/state-store';
import { getMessageCreatedAtTimestamp } from './pagination/paginators/MessageIntervalPaginator';
import { nowNs, nsToMs } from './utils/time';
import type { ChannelResponse, LocalMessage } from './types';
import type { ChannelResponse, LocalMessage, TimestampNS } from './types';
import { WithSubscriptions } from './utils/WithSubscriptions';
import type { Channel } from './channel';

Expand All @@ -18,7 +18,7 @@ export type CooldownTimerState = {
* Creation timestamp of the latest message authored by the current user in this channel, in unix
* nanoseconds as the API sends it. Change reported via message.new WS event.
*/
ownLatestMessageTimestamp?: number;
ownLatestMessageTimestamp?: TimestampNS;
/**
* Remaining cooldown in whole seconds (rounded).
*/
Expand Down Expand Up @@ -141,7 +141,7 @@ export class CooldownTimer extends WithSubscriptions {
*
* @param timestamp - Unix nanoseconds, as the API sends it.
*/
public setOwnLatestMessageTimestamp = (timestamp: number | undefined) => {
public setOwnLatestMessageTimestamp = (timestamp: TimestampNS | undefined) => {
this.state.partialNext({ ownLatestMessageTimestamp: timestamp });
this.recalculate();
};
Expand All @@ -155,11 +155,11 @@ export class CooldownTimer extends WithSubscriptions {
messages,
}: {
messages: LocalMessage[];
}): number | undefined {
}): TimestampNS | undefined {
const ownUserId = this.getOwnUserId();
if (!ownUserId) return undefined;

let latest: number | undefined;
let latest: TimestampNS | undefined;
for (let i = messages.length - 1; i >= 0; i -= 1) {
const message = messages[i];
if (message.user?.id !== ownUserId) continue;
Expand Down
7 changes: 5 additions & 2 deletions src/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,11 @@ export class ApiClient {
const remaining = headers['x-ratelimit-remaining'] as string | undefined;
if (remaining) rateLimit.rate_limit_remaining = parseInt(remaining, 10);

const reset = headers['x-ratelimit-reset'] as string | undefined;
if (reset) rateLimit.rate_limit_reset = new Date(reset);
// Unix SECONDS (`strconv.FormatInt(info.Reset, 10)` server-side). `new Date('1700000000')` parses the
// string as a date and is Invalid Date, so convert the number explicitly.
const reset = Number(headers['x-ratelimit-reset']);
if (Number.isFinite(reset) && reset > 0)
rateLimit.rate_limit_reset = new Date(reset * 1000);

return {
response_headers: headers as Record<string, string>,
Expand Down
9 changes: 5 additions & 4 deletions src/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
logChatPromiseExecution,
sanitizeOutgoingAttachments,
} from './utils';
import { msToNs, nowNs } from './utils/time';
import { asTimestampNS, msToNs, nowNs } from './utils/time';
import { normalizeUploadFile } from './upload-utils';
import type { StreamChat } from './client';
import { chatLoggerSystem } from './logger';
Expand Down Expand Up @@ -62,6 +62,7 @@ import type {
SharedLocation,
StreamRequestOptions,
StreamResponse,
TimestampNS,
UnBanUserOptions,
UpdateChannelPartialRequest,
UpdateLiveLocationRequest,
Expand Down Expand Up @@ -1153,7 +1154,7 @@ export class Channel extends WithMessageOperations(ChannelApi) {
/**
* Returns the mute status for the current channel.
*
* @returns An object of the form `{ muted: true | false, createdAt: number | null, expiresAt: number | null }`,
* @returns An object of the form `{ muted: true | false, createdAt: TimestampNS | null, expiresAt: TimestampNS | null }`,
* where the timestamps are unix nanoseconds as the API sends them.
*/
muteStatus() {
Expand Down Expand Up @@ -1733,7 +1734,7 @@ export class Channel extends WithMessageOperations(ChannelApi) {
* defaults to the current user's read state).
* @returns Unread count.
*/
countUnread(lastRead?: number | null) {
countUnread(lastRead?: TimestampNS | null) {
// Nullish, not truthy: `0` is a legitimate wire timestamp (the epoch).
if (lastRead == null) return this.state.unreadCount;
let count = 0;
Expand Down Expand Up @@ -2523,7 +2524,7 @@ export class Channel extends WithMessageOperations(ChannelApi) {
// every "no last read" consumer already treats a missing value.
if (ownUserId && countsAsOwnUnread && !currentReadState[ownUserId]) {
nextReadState[ownUserId] = {
last_read: 0,
last_read: asTimestampNS(0),
unread_messages: 1,
user: (client.user ?? { id: ownUserId }) as UserResponse,
};
Expand Down
9 changes: 5 additions & 4 deletions src/channel_state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
LocalMessage,
MessageResponse,
PendingMessageResponse,
TimestampNS,
UserResponse,
} from './types';
import { AIStates } from './types';
Expand All @@ -17,13 +18,13 @@ type ChannelReadStatus = Record<
string,
{
/** Unix nanoseconds, as the API sends it. */
last_read: number;
last_read: TimestampNS;
unread_messages: number;
user: UserResponse;
first_unread_message_id?: string;
last_read_message_id?: string;
/** Unix nanoseconds, as the API sends it. */
last_delivered_at?: number;
last_delivered_at?: TimestampNS;
last_delivered_message_id?: string;
}
>;
Expand Down Expand Up @@ -110,9 +111,9 @@ export type ChannelDataState = {
export type ChannelMuteStatus = {
muted: boolean;
/** Unix nanoseconds, as the API sends it. */
createdAt: number | null;
createdAt: TimestampNS | null;
/** Unix nanoseconds, as the API sends it. */
expiresAt: number | null;
expiresAt: TimestampNS | null;
};

/**
Expand Down
21 changes: 18 additions & 3 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import type {
SdkIdentifier,
StreamChatOptions,
StreamRequestOptions,
TimestampNS,
TokenOrProvider,
UnBanUserOptions,
UpdateUserPartialRequest,
Expand Down Expand Up @@ -122,6 +123,13 @@ export type ChannelConfigsState = {

export type ClientUser = PartializeAllBut<OwnUserResponse, 'id'> & { anon?: boolean };

/**
* `T`, unless it is a server-sent {@link TimestampNS}: for parameters where a bare `number` means a
* relative offset rather than an instant. `any` passes through (`0 extends 1 & T`), so untyped
* callers are not suddenly rejected.
*/
type NotATimestampNS<T> = 0 extends 1 & T ? T : T extends TimestampNS ? never : T;

export class StreamChat extends ChatApi {
private static _instance?: unknown | StreamChat; // type is undefined|StreamChat, unknown is due to TS limitations with statics
messageDeliveryReporter: MessageDeliveryReporter;
Expand Down Expand Up @@ -2011,6 +2019,10 @@ export class StreamChat extends ChatApi {
/**
* Pins the message.
*
* A `number` is a relative offset in SECONDS, so a server-sent timestamp (`message.pinned_at`,
* `message.pin_expires`) is rejected at compile time rather than read as ~1.79e18 seconds from now.
* Hand one back as a date with `nsToDate(timestamp)`.
*
* @param messageOrMessageId - MessageRequest object or message ID.
* @param timeoutOrExpirationDate - Expiration date or timeout. Use `number` to set the timeout
* in seconds, `string` or `Date` to set the exact expiration date (optional).
Expand All @@ -2021,10 +2033,13 @@ export class StreamChat extends ChatApi {
* into the request (optional).
* @returns The updated message response.
*/
pinMessage(
pinMessage<
Expiration extends null | number | string | Date | undefined = undefined,
PinnedAt extends number | string | Date | undefined = undefined,
>(
messageOrMessageId: string | { id: string },
timeoutOrExpirationDate?: null | number | string | Date,
pinnedAt?: number | string | Date,
timeoutOrExpirationDate?: NotATimestampNS<Expiration>,
pinnedAt?: NotATimestampNS<PinnedAt>,
requestOptions?: StreamRequestOptions,
) {
const id = this._validateAndGetMessageId(
Expand Down
6 changes: 4 additions & 2 deletions src/gen/chat/ChatApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ import type {
SyncRequest,
SyncResponse,
TranslateMessageRequest,
TranslateMessageResponse,
TruncateChannelRequest,
TruncateChannelResponse,
UnblockUsersRequest,
Expand Down Expand Up @@ -1478,7 +1479,7 @@ export class ChatApi {
async translateMessage(
request: TranslateMessageRequest & { id: string },
requestOptions?: StreamRequestOptions,
): Promise<StreamResponse<MessageActionResponse>> {
): Promise<StreamResponse<TranslateMessageResponse>> {
const pathParams = {
id: request?.id,
};
Expand All @@ -1487,7 +1488,7 @@ export class ChatApi {
};

const response = await this.apiClient.sendRequest<
StreamResponse<MessageActionResponse>
StreamResponse<TranslateMessageResponse>
>(
'POST',
'/api/v2/chat/messages/{id}/translate',
Expand Down Expand Up @@ -2122,6 +2123,7 @@ export class ChatApi {
id: request?.id,
is_closed: request?.is_closed,
max_votes_allowed: request?.max_votes_allowed,
team: request?.team,
voting_visibility: request?.voting_visibility,
options: request?.options,
custom: request?.custom,
Expand Down
Loading
Loading