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 CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ This is a single-package SDK with **no monorepo**. The public surface is everyth
- **`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. 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()`.
- **Event types.** There is no `events.ts` / `EVENT_MAP` any more (removed in v10). Wire events come from the generated `WSEvent` union (`src/gen/models`) and are decoded by `src/gen/model-decoders/event-decoder-mapping.ts`, so adding one means regenerating rather than hand-editing. `src/types.ts` overlays two non-generated members onto the public `Event` union: `LocalEvent` — `channels.queried`, `connection.changed`, `connection.recovered`, `capabilities.changed`, `message.read_locally`, `offline_reactions.queried`, `live_location_sharing.*`, all dispatched client-side only and never received over the wire — and `ConnectedEvent` (`connection.ok`), which _is_ a wire event but is not published in the OpenAPI spec yet.
- **Event types.** There is no `events.ts` / `EVENT_MAP` any more (removed in v10). Wire events come from the generated `WSEvent` union (`src/gen/models`) so adding one means regenerating rather than hand-editing. There is no runtime decoder layer any more: `--opt response_dates_as_number` types every server-sent date as the unix-nanosecond number the wire already carries, so `src/gen/model-decoders/` (including `event-decoder-mapping.ts`) is no longer emitted at all, and `src/connection.ts` decodes frames with a plain cast. The unit invariant and its traps live in `src/utils/time.ts`; the consumer-facing delta is `v9-to-v10-migration-guide-dates.md`. `src/types.ts` overlays two non-generated members onto the public `Event` union: `LocalEvent` — `channels.queried`, `connection.changed`, `connection.recovered`, `capabilities.changed`, `message.read_locally`, `offline_reactions.queried`, `live_location_sharing.*`, all dispatched client-side only and never received over the wire — and `ConnectedEvent` (`connection.ok`), which _is_ a wire event but is not published in the OpenAPI spec yet.
- **`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.
- **Domain subsystems** (each a folder with its own `index.ts` barrel):
Expand Down
1 change: 1 addition & 0 deletions scripts/apply-custom-data-types.mts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ const CUSTOM_DATA_MAPPING: Record<string, string> = {
ConnectUserDetailsRequest: 'CustomUserData',
EntityCreatorResponse: 'CustomUserData',
FullUserResponse: 'CustomUserData',
MemberUserRequest: 'CustomUserData',
OwnUserResponse: 'CustomUserData',
UserRequest: 'CustomUserData',
UserResponse: 'CustomUserData',
Expand Down
2 changes: 1 addition & 1 deletion scripts/generate-client.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ CHAT_DIR="../chat"

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)
( 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
Expand Down
63 changes: 30 additions & 33 deletions src/CooldownTimer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { StateStore } from './store';
import { getMessageCreatedAtTimestamp } from './pagination/paginators/MessageIntervalPaginator';
import { nowNs, nsToMs } from './utils/time';
import type { ChannelResponse, LocalMessage } from './types';
import { WithSubscriptions } from './utils/WithSubscriptions';
import type { Channel } from './channel';
Expand All @@ -13,24 +15,16 @@ export type CooldownTimerState = {
*/
canSkipCooldown: boolean;
/**
* Latest message creation date authored by the current user in this channel. Change reported via message.new WS event.
* 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.
*/
ownLatestMessageDate?: Date;
ownLatestMessageTimestamp?: number;
/**
* Remaining cooldown in whole seconds (rounded).
*/
cooldownRemaining: number;
};

const toDateOrUndefined = (value: unknown): Date | undefined => {
if (value instanceof Date) return value;
if (typeof value === 'string' || typeof value === 'number') {
const parsed = new Date(value);
if (!Number.isNaN(parsed.getTime())) return parsed;
}
return undefined;
};

export class CooldownTimer extends WithSubscriptions {
public readonly state: StateStore<CooldownTimerState>;
private timeout: ReturnType<typeof setTimeout> | null = null;
Expand All @@ -42,7 +36,7 @@ export class CooldownTimer extends WithSubscriptions {
this.state = new StateStore<CooldownTimerState>({
cooldownConfigSeconds: 0,
cooldownRemaining: 0,
ownLatestMessageDate: undefined,
ownLatestMessageTimestamp: undefined,
canSkipCooldown: false,
});
this.refresh();
Expand All @@ -60,8 +54,8 @@ export class CooldownTimer extends WithSubscriptions {
return this.state.getLatestValue().canSkipCooldown;
}

get ownLatestMessageDate() {
return this.state.getLatestValue().ownLatestMessageDate;
get ownLatestMessageTimestamp() {
return this.state.getLatestValue().ownLatestMessageTimestamp;
}

/**
Expand All @@ -85,7 +79,7 @@ export class CooldownTimer extends WithSubscriptions {
),
);

// `ownLatestMessageDate` comes from the paginator's head interval. Selected on `items` rather than on
// `ownLatestMessageTimestamp` comes from the paginator's head interval. Selected on `items` rather than on
// the derived date: any ingest can change which message is the own-latest, and `refresh` already
// declines to publish unless one of its inputs actually moved.
this.addUnsubscribeFunction(
Expand Down Expand Up @@ -114,18 +108,18 @@ export class CooldownTimer extends WithSubscriptions {
.data ?? {}) as Partial<ChannelResponse>;
const canSkipCooldown = (own_capabilities ?? []).includes('skip-slow-mode');

const ownLatestMessageDate = this.findOwnLatestMessageDate({
const ownLatestMessageTimestamp = this.findOwnLatestMessageTimestamp({
messages: this.channel.messagePaginator.headItems,
});

if (
cooldownConfigSeconds !== this.cooldownConfigSeconds ||
ownLatestMessageDate?.getTime() !== this.ownLatestMessageDate?.getTime() ||
ownLatestMessageTimestamp !== this.ownLatestMessageTimestamp ||
canSkipCooldown !== this.canSkipCooldown
) {
this.state.partialNext({
cooldownConfigSeconds,
ownLatestMessageDate,
ownLatestMessageTimestamp,
canSkipCooldown,
});
}
Expand All @@ -142,11 +136,13 @@ export class CooldownTimer extends WithSubscriptions {
};

/**
* Updates the known latest own message date and recomputes remaining time.
* Prefer calling this when you already know the message date (e.g. from an event).
* Updates the known latest own message timestamp and recomputes remaining time.
* Prefer calling this when you already know it (e.g. from an event).
*
* @param timestamp - Unix nanoseconds, as the API sends it.
*/
public setOwnLatestMessageDate = (date: Date | undefined) => {
this.state.partialNext({ ownLatestMessageDate: date });
public setOwnLatestMessageTimestamp = (timestamp: number | undefined) => {
this.state.partialNext({ ownLatestMessageTimestamp: timestamp });
this.recalculate();
};

Expand All @@ -155,38 +151,39 @@ export class CooldownTimer extends WithSubscriptions {
return client.userId ?? client.user?.id;
}

private findOwnLatestMessageDate({
private findOwnLatestMessageTimestamp({
messages,
}: {
messages: LocalMessage[];
}): Date | undefined {
}): number | undefined {
const ownUserId = this.getOwnUserId();
if (!ownUserId) return undefined;

let latest: Date | undefined;
let latest: number | undefined;
for (let i = messages.length - 1; i >= 0; i -= 1) {
const message = messages[i];
if (message.user?.id !== ownUserId) continue;
const createdAt = toDateOrUndefined(message.created_at);
if (!createdAt) continue;
if (!latest || createdAt.getTime() > latest.getTime()) {
const createdAt = getMessageCreatedAtTimestamp(message);
if (createdAt === null) continue;
if (latest === undefined || createdAt > latest) {
latest = createdAt;
}
if (latest.getTime() > createdAt.getTime()) break;
if (latest > createdAt) break;
}
return latest;
}

private recalculate = () => {
this.clearTimeout();

const { cooldownConfigSeconds, ownLatestMessageDate, canSkipCooldown } =
const { cooldownConfigSeconds, ownLatestMessageTimestamp, canSkipCooldown } =
this.state.getLatestValue();

const timeSinceOwnLastMessage =
ownLatestMessageDate != null
? // prevent negative values
Math.max(0, (Date.now() - ownLatestMessageDate.getTime()) / 1000)
ownLatestMessageTimestamp != null
? // prevent negative values. Both operands are wire timestamps, so the difference is in
// nanoseconds — convert once, here, to the seconds the cooldown config speaks.
Math.max(0, nsToMs(nowNs() - ownLatestMessageTimestamp) / 1000)
: undefined;

const remaining =
Expand Down
101 changes: 74 additions & 27 deletions src/LiveLocationManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
*/

import { withCancellation } from './utils/concurrency';
import { nowNs, nsToMs } from './utils/time';
import { deepFreezeConfig } from './configuration/utils/deepFreezeConfig';
import { StateStore } from './store';
import { ConfigController } from './configuration/ConfigController';
Expand Down Expand Up @@ -39,19 +40,38 @@ export type LiveLocationManagerState = {
messages: Map<MessageId, ScheduledLiveLocationSharing>;
};

const isExpiredLocation = (location: SharedLiveLocationResponse) => {
const endTimeTimestamp = new Date(location.end_at).getTime();
/** `setTimeout` silently clamps a longer delay to 1 ms, so longer waits are armed in steps. */
const MAX_TIMEOUT_MS = 2 ** 31 - 1;

return endTimeTimestamp < Date.now();
};
/**
* Whether the manager can track this location: a finite `end_at` that has not passed. `end_at` is
* optional on the wire, and `NaN < nowNs()` is `false`, so both would otherwise read as live.
*/
const canTrackLocation = (location: SharedLocationResponseData): boolean =>
typeof location.end_at === 'number' &&
Number.isFinite(location.end_at) &&
location.end_at >= nowNs();

/**
* The narrowing form, which replaces the `as` cast at the response boundary. Negating a predicate
* narrows `state.messages` values to `never`, so use `canTrackLocation` for the boolean.
*/
const isTrackableLocation = (
location: SharedLocationResponseData,
): location is SharedLiveLocationResponse => canTrackLocation(location);

/**
* Milliseconds from now until a live location stops sharing. Never negative.
* `end_at` is a wire timestamp, so the subtraction happens in nanoseconds and is converted once.
*/
const msUntilExpiry = (endAt: number) => Math.max(0, nsToMs(endAt - nowNs()));

function isValidLiveLocationMessage(
message?: MessageResponse,
): message is MessageResponse & { shared_location: SharedLiveLocationResponse } {
if (!message || message.type === 'deleted' || !message.shared_location?.end_at)
return false;
if (!message || message.type === 'deleted' || !message.shared_location) return false;

return !isExpiredLocation(message.shared_location as SharedLiveLocationResponse);
return isTrackableLocation(message.shared_location);
}

export type LiveLocationManagerConstructorParameters = {
Expand Down Expand Up @@ -234,20 +254,16 @@ export class LiveLocationManager extends WithSubscriptions {
const { active_live_locations } = await this.client.getUserLiveLocations();
this.state.next({
messages: new Map(
(active_live_locations as SharedLiveLocationResponse[])
.filter((location) => !isExpiredLocation(location))
.map((location) => [
location.message_id,
{
...location,
stopSharingTimeout: setTimeout(
() => {
this.unregisterMessages([location.message_id]);
},
new Date(location.end_at).getTime() - Date.now(),
),
},
]),
active_live_locations.filter(isTrackableLocation).map((location) => [
location.message_id,
{
...location,
stopSharingTimeout: this.scheduleStopSharing(
location.message_id,
location.end_at,
),
},
]),
),
ready: true,
});
Expand Down Expand Up @@ -292,7 +308,8 @@ export class LiveLocationManager extends WithSubscriptions {
const expiredLocations: string[] = [];

for (const [messageId, location] of this.messages) {
if (isExpiredLocation(location)) {
// Drops an absent or non-finite `end_at` too, not just an expired one.
if (!canTrackLocation(location)) {
expiredLocations.push(location.message_id);
continue;
}
Expand Down Expand Up @@ -357,6 +374,38 @@ export class LiveLocationManager extends WithSubscriptions {
return () => subscriptions.forEach((subscription) => subscription.unsubscribe());
}

/**
* Arms the stop-sharing timer, stepping when the expiry is beyond `MAX_TIMEOUT_MS` (~24.9 days).
* A share of any length is valid — only a minimum duration is enforced — and scheduling one in a
* single call was clamped to 1 ms, unregistering it immediately.
*/
private scheduleStopSharing(
messageId: MessageId,
endAt: number,
): ReturnType<typeof setTimeout> | null {
if (!Number.isFinite(endAt)) return null;

const remaining = msUntilExpiry(endAt);
if (remaining <= MAX_TIMEOUT_MS) {
return setTimeout(() => {
this.unregisterMessages([messageId]);
}, remaining);
}

return setTimeout(() => {
this.state.next((currentValue) => {
const current = currentValue.messages.get(messageId);
if (!current) return currentValue; // unregistered while waiting
const messages = new Map(currentValue.messages);
messages.set(messageId, {
...current,
stopSharingTimeout: this.scheduleStopSharing(messageId, endAt),
});
return { ...currentValue, messages };
});
}, MAX_TIMEOUT_MS);
}

private registerMessage(message: MessageResponse) {
if (
!this.client.userId ||
Expand All @@ -369,11 +418,9 @@ export class LiveLocationManager extends WithSubscriptions {
const messages = new Map(currentValue.messages);
messages.set(message.id, {
...message.shared_location,
stopSharingTimeout: setTimeout(
() => {
this.unregisterMessages([message.id]);
},
new Date(message.shared_location.end_at).getTime() - Date.now(),
stopSharingTimeout: this.scheduleStopSharing(
message.id,
message.shared_location.end_at,
),
});
return {
Expand Down
Loading