diff --git a/sdk/webpubsub-chat-client/.gitignore b/sdk/webpubsub-chat-client/.gitignore index 89827b2e..c266ba67 100644 --- a/sdk/webpubsub-chat-client/.gitignore +++ b/sdk/webpubsub-chat-client/.gitignore @@ -5,3 +5,6 @@ tsconfig.tsbuildinfo .env .yarn *.tgz +# api-extractor working directory +temp +package-lock.json diff --git a/sdk/webpubsub-chat-client/README.md b/sdk/webpubsub-chat-client/README.md index 9949b34a..1d992932 100644 --- a/sdk/webpubsub-chat-client/README.md +++ b/sdk/webpubsub-chat-client/README.md @@ -30,12 +30,12 @@ const client = await ChatClient.start(wpsClient); console.log(`Started as: ${client.userId}`); // Listen for events -client.onMessage((event) => { +client.on('message', (event) => { const msg = event.message; console.log(`${msg.createdBy}: ${msg.content.text}`); }); -client.onRoomJoined((event) => { +client.on('room-joined', (event) => { console.log(`Joined room: ${event.room.title}`); }); @@ -44,7 +44,7 @@ const room = await client.createRoom('My Room', ['bob']); await client.sendToRoom(room.roomId, 'Hello!'); // Get message history (auto-paginating async iterator) -for await (const msg of client.listRoomMessages({ roomId: room.roomId })) { +for await (const msg of client.listRoomMessages(room.roomId)) { console.log(`${msg.createdBy}: ${msg.content.text}`); } @@ -63,34 +63,26 @@ await client.stop(); #### Constructor ```typescript -// With existing WebPubSubClient new ChatClient(wpsClient: WebPubSubClient) - -// With client access URL -new ChatClient(clientAccessUrl: string, options?: WebPubSubClientOptions) - -// With credential -new ChatClient(credential: WebPubSubClientCredential, options?: WebPubSubClientOptions) ``` -To construct and start in one step, prefer the static `ChatClient.start(...)` factory below. +`ChatClient` always wraps a pre-constructed `WebPubSubClient` and owns its lifecycle: `start()` starts the transport, `stop()` stops it. -When constructed from an existing `WebPubSubClient`, `ChatClient` owns that client's lifecycle: `start()` starts it and `stop()` stops it. +To construct from a client-access URL or `WebPubSubClientCredential`, use the static `ChatClient.start(...)` factory below — it builds the underlying `WebPubSubClient` and starts the chat client atomically, so callers never observe a half-constructed instance. #### Static Methods | Method | Description | |--------|-------------| -| `ChatClient.start(clientAccessUrl, options?)` | Create a client and start it in one step | -| `ChatClient.start(credential, options?)` | Create a client from a credential and start it | -| `ChatClient.start(wpsClient)` | Create a client from an existing `WebPubSubClient` and start it | +| `ChatClient.start(clientAccessUrl, webPubSubClientOptions?, options?)` | Construct from a URL and start (`webPubSubClientOptions?: WebPubSubClientOptions`, `options?: StartOptions`) | +| `ChatClient.start(credential, webPubSubClientOptions?, options?)` | Construct from a credential and start (same option shape as above) | +| `ChatClient.start(wpsClient, options?)` | Start a pre-constructed `WebPubSubClient` (`options?: StartOptions`) | #### Properties | Property | Type | Description | |----------|------|-------------| -| `userId` | `string` | Current user's ID (throws if not started) | -| `isStarted` | `boolean` | `true` once `start()` has completed and `stop()` has not been called since | +| `userId` | `string` | Current user's ID (throws if not started). Read-only — set internally on `start()`. | | `rooms` | `RoomInfo[]` | Snapshot of currently joined rooms (not live-updated) | | `connection` | `WebPubSubClient` | Underlying WebPubSub connection owned by this chat client | @@ -98,39 +90,81 @@ When constructed from an existing `WebPubSubClient`, `ChatClient` owns that clie | Method | Description | |--------|-------------| -| `start()` | Connect and authenticate. Idempotent; concurrent calls share one in-flight promise. After `stop()` the client can be started again. | +| `start(options?)` | Connect and authenticate. Idempotent; concurrent calls share one in-flight promise. After `stop()` the client can be started again. Accepts `{ abortSignal }`. | | `stop()` | Disconnect and reset client state. Returns `Promise`. | -| `createRoom(title, members, roomId?)` | Create a new room with initial members. The current user is automatically added to the members list. | -| `getRoom(roomId, withMembers)` | Get room info | -| `addUserToRoom(roomId, userId)` | Add user to room (admin operation) | -| `removeUserFromRoom(roomId, userId)` | Remove user from room (admin operation) | -| `sendToRoom(roomId, message)` | Send text message to room, returns message ID | -| `listRoomMessages(options)` | Paged async iterator over room message history (auto-paginates). Use `for await` to stream every message, or `.byPage({ maxPageSize })` to load up to `maxPageSize` messages at a time. `options = { roomId, startId?, endId?, pageSize? }` | -| `getUserInfo(userId)` | Get user profile | +| `createRoom(title, members, options?)` | Create a new room with initial members. The current user is automatically added to the members list. Options: `{ roomId?, abortSignal? }` — supply `roomId` to choose an explicit id, otherwise the service assigns one. | +| `getRoomDetail(roomId, options?)` | Get room info. Options: `{ withMembers?, abortSignal? }` — set `withMembers: true` to populate the members list (extra round-trip). | +| `addUserToRoom(roomId, userId, options?)` | Add user to room (admin operation) | +| `removeUserFromRoom(roomId, userId, options?)` | Remove user from room (admin operation) | +| `sendToRoom(roomId, message, options?)` | Send text message to room, returns message ID | +| `listRoomMessages(roomId, options?)` | Paged async iterator over room message history (auto-paginates). Use `for await` to stream every message, or `.byPage({ maxPageSize })` to load up to `maxPageSize` messages at a time. `options = { startId?, endId?, maxPageSize?, abortSignal? }` | +| `getUserProfile(userId, options?)` | Get user profile | + +Every asynchronous method accepts an optional final `options` argument +extending `OperationOptions` (`{ abortSignal?: AbortSignalLike }`) to +cancel in-flight invocations: + +```ts +const ac = new AbortController(); +setTimeout(() => ac.abort(), 5000); +await client.sendToRoom(roomId, "hi", { abortSignal: ac.signal }); +``` + +#### Errors + +Operations reject with a `ChatError` (extends `Error`) carrying a +service-defined `code`. Known codes are exposed as `KnownChatErrorCode`; +the service may return additional codes in newer versions, so always +handle the unknown-code case. + +```ts +import { ChatError, KnownChatErrorCode } from "@azure/web-pubsub-chat-client"; + +try { + await client.sendToRoom(roomId, "hi"); +} catch (err) { + if (err instanceof ChatError && err.code === KnownChatErrorCode.UnknownRoom) { + // re-join or refresh local state + } else { + throw err; + } +} +``` + +| Code | Meaning | +|------|---------| +| `KnownChatErrorCode.RoomAlreadyExists` | Tried to create a room whose `roomId` is already in use. | +| `KnownChatErrorCode.UserAlreadyInRoom` | Adding a user that is already a member. | +| `KnownChatErrorCode.NoPermissionInRoom` | Caller lacks permission to perform the operation. | +| `KnownChatErrorCode.NotStarted` | An API was called before `start()` resolved. | +| `KnownChatErrorCode.UnknownRoom` | The room is not in the client's local cache (joined or created). | +| `KnownChatErrorCode.InvalidServerResponse` | The service returned a malformed response. | #### Event Listeners -Chat events are subscribed via a single generic `on(event, callback)` API -or typed convenience methods (`onMessage`, `onRoomJoined`, ...). All -listener registrations return a `Disposable` (`() => void`) that removes -the listener when called. Use `off(event, callback)` to unsubscribe a -specific callback. +Chat events use the same shape as the underlying `WebPubSubClient`: +one `on(event, listener)` overload per event, returning `void`, paired +with `off(event, listener)` for removal. Pass the same callback +reference to `off()` to unregister. ```ts -const dispose = client.on('message', (event) => { +const onMsg = (event) => { console.log(event.message.content.text); -}); +}; +client.on('message', onMsg); // later -dispose(); +client.off('message', onMsg); ``` -| Event name | Convenience method | Payload | Description | -|------------|-------------------|---------|-------------| -| `message` | `onMessage(cb)` | `MessageEvent` | New message received (or sent by this client). | -| `roomJoined` | `onRoomJoined(cb)` | `RoomJoinedEvent` | This client joined a room. | -| `roomLeft` | `onRoomLeft(cb)` | `RoomLeftEvent` | This client left a room. | -| `memberJoined` | `onMemberJoined(cb)` | `MemberJoinedEvent` | Another user joined a room this client is in. | -| `memberLeft` | `onMemberLeft(cb)` | `MemberLeftEvent` | Another user left a room this client is in. | +| Event name | Listener argument | Description | +|------------|-------------------|-------------| +| `started` | `OnStartedArgs` | `start()` completed successfully — `userId` and `rooms` are populated. | +| `stopped` | `OnStoppedArgs` | The client transitioned to not-started (explicit `stop()` or transport-driven). | +| `message` | `OnMessageArgs` | New message received (or sent by this client). | +| `room-joined` | `OnRoomJoinedArgs` | This client joined a room. | +| `room-left` | `OnRoomLeftArgs` | This client left a room. | +| `member-joined` | `OnMemberJoinedArgs` | Another user joined a room this client is in. | +| `member-left` | `OnMemberLeftArgs` | Another user left a room this client is in. | Connection-lifecycle events (`connected`, `disconnected`, `stopped`) live on the underlying `WebPubSubClient`. Subscribe through `client.connection`: diff --git a/sdk/webpubsub-chat-client/api-extractor.json b/sdk/webpubsub-chat-client/api-extractor.json new file mode 100644 index 00000000..bcbd3b5f --- /dev/null +++ b/sdk/webpubsub-chat-client/api-extractor.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + + "mainEntryPointFilePath": "/dist/index.d.ts", + + "apiReport": { + "enabled": true, + "reportFolder": "/review/", + "reportFileName": ".api.md" + }, + + "docModel": { + "enabled": false + }, + + "dtsRollup": { + "enabled": false + }, + + "tsdocMetadata": { + "enabled": false + }, + + "messages": { + "compilerMessageReporting": { + "default": { "logLevel": "warning" } + }, + "extractorMessageReporting": { + "default": { "logLevel": "warning" }, + "ae-missing-release-tag": { "logLevel": "none" } + }, + "tsdocMessageReporting": { + "default": { "logLevel": "warning" } + } + } +} diff --git a/sdk/webpubsub-chat-client/examples/quickstart/client.js b/sdk/webpubsub-chat-client/examples/quickstart/client.js index 41541abe..b51ec010 100644 --- a/sdk/webpubsub-chat-client/examples/quickstart/client.js +++ b/sdk/webpubsub-chat-client/examples/quickstart/client.js @@ -77,7 +77,7 @@ async function main() { // List message history (auto-paginating async iterator) console.log('\n--- Message History ---'); - for await (const msg of alice.listRoomMessages({ roomId: room.roomId })) { + for await (const msg of alice.listRoomMessages(room.roomId)) { console.log(` [${msg.createdBy}] [${msg.createdAt}] ${msg.content.text}`); } @@ -85,7 +85,7 @@ async function main() { // `byPage` lets the caller decide when to load the next batch — handy // for "load 50 latest, then 50 more on scroll-up" UI patterns. console.log('\n--- Message History (pages of 3) ---'); - const pages = alice.listRoomMessages({ roomId: room.roomId }).byPage({ maxPageSize: 3 }); + const pages = alice.listRoomMessages(room.roomId).byPage({ maxPageSize: 3 }); let pageNum = 0; while (true) { const { value, done } = await pages.next(); diff --git a/sdk/webpubsub-chat-client/package.json b/sdk/webpubsub-chat-client/package.json index 4df6dc84..a0175351 100644 --- a/sdk/webpubsub-chat-client/package.json +++ b/sdk/webpubsub-chat-client/package.json @@ -49,9 +49,12 @@ "test:start-server": "node .\\examples\\quickstart\\server.js", "test": "npx tsx --test tests/**/*.ts", "test:integration": "tsx --test tests/integration.test.ts", - "test:one": "tsx --test --test-name-pattern" + "test:one": "tsx --test --test-name-pattern", + "extract-api": "api-extractor run --local", + "check-api": "api-extractor run" }, "dependencies": { + "@azure/abort-controller": "^2.1.2", "@azure/core-paging": "^1.6.2", "@azure/logger": "^1.3.0", "@azure/web-pubsub-client": "1.0.4", @@ -59,6 +62,7 @@ "ws": "^8.0.0" }, "devDependencies": { + "@microsoft/api-extractor": "^7.58.7", "@types/events": "^3.0.3", "@types/node": "^25.0.3", "esbuild": "^0.27.3", diff --git a/sdk/webpubsub-chat-client/review/web-pubsub-chat-client.api.md b/sdk/webpubsub-chat-client/review/web-pubsub-chat-client.api.md new file mode 100644 index 00000000..2cc579e9 --- /dev/null +++ b/sdk/webpubsub-chat-client/review/web-pubsub-chat-client.api.md @@ -0,0 +1,441 @@ +## API Report File for "@azure/web-pubsub-chat-client" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { AbortSignalLike } from '@azure/abort-controller'; +import { PagedAsyncIterableIterator } from '@azure/core-paging'; +import { WebPubSubClient } from '@azure/web-pubsub-client'; +import { WebPubSubClientCredential } from '@azure/web-pubsub-client'; +import { WebPubSubClientOptions } from '@azure/web-pubsub-client'; + +// @public +export interface AddUserToRoomOptions extends OperationOptions { +} + +// @public (undocumented) +export class ChatClient { + constructor(wpsClient: WebPubSubClient); + addUserToRoom(roomId: string, userId: string, options?: AddUserToRoomOptions): Promise; + // (undocumented) + readonly connection: WebPubSubClient; + createRoom(title: string, members: string[], options?: CreateRoomOptions): Promise; + getRoomDetail(roomId: string, options?: GetRoomOptions): Promise; + // (undocumented) + getUserProfile(userId: string, options?: GetUserProfileOptions): Promise; + hasJoinedRoom(roomId: string): boolean; + listRoomMessages(roomId: string, options?: ListRoomMessagesOptions): PagedAsyncIterableIterator; + off(event: "started", listener: (e: OnStartedArgs) => void): void; + // (undocumented) + off(event: "stopped", listener: (e: OnStoppedArgs) => void): void; + // (undocumented) + off(event: "message", listener: (e: OnMessageArgs) => void): void; + // (undocumented) + off(event: "room-joined", listener: (e: OnRoomJoinedArgs) => void): void; + // (undocumented) + off(event: "room-left", listener: (e: OnRoomLeftArgs) => void): void; + // (undocumented) + off(event: "member-joined", listener: (e: OnMemberJoinedArgs) => void): void; + // (undocumented) + off(event: "member-left", listener: (e: OnMemberLeftArgs) => void): void; + on(event: "started", listener: (e: OnStartedArgs) => void): void; + // (undocumented) + on(event: "stopped", listener: (e: OnStoppedArgs) => void): void; + // (undocumented) + on(event: "message", listener: (e: OnMessageArgs) => void): void; + // (undocumented) + on(event: "room-joined", listener: (e: OnRoomJoinedArgs) => void): void; + // (undocumented) + on(event: "room-left", listener: (e: OnRoomLeftArgs) => void): void; + // (undocumented) + on(event: "member-joined", listener: (e: OnMemberJoinedArgs) => void): void; + // (undocumented) + on(event: "member-left", listener: (e: OnMemberLeftArgs) => void): void; + removeUserFromRoom(roomId: string, userId: string, options?: RemoveUserFromRoomOptions): Promise; + get rooms(): RoomInfo[]; + // (undocumented) + sendToRoom(roomId: string, message: string, options?: SendToRoomOptions): Promise; + static start(clientAccessUrl: string, webPubSubClientOptions?: WebPubSubClientOptions, options?: StartOptions): Promise; + // (undocumented) + static start(credential: WebPubSubClientCredential, webPubSubClientOptions?: WebPubSubClientOptions, options?: StartOptions): Promise; + // (undocumented) + static start(wpsClient: WebPubSubClient, options?: StartOptions): Promise; + start(options?: StartOptions): Promise; + stop(): Promise; + // (undocumented) + get userId(): string; +} + +// @public (undocumented) +export class ChatError extends Error { + constructor(message: string, code: string); + // (undocumented) + readonly code: string; +} + +// @public +export interface ChatMessage extends MessageInfo { +} + +// @public (undocumented) +export interface components { + // (undocumented) + headers: never; + // (undocumented) + parameters: never; + // (undocumented) + pathItems: never; + // (undocumented) + requestBodies: never; + // (undocumented) + responses: never; + // (undocumented) + schemas: { + NotificationType: "MessageCreated" | "MessageUpdated" | "MessageDeleted" | "RoomJoined" | "RoomLeft" | "RoomClosed" | "RoomMemberJoined" | "RoomMemberLeft" | "AddContact"; + Notification: { + notificationType: components["schemas"]["NotificationType"]; + body: components["schemas"]["NewMessageNotificationBody"] | components["schemas"]["NewRoomNotificationBody"] | components["schemas"]["UpdateMessageNotificationBody"] | components["schemas"]["AddContactNotificationBody"] | components["schemas"]["MemberJoinedNotificationBody"] | components["schemas"]["MemberLeftNotificationBody"] | components["schemas"]["RoomLeftNotificationBody"]; + }; + NewMessageNotificationBody: { + conversation: components["schemas"]["ChatConversation"]; + message: components["schemas"]["MessageInfo"]; + notificationType: "MessageCreated"; + }; + NewMessageNotification: components["schemas"]["Notification"] & { + notificationType?: "NewMessage"; + }; + NewRoomNotificationBody: { + roomId: string; + title: string; + defaultConversationId?: string; + properties?: Record | null; + notificationType: "RoomJoined"; + }; + NewRoomNotification: components["schemas"]["Notification"] & { + notificationType?: "NewRoom"; + body?: components["schemas"]["NewRoomNotificationBody"]; + }; + UpdateMessageNotificationBody: { + conversation: components["schemas"]["ChatConversation"]; + message: components["schemas"]["MessageInfo"]; + notificationType: "MessageUpdated"; + }; + UpdateMessageNotification: components["schemas"]["Notification"] & { + notificationType?: "UpdateMessage"; + body?: components["schemas"]["UpdateMessageNotificationBody"]; + }; + AddContactNotificationBody: { + userId: string; + notificationType: "AddContact"; + }; + AddContactNotification: components["schemas"]["Notification"] & { + notificationType?: "AddContact"; + body?: components["schemas"]["AddContactNotificationBody"]; + }; + MemberJoinedNotificationBody: { + roomId: string; + title: string; + userId: string; + notificationType: "RoomMemberJoined"; + }; + MemberJoinedNotification: components["schemas"]["Notification"] & { + notificationType?: "MemberJoined"; + body?: components["schemas"]["MemberJoinedNotificationBody"]; + }; + MemberLeftNotificationBody: { + roomId: string; + title: string; + userId: string; + notificationType: "RoomMemberLeft"; + }; + MemberLeftNotification: components["schemas"]["Notification"] & { + notificationType?: "MemberLeft"; + body?: components["schemas"]["MemberLeftNotificationBody"]; + }; + RoomLeftNotificationBody: { + roomId: string; + title: string; + notificationType: "RoomLeft"; + }; + RoomLeftNotification: components["schemas"]["Notification"] & { + notificationType?: "RoomLeft"; + body?: components["schemas"]["RoomLeftNotificationBody"]; + }; + RoomClosedNotificationBody: { + roomId: string; + title: string; + notificationType: "RoomClosed"; + }; + RoomClosedNotification: components["schemas"]["Notification"] & { + notificationType?: "RoomClosed"; + body?: components["schemas"]["RoomClosedNotificationBody"]; + }; + MessageDeletedNotificationBody: { + conversation: components["schemas"]["ChatConversation"]; + messageId: string; + notificationType: "MessageDeleted"; + }; + MessageDeletedNotification: components["schemas"]["Notification"] & { + notificationType?: "MessageDeleted"; + body?: components["schemas"]["MessageDeletedNotificationBody"]; + }; + UserProfile: { + userId: string; + roomIds?: string[]; + conversationIds?: string[]; + }; + ListUserConversationRequest: { + continuationToken?: string | null; + maxCount: number | null; + }; + ListUserConversationResponse: { + conversations: components["schemas"]["ChatConversation"][]; + continuationToken?: string | null; + }; + ChatConversation: { + roomId?: string | null; + topicId?: string | null; + conversationId?: string | null; + }; + ApprovalEnum: "AutoApprove" | "ManualApprove" | "AutoDeny"; + UserPolicy: { + addContact: components["schemas"]["ApprovalEnum"]; + publicProperties?: string[]; + friendProperties?: string[]; + privateProperties?: string[]; + }; + ContactResultState: "OK" | "Pending" | "Failed"; + AddContactResult: { + userId: string; + state: components["schemas"]["ContactResultState"]; + message: string; + }; + ContactRequest: { + userId: string; + message: string; + }; + ContactOperation: "Approve" | "Deny" | "Block"; + ContactRequestOperation: { + operation: components["schemas"]["ContactOperation"]; + userId: string; + }; + RoomInfo: { + roomId: string; + title: string; + defaultConversationId: string; + properties?: Record | null; + }; + RoomInfoWithMembers: components["schemas"]["RoomInfo"] & { + members: string[]; + }; + RoomMemberJoinEnum: "AutoApprove" | "ManualApprove" | "InviteOnly"; + RoomMessagePermissionEnum: "Allow" | "AdminOnly" | "Deny"; + RoomReactPermissionEnum: "Allow" | "Deny"; + RoomPolicy: { + memberJoin: components["schemas"]["RoomMemberJoinEnum"]; + messageTypeText?: components["schemas"]["RoomMessagePermissionEnum"]; + messageTypeImage?: components["schemas"]["RoomMessagePermissionEnum"]; + react?: components["schemas"]["RoomReactPermissionEnum"]; + }; + MessageRangeQuery: { + conversation: components["schemas"]["ChatConversation"]; + start?: string | null; + end?: string | null; + maxCount: number | null; + }; + MessageInfo: { + messageId: string; + createdBy?: string; + createdAt?: string; + bodyType?: string; + messageBodyType: string; + content: { + text?: string | null; + binary?: string | null; + }; + refMessageId?: string | null; + }; + CreateTextMessage: { + conversation: components["schemas"]["ChatConversation"]; + message: string; + refMessageId?: string | null; + extMentions?: string[] | null; + extDeleteAfterRead?: boolean | null; + extScheduled?: string | null; + }; + CreateMessage: { + conversation: components["schemas"]["ChatConversation"]; + messageType: string; + content: { + text?: string | null; + binary?: string | null; + }; + refMessageId?: string | null; + extMentions?: string[] | null; + extDeleteAfterRead?: boolean | null; + extScheduled?: string | null; + }; + MessageBody: { + conversation: components["schemas"]["ChatConversation"]; + messageId: string; + messageType: string; + messageBodyType: string; + content: { + text?: string | null; + binary?: string | null; + }; + refMessageId?: string | null; + }; + JoinRoomState: "OK" | "Pending" | "Failed"; + JoinRoomResult: { + room: string; + state: components["schemas"]["JoinRoomState"]; + message: string; + }; + JoinRoomRequest: { + userId: string; + message: string; + }; + JoinRoomOperationEnum: "Approve" | "Deny" | "Block"; + JoinRoomOperation: { + room: string; + userId: string; + operation: components["schemas"]["JoinRoomOperationEnum"]; + }; + RoomMember: { + userId: string; + role: string; + }; + RoomMemberOperationEnum: "Add" | "Delete" | "Update"; + RoomMemberOperation: { + operation: components["schemas"]["RoomMemberOperationEnum"]; + member: components["schemas"]["RoomMember"]; + }; + RoomMemberOperationType: "Add" | "Delete"; + ManageRoomMemberRequest: { + roomId: string; + operation: components["schemas"]["RoomMemberOperationType"]; + userId: string; + }; + SendMessageResponse: { + id: string; + }; + }; +} + +// @public +export interface CreateRoomOptions extends OperationOptions { + roomId?: string; +} + +// @public +export interface GetRoomOptions extends OperationOptions { + withMembers?: boolean; +} + +// @public +export interface GetUserProfileOptions extends OperationOptions { +} + +// @public +export const KnownChatErrorCode: { + readonly RoomAlreadyExists: "RoomAlreadyExists"; + readonly UserAlreadyInRoom: "UserAlreadyInRoom"; + readonly NoPermissionInRoom: "NoPermissionInRoom"; + readonly NotStarted: "NotStarted"; + readonly UnknownRoom: "UnknownRoom"; + readonly InvalidServerResponse: "InvalidServerResponse"; +}; + +// @public +export interface ListRoomMessagesOptions extends OperationOptions { + endId?: string; + maxPageSize?: number; + startId?: string; +} + +// @public (undocumented) +export type MessageInfo = Schemas["MessageInfo"]; + +// @public +export interface OnMemberJoinedArgs { + // (undocumented) + roomId: string; + // (undocumented) + title: string; + // (undocumented) + userId: string; +} + +// @public +export interface OnMemberLeftArgs { + // (undocumented) + roomId: string; + // (undocumented) + title: string; + // (undocumented) + userId: string; +} + +// @public +export interface OnMessageArgs { + message: ChatMessage; + roomId: string; +} + +// @public +export interface OnRoomJoinedArgs { + // (undocumented) + room: RoomInfo; +} + +// @public +export interface OnRoomLeftArgs { + // (undocumented) + roomId: string; + // (undocumented) + title: string; +} + +// @public +export interface OnStartedArgs { + userId: string; +} + +// @public +export interface OnStoppedArgs { +} + +// @public +export interface OperationOptions { + abortSignal?: AbortSignalLike; +} + +// @public +export interface RemoveUserFromRoomOptions extends OperationOptions { +} + +// @public (undocumented) +export type RoomInfo = Schemas["RoomInfo"]; + +// @public (undocumented) +export type RoomInfoWithMembers = Schemas["RoomInfoWithMembers"]; + +// @public (undocumented) +export type Schemas = components["schemas"]; + +// @public +export interface SendToRoomOptions extends OperationOptions { +} + +// @public +export interface StartOptions extends OperationOptions { +} + +// @public (undocumented) +export type UserProfile = Schemas["UserProfile"]; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/sdk/webpubsub-chat-client/src/chatClient.ts b/sdk/webpubsub-chat-client/src/chatClient.ts index e35ecf26..bf0c359a 100644 --- a/sdk/webpubsub-chat-client/src/chatClient.ts +++ b/sdk/webpubsub-chat-client/src/chatClient.ts @@ -17,17 +17,26 @@ import { RoomLeftNotificationBody, } from "./generatedTypes.js"; import type { - ChatEventListener, - ChatEventName, ChatMessage, - Disposable, - MemberJoinedEvent, - MemberLeftEvent, - MessageEvent, - RoomJoinedEvent, - RoomLeftEvent, + OnMemberJoinedArgs, + OnMemberLeftArgs, + OnMessageArgs, + OnRoomJoinedArgs, + OnRoomLeftArgs, + OnStartedArgs, + OnStoppedArgs, } from "./events.js"; -import type { ListRoomMessagesOptions } from "./options.js"; +import type { + ListRoomMessagesOptions, + OperationOptions, + StartOptions, + GetRoomOptions, + CreateRoomOptions, + SendToRoomOptions, + GetUserProfileOptions, + AddUserToRoomOptions, + RemoveUserFromRoomOptions, +} from "./options.js"; import { ERRORS, INVOCATION_NAME } from "./constant.js"; import { logger } from "./logger.js"; @@ -66,7 +75,7 @@ class ChatClient { private readonly _emitter = new EventEmitter(); private readonly _rooms = new Map(); - protected _conversationIds = new Set(); + private _conversationIds = new Set(); private _userId: string | undefined; private _isStarted = false; private _startPromise: Promise | undefined; @@ -75,36 +84,20 @@ class ChatClient { private _isConnectionStopping = false; /** - * Create a `ChatClient` from a client-access URL. + * Create a `ChatClient` that wraps an existing `WebPubSubClient`. * - * The client is constructed but not started; call `start()` (or use - * the {@link ChatClient.start} static factory) to authenticate. - */ - constructor(clientAccessUrl: string, options?: WebPubSubClientOptions); - /** - * Create a `ChatClient` from a {@link WebPubSubClientCredential}. + * `ChatClient` owns the transport's lifecycle: `start()` starts it + * and `stop()` stops it. The chat client is constructed but not + * started; call `start()` to authenticate, or use the static + * `ChatClient.start(...)` factory to construct-and-start in one step. * - * The client is constructed but not started; call `start()` (or use - * the {@link ChatClient.start} static factory) to authenticate. + * To construct from a client-access URL or `WebPubSubClientCredential`, + * use the corresponding `ChatClient.start(...)` overload — it builds + * the underlying `WebPubSubClient` and starts the chat client + * atomically, so callers never observe a half-constructed instance. */ - constructor(credential: WebPubSubClientCredential, options?: WebPubSubClientOptions); - /** - * Create a `ChatClient` that reuses an existing `WebPubSubClient`. - * - * Passing an existing client gives `ChatClient` lifecycle ownership: - * `start()` starts it and `stop()` stops it. The client is constructed - * but not started; call `start()` to authenticate. - */ - constructor(wpsClient: WebPubSubClient); - - constructor(arg1: string | WebPubSubClientCredential | WebPubSubClient, options?: WebPubSubClientOptions) { - if (isWebPubSubClient(arg1)) { - this.connection = arg1; - } else if (typeof arg1 === "string") { - this.connection = new WebPubSubClient(arg1, options); - } else { - this.connection = new WebPubSubClient(arg1, options); - } + constructor(wpsClient: WebPubSubClient) { + this.connection = wpsClient; this.connection.on("group-message", (e) => { this._handleNotification(e.message.data as Notification); }); @@ -129,9 +122,14 @@ class ChatClient { switch (type) { case "MessageCreated": { const body = data.body as NewMessageNotificationBody; - const event: MessageEvent = { - conversationId: body.conversation.conversationId ?? "", - roomId: body.conversation.roomId ?? undefined, + if (!body.conversation.roomId) { + logger.warning( + `MessageCreated notification missing roomId; skipping emit. conversationId=${body.conversation.conversationId}`, + ); + break; + } + const event: OnMessageArgs = { + roomId: body.conversation.roomId, message: body.message as ChatMessage, }; this._emitter.emit("message", event); @@ -141,21 +139,21 @@ class ChatClient { const roomInfo = data.body as NewRoomNotificationBody as RoomInfo; // Add to _rooms first so listeners can use listRoomMessages this._rooms.set(roomInfo.roomId, roomInfo); - const event: RoomJoinedEvent = { room: roomInfo }; - this._emitter.emit("roomJoined", event); + const event: OnRoomJoinedArgs = { room: roomInfo }; + this._emitter.emit("room-joined", event); break; } case "RoomMemberJoined": { const body = data.body as MemberJoinedNotificationBody; - const event: MemberJoinedEvent = { roomId: body.roomId, title: body.title, userId: body.userId }; - this._emitter.emit("memberJoined", event); + const event: OnMemberJoinedArgs = { roomId: body.roomId, title: body.title, userId: body.userId }; + this._emitter.emit("member-joined", event); break; } // someone (not self) left a specific room case "RoomMemberLeft": { const body = data.body as MemberLeftNotificationBody; - const event: MemberLeftEvent = { roomId: body.roomId, title: body.title, userId: body.userId }; - this._emitter.emit("memberLeft", event); + const event: OnMemberLeftArgs = { roomId: body.roomId, title: body.title, userId: body.userId }; + this._emitter.emit("member-left", event); break; } // self left a specific room @@ -164,8 +162,8 @@ class ChatClient { if (!this._rooms.has(body.roomId)) { break; } - const event: RoomLeftEvent = { roomId: body.roomId, title: body.title }; - this._emitter.emit("roomLeft", event); + const event: OnRoomLeftArgs = { roomId: body.roomId, title: body.title }; + this._emitter.emit("room-left", event); this._rooms.delete(body.roomId); break; } @@ -185,10 +183,17 @@ class ChatClient { } /** Invoke server event and return typed data */ - private async invokeWithReturnType(eventName: string, payload: any, dataType: WebPubSubDataType): Promise { + private async invokeWithReturnType( + eventName: string, + payload: any, + dataType: WebPubSubDataType, + options?: OperationOptions, + ): Promise { logger.verbose(`invoke event: '${eventName}', dataType: ${dataType}, payload:`, payload); try { - const rawResponse = await this.connection.invokeEvent(eventName, payload, dataType); + const rawResponse = await this.connection.invokeEvent(eventName, payload, dataType, { + abortSignal: options?.abortSignal, + }); logger.verbose(`invoke response for '${eventName}':`, rawResponse); const data = rawResponse.data as any; if (data && typeof data === "object" && typeof data.code === "string") { @@ -207,22 +212,66 @@ class ChatClient { /** * Create a chat client and `start()` it in one step. * - * Overloads accept the same arguments as the constructor. The - * returned promise resolves to a started client. + * Construction options for the underlying transport and the cancellation + * token for the start operation are kept as **separate parameters** so + * they never collide at the type level. `webPubSubClientOptions` is the + * full `WebPubSubClientOptions` bag (`protocol`, `autoReconnect`, + * `reconnectRetryOptions`, keep-alive intervals, ...). `options` is the + * same {@link StartOptions} the instance `start()` accepts. + * + * ```ts + * // Most callers only need the URL. + * const chat = await ChatClient.start(url); + * + * // Customise the transport, then start. + * const chat = await ChatClient.start( + * url, + * { autoReconnect: false, reconnectRetryOptions: { maxRetries: 5 } }, + * { abortSignal }, + * ); + * + * // Already have a WebPubSubClient? Hand it in directly. + * const chat = await ChatClient.start(wpsClient, { abortSignal }); + * ``` + * + * Why two parameters instead of an intersection? `WebPubSubClientOptions` + * ships from a separate package (`@azure/web-pubsub-client`) on its own + * release cadence, so intersecting it with our `StartOptions` would be + * fragile against upstream field additions (most obviously `abortSignal`). + * Keeping the two bags positional preserves the full set of + * construction knobs without exposing the intersection. */ - public static async start(clientAccessUrl: string, options?: WebPubSubClientOptions): Promise; - public static async start(credential: WebPubSubClientCredential, options?: WebPubSubClientOptions): Promise; - public static async start(wpsClient: WebPubSubClient): Promise; - public static async start(arg1: string | WebPubSubClientCredential | WebPubSubClient, options?: WebPubSubClientOptions): Promise { + public static async start( + clientAccessUrl: string, + webPubSubClientOptions?: WebPubSubClientOptions, + options?: StartOptions, + ): Promise; + public static async start( + credential: WebPubSubClientCredential, + webPubSubClientOptions?: WebPubSubClientOptions, + options?: StartOptions, + ): Promise; + public static async start(wpsClient: WebPubSubClient, options?: StartOptions): Promise; + public static async start( + arg1: string | WebPubSubClientCredential | WebPubSubClient, + arg2?: WebPubSubClientOptions | StartOptions, + arg3?: StartOptions, + ): Promise { let chatClient: ChatClient; - if (typeof arg1 === "string") { - chatClient = new ChatClient(arg1, options); - } else if (isWebPubSubClient(arg1)) { + let startOptions: StartOptions | undefined; + if (isWebPubSubClient(arg1)) { chatClient = new ChatClient(arg1); + startOptions = arg2 as StartOptions | undefined; + } else if (typeof arg1 === "string") { + const wpsClient = new WebPubSubClient(arg1, arg2 as WebPubSubClientOptions | undefined); + chatClient = new ChatClient(wpsClient); + startOptions = arg3; } else { - chatClient = new ChatClient(arg1, options); + const wpsClient = new WebPubSubClient(arg1, arg2 as WebPubSubClientOptions | undefined); + chatClient = new ChatClient(wpsClient); + startOptions = arg3; } - await chatClient.start(); + await chatClient.start({ abortSignal: startOptions?.abortSignal }); return chatClient; } @@ -234,8 +283,11 @@ class ChatClient { * calls made on an already-started client resolve immediately. After * `stop()` the client can be started again; state from the previous * session is reset. + * + * @param options - Cancellation token for the start operation. Aborting + * leaves the client in its initial (not-started) state. */ - public async start(): Promise { + public async start(options?: StartOptions): Promise { if (this._startPromise) return this._startPromise; if (this._isStarted) return; @@ -246,7 +298,7 @@ class ChatClient { if (this._isStarted) return; } - const startPromise = this.startCore(); + const startPromise = this.startCore(options); this._startPromise = startPromise; try { await startPromise; @@ -257,21 +309,31 @@ class ChatClient { } } - private async startCore(): Promise { + private async startCore(options?: StartOptions): Promise { this.resetState(); try { - await this.connection.start(); + await this.connection.start({ abortSignal: options?.abortSignal }); this._connectionStoppedTCS = new PromiseCompletionSource(); this._isConnectionStopping = false; - const loginResponse = await this.invokeWithReturnType(INVOCATION_NAME.LOGIN, "", "text"); + const loginResponse = await this.invokeWithReturnType( + INVOCATION_NAME.LOGIN, + "", + "text", + options, + ); logger.info("loginResponse", loginResponse); const conversationIds = new Set(loginResponse.conversationIds || []); const roomInfos = await Promise.all( (loginResponse.roomIds || []).map(async (roomId) => { - const roomInfo = await this.invokeWithReturnType(INVOCATION_NAME.GET_ROOM, { id: roomId, withMembers: false }, "json"); + const roomInfo = await this.invokeWithReturnType( + INVOCATION_NAME.GET_ROOM, + { id: roomId, withMembers: false }, + "json", + options, + ); return { roomId, roomInfo }; - }) + }), ); this._userId = loginResponse.userId; @@ -280,6 +342,8 @@ class ChatClient { this._rooms.set(roomId, roomInfo); }); this._isStarted = true; + const startedEvent: OnStartedArgs = { userId: loginResponse.userId }; + this._emitter.emit("started", startedEvent); } catch (err) { this.resetState(); await this.stopConnection(); @@ -287,41 +351,55 @@ class ChatClient { } } - /** Whether `start()` has completed successfully and `stop()` has not been called since. */ - public get isStarted(): boolean { - return this._isStarted; - } - private ensureStarted(): void { if (!this._isStarted) { - throw new Error("Not started. Please call start() first."); + throw new ChatError("Not started. Please call start() first.", ERRORS.NotStarted); } } - public async getUserInfo(userId: string): Promise { + public async getUserProfile(userId: string, options?: GetUserProfileOptions): Promise { this.ensureStarted(); - return this.invokeWithReturnType(INVOCATION_NAME.GET_USER_PROPERTIES, { userId: userId }, "json"); - } - - public async sendToConversation(conversationId: string, message: string): Promise { + return this.invokeWithReturnType( + INVOCATION_NAME.GET_USER_PROPERTIES, + { userId: userId }, + "json", + options, + ); + } + + private async sendToConversation( + conversationId: string, + message: string, + options?: OperationOptions, + ): Promise { this.ensureStarted(); const payload = { conversation: { conversationId: conversationId }, content: message, }; - const resp = await this.invokeWithReturnType(INVOCATION_NAME.SEND_TEXT_MESSAGE, payload, "json"); + const resp = await this.invokeWithReturnType( + INVOCATION_NAME.SEND_TEXT_MESSAGE, + payload, + "json", + options, + ); if (!resp || !resp.id) { - throw new Error(`Failed to send message to conversation ${conversationId}, got invalid invoke response: ${JSON.stringify(resp)}`); + throw new ChatError( + `Failed to send message to conversation ${conversationId}, got invalid invoke response: ${JSON.stringify(resp)}`, + ERRORS.InvalidServerResponse, + ); } const msgId = resp.id; const roomId = Array.from(this._rooms.values()).find((r) => r.defaultConversationId === conversationId)?.roomId; if (!roomId) { - logger.warning(`Failed to find roomId for conversationId ${conversationId} when sending message.`); + logger.warning( + `Failed to find roomId for conversationId ${conversationId} when sending message; skipping local sender-echo emit.`, + ); + return msgId; } // sender won't receive conversation message via notification mechanism, so emit event here - const event: MessageEvent = { - conversationId: conversationId, - roomId: roomId, + const event: OnMessageArgs = { + roomId, message: { messageId: msgId, createdBy: this.userId, @@ -336,51 +414,85 @@ class ChatClient { return msgId; } - public async sendToRoom(roomId: string, message: string): Promise { + public async sendToRoom(roomId: string, message: string, options?: SendToRoomOptions): Promise { this.ensureStarted(); const conversationId = this._rooms.get(roomId)?.defaultConversationId; if (!conversationId) { - throw Error(`Failed to sendToRoom, not found roomId ${roomId}`); + throw new ChatError(`Failed to sendToRoom, not found roomId ${roomId}`, ERRORS.UnknownRoom); } - return await this.sendToConversation(conversationId, message); + return await this.sendToConversation(conversationId, message, options); } - public async getRoom(roomId: string, withMembers: boolean): Promise { + /** + * Fetch the latest service-side view of a room. + * + * @param roomId - Room to query. + * @param options - Optional `{ withMembers, abortSignal }`. When + * `withMembers` is `true` the returned `members` array is + * populated; defaults to `false` to save a round-trip. + */ + public async getRoomDetail(roomId: string, options?: GetRoomOptions): Promise { this.ensureStarted(); - return this.invokeWithReturnType(INVOCATION_NAME.GET_ROOM, { id: roomId, withMembers: withMembers }, "json"); + return this.invokeWithReturnType( + INVOCATION_NAME.GET_ROOM, + { id: roomId, withMembers: options?.withMembers ?? false }, + "json", + options, + ); } - /** Create a room and its initial members. If `roomId` is not set, the service will create a random one. */ - public async createRoom(title: string, members: string[], roomId?: string): Promise { + /** + * Create a room and its initial members. The current user is always + * included in the resulting member list. + * + * @param title - Display title for the room. + * @param members - Other user ids to invite. The caller is added + * automatically; duplicates are de-duplicated. + * @param options - Optional `{ roomId, abortSignal }`. Pass `roomId` + * to choose an id explicitly; omit to let the service assign one. + */ + public async createRoom( + title: string, + members: string[], + options?: CreateRoomOptions, + ): Promise { this.ensureStarted(); let roomDetails = { title: title, members: [...new Set([...members, this.userId])], // deduplicate and add self } as any; - if (roomId) { - roomDetails = { ...roomDetails, roomId: roomId }; + if (options?.roomId) { + roomDetails = { ...roomDetails, roomId: options.roomId }; } - const roomInfo = await this.invokeWithReturnType(INVOCATION_NAME.CREATE_ROOM, roomDetails, "json"); + const roomInfo = await this.invokeWithReturnType( + INVOCATION_NAME.CREATE_ROOM, + roomDetails, + "json", + options, + ); this._rooms.set(roomInfo.roomId, roomInfo); - const event: RoomJoinedEvent = { room: roomInfo }; - this._emitter.emit("roomJoined", event); + const event: OnRoomJoinedArgs = { room: roomInfo }; + this._emitter.emit("room-joined", event); return roomInfo; } - private async manageRoomMember(request: ManageRoomMemberRequest): Promise { - await this.invokeWithReturnType(INVOCATION_NAME.MANAGE_ROOM_MEMBER, request, "json"); + private async manageRoomMember( + request: ManageRoomMemberRequest, + options?: OperationOptions, + ): Promise { + await this.invokeWithReturnType(INVOCATION_NAME.MANAGE_ROOM_MEMBER, request, "json", options); } - private async ensureRoomCached(roomId: string): Promise { + private async ensureRoomCached(roomId: string, options?: OperationOptions): Promise { if (this._rooms.has(roomId)) { return; } - const roomInfo = await this.getRoom(roomId, false); + const roomInfo = await this.getRoomDetail(roomId, options); this._rooms.set(roomId, roomInfo); } /** Add a user to a room. This is an admin operation where one user adds another user to a room. */ - public async addUserToRoom(roomId: string, userId: string): Promise { + public async addUserToRoom(roomId: string, userId: string, options?: AddUserToRoomOptions): Promise { this.ensureStarted(); const payload: ManageRoomMemberRequest = { roomId: roomId, operation: "Add", userId: userId }; const isSelf = userId === this.userId; @@ -388,30 +500,30 @@ class ChatClient { // from the service so sendToRoom can use its conversation id. const shouldCacheRoomAfterSelfAdd = isSelf && !this._rooms.has(roomId); try { - await this.manageRoomMember(payload); + await this.manageRoomMember(payload, options); } catch (error) { - if (!isSelf || !(error instanceof ChatError && error.code === ERRORS.USER_ALREADY_IN_ROOM)) { + if (!isSelf || !(error instanceof ChatError && error.code === ERRORS.UserAlreadyInRoom)) { throw error; } } if (shouldCacheRoomAfterSelfAdd) { - await this.ensureRoomCached(roomId); + await this.ensureRoomCached(roomId, options); } } /** Remove a user from a room. This is an admin operation where one user removes another user from a room. */ - public async removeUserFromRoom(roomId: string, userId: string): Promise { + public async removeUserFromRoom(roomId: string, userId: string, options?: RemoveUserFromRoomOptions): Promise { this.ensureStarted(); const payload: ManageRoomMemberRequest = { roomId: roomId, operation: "Delete", userId: userId }; - await this.manageRoomMember(payload); + await this.manageRoomMember(payload, options); // RoomLeft notification is not guaranteed for server-managed membership; // eagerly clean up local cache and emit RoomLeft so callers see consistent state immediately. if (userId === this.userId) { const roomInfo = this._rooms.get(roomId); if (roomInfo) { this._rooms.delete(roomId); - const event: RoomLeftEvent = { roomId, title: roomInfo.title }; - this._emitter.emit("roomLeft", event); + const event: OnRoomLeftArgs = { roomId, title: roomInfo.title }; + this._emitter.emit("room-left", event); } } } @@ -425,7 +537,7 @@ class ChatClient { * * @example Stream every message (e.g. for export or full sync): * ```ts - * for await (const msg of client.listRoomMessages({ roomId })) { + * for await (const msg of client.listRoomMessages(roomId)) { * console.log(msg.content.text); * } * ``` @@ -433,7 +545,7 @@ class ChatClient { * @example Load history one page at a time (Teams-style scroll-back): * ```ts * // Load up to 50 messages per page. - * const pages = client.listRoomMessages({ roomId }).byPage({ maxPageSize: 50 }); + * const pages = client.listRoomMessages(roomId).byPage({ maxPageSize: 50 }); * const first = await pages.next(); * displayMessages(first.value); * // later, when the user scrolls up: @@ -443,18 +555,18 @@ class ChatClient { * * The room must be one this client has created or joined. */ - public listRoomMessages(options: ListRoomMessagesOptions): PagedAsyncIterableIterator { + public listRoomMessages(roomId: string, options?: ListRoomMessagesOptions): PagedAsyncIterableIterator { this.ensureStarted(); - const conversationId = this._rooms.get(options.roomId)?.defaultConversationId; + const conversationId = this._rooms.get(roomId)?.defaultConversationId; if (!conversationId) { - throw new Error(`Failed to listRoomMessages, not found roomId ${options.roomId}`); + throw new ChatError(`Failed to listRoomMessages, not found roomId ${roomId}`, ERRORS.UnknownRoom); } - const defaultPageSize = options.pageSize ?? 100; + const defaultPageSize = options?.maxPageSize ?? 100; const firstPageLink: MessageRangeQuery = { conversation: { conversationId }, - start: options.startId ?? null, - end: options.endId ?? null, + start: options?.startId ?? null, + end: options?.endId ?? null, maxCount: defaultPageSize, }; @@ -470,6 +582,7 @@ class ChatClient { INVOCATION_NAME.LIST_MESSAGES, query, "json", + { abortSignal: options?.abortSignal }, ); if (result.messages.length === 0) { return undefined; @@ -495,57 +608,52 @@ class ChatClient { public get userId(): string { if (!this._userId) { - throw new Error("User ID is not set. Please call start() first."); + throw new ChatError("User ID is not set. Please call start() first.", ERRORS.NotStarted); } return this._userId; } /** - * Subscribe to a chat client event. Returns a disposer that removes the - * listener when called. + * Subscribe to a chat-client event. + * + * Mirrors the underlying `WebPubSubClient.on(event, listener)` shape: + * one explicit overload per event, returns `void`, paired with + * `off(event, listener)` for removal. Pass the same callback + * reference to `off()` to unsubscribe. * * Connection-lifecycle events (`connected`, `disconnected`, `stopped`) * are not exposed here — subscribe via * `chatClient.connection.on("connected", ...)` etc. * * @example - * const dispose = client.on("message", (e) => console.log(e.message.content.text)); + * ```ts + * const onMsg = (e: OnMessageArgs) => console.log(e.message.content.text); + * client.on("message", onMsg); * // later - * dispose(); + * client.off("message", onMsg); + * ``` */ - public on(event: K, callback: ChatEventListener): Disposable { - this._emitter.on(event, callback as any); - return () => this._emitter.off(event, callback as any); - } - - /** Remove a listener previously registered with {@link on}. */ - public off(event: K, callback: ChatEventListener): void { - this._emitter.off(event, callback as any); - } - - /** Subscribe to new messages (including the sender-side event emitted by `sendToRoom` / `sendToConversation`). */ - public onMessage(callback: ChatEventListener<"message">): Disposable { - return this.on("message", callback); - } - - /** Subscribe to room-join events for this client (created or invited). */ - public onRoomJoined(callback: ChatEventListener<"roomJoined">): Disposable { - return this.on("roomJoined", callback); - } - - /** Subscribe to events where this client leaves a room. */ - public onRoomLeft(callback: ChatEventListener<"roomLeft">): Disposable { - return this.on("roomLeft", callback); - } - - /** Subscribe to events where another user joins a room this client is in. */ - public onMemberJoined(callback: ChatEventListener<"memberJoined">): Disposable { - return this.on("memberJoined", callback); - } - - /** Subscribe to events where another user leaves a room this client is in. */ - public onMemberLeft(callback: ChatEventListener<"memberLeft">): Disposable { - return this.on("memberLeft", callback); + public on(event: "started", listener: (e: OnStartedArgs) => void): void; + public on(event: "stopped", listener: (e: OnStoppedArgs) => void): void; + public on(event: "message", listener: (e: OnMessageArgs) => void): void; + public on(event: "room-joined", listener: (e: OnRoomJoinedArgs) => void): void; + public on(event: "room-left", listener: (e: OnRoomLeftArgs) => void): void; + public on(event: "member-joined", listener: (e: OnMemberJoinedArgs) => void): void; + public on(event: "member-left", listener: (e: OnMemberLeftArgs) => void): void; + public on(event: string, listener: (e: any) => void): void { + this._emitter.on(event, listener); + } + + /** Remove a listener previously registered with `on()`. */ + public off(event: "started", listener: (e: OnStartedArgs) => void): void; + public off(event: "stopped", listener: (e: OnStoppedArgs) => void): void; + public off(event: "message", listener: (e: OnMessageArgs) => void): void; + public off(event: "room-joined", listener: (e: OnRoomJoinedArgs) => void): void; + public off(event: "room-left", listener: (e: OnRoomLeftArgs) => void): void; + public off(event: "member-joined", listener: (e: OnMemberJoinedArgs) => void): void; + public off(event: "member-left", listener: (e: OnMemberLeftArgs) => void): void; + public off(event: string, listener: (e: any) => void): void { + this._emitter.off(event, listener); } /** @@ -555,6 +663,10 @@ class ChatClient { * be started again via `start()`. Callers that want the same identity * should keep their authentication source (URL or credential) * constant. + * + * Stopping is not cancellable: it tears down the transport and clears + * local state, mirroring the underlying `WebPubSubClient.stop()`, which + * takes no options. */ public async stop(): Promise { const startPromise = this._startPromise; @@ -567,11 +679,22 @@ class ChatClient { await this.stopConnection(); } + /** + * Reset chat-domain state. Emits `"stopped"` exactly once per + * started → not-started transition: if `_isStarted` was already + * false on entry (e.g. the pre-start guard inside `startCore()` or + * the post-failure rollback), no event fires. + */ private resetState(): void { + const wasStarted = this._isStarted; this._isStarted = false; this._userId = undefined; this._rooms.clear(); this._conversationIds.clear(); + if (wasStarted) { + const stoppedEvent: OnStoppedArgs = {}; + this._emitter.emit("stopped", stoppedEvent); + } } private async stopConnection(): Promise { diff --git a/sdk/webpubsub-chat-client/src/constant.ts b/sdk/webpubsub-chat-client/src/constant.ts index ad8273db..5ba3bf34 100644 --- a/sdk/webpubsub-chat-client/src/constant.ts +++ b/sdk/webpubsub-chat-client/src/constant.ts @@ -11,9 +11,12 @@ const INVOCATION_NAME = { } as const; const ERRORS = { - ROOM_ALREADY_EXISTS: "RoomAlreadyExists", - USER_ALREADY_IN_ROOM: "UserAlreadyInRoom", - NO_PERMISSION_IN_ROOM: "NoPermissionInRoom", + RoomAlreadyExists: "RoomAlreadyExists", + UserAlreadyInRoom: "UserAlreadyInRoom", + NoPermissionInRoom: "NoPermissionInRoom", + NotStarted: "NotStarted", + UnknownRoom: "UnknownRoom", + InvalidServerResponse: "InvalidServerResponse", } as const; export { INVOCATION_NAME, ERRORS }; diff --git a/sdk/webpubsub-chat-client/src/events.ts b/sdk/webpubsub-chat-client/src/events.ts index 1ed2e431..17a27c78 100644 --- a/sdk/webpubsub-chat-client/src/events.ts +++ b/sdk/webpubsub-chat-client/src/events.ts @@ -7,63 +7,60 @@ import type { MessageInfo, RoomInfo } from "./generatedTypes.js"; */ export interface ChatMessage extends MessageInfo {} -/** Payload of the `"message"` event. */ -export interface MessageEvent { - /** Conversation the message belongs to. */ - conversationId: string; - /** Room id when the conversation is room-scoped; otherwise undefined. */ - roomId?: string; +/** + * Argument of the `"started"` event listener. Fired after `start()` + * completes successfully — the underlying connection is open, chat- + * domain login has resolved, and `client.userId` / `client.rooms` are + * populated. Mirrors the `OnArgs` shape used by the underlying + * `WebPubSubClient` (e.g. `OnConnectedArgs`). + */ +export interface OnStartedArgs { + /** The chat-domain identity of this client. Equivalent to `client.userId`. */ + userId: string; +} + +/** + * Argument of the `"stopped"` event listener. Fired when the chat + * client transitions from started to not-started — either because + * `stop()` was called or the underlying connection terminated. Empty + * payload (reserved for future fields), matching upstream + * `WebPubSubClient.OnStoppedArgs`. + */ +export interface OnStoppedArgs {} + +/** + * Argument of the `"message"` event listener. Naming mirrors the + * `OnArgs` convention used by the underlying `WebPubSubClient` + * (e.g. `OnGroupDataMessageArgs`). + */ +export interface OnMessageArgs { + /** Room the message belongs to. */ + roomId: string; /** The message. */ message: ChatMessage; } -/** Payload of the `"roomJoined"` event. Fired when the current client joins a room. */ -export interface RoomJoinedEvent { +/** Argument of the `"room-joined"` event listener. Fired when the current client joins a room. */ +export interface OnRoomJoinedArgs { room: RoomInfo; } -/** Payload of the `"roomLeft"` event. Fired when the current client leaves a room. */ -export interface RoomLeftEvent { +/** Argument of the `"room-left"` event listener. Fired when the current client leaves a room. */ +export interface OnRoomLeftArgs { roomId: string; title: string; } -/** Payload of the `"memberJoined"` event. Fired when another user joins a room this client is in. */ -export interface MemberJoinedEvent { +/** Argument of the `"member-joined"` event listener. Fired when another user joins a room this client is in. */ +export interface OnMemberJoinedArgs { roomId: string; title: string; userId: string; } -/** Payload of the `"memberLeft"` event. Fired when another user leaves a room this client is in. */ -export interface MemberLeftEvent { +/** Argument of the `"member-left"` event listener. Fired when another user leaves a room this client is in. */ +export interface OnMemberLeftArgs { roomId: string; title: string; userId: string; } - -/** - * Map of all chat-domain `ChatClient` events to their payload types. - * - * Connection-lifecycle events (`connected`, `disconnected`, `stopped`) - * live on the underlying transport and are subscribed via - * `chatClient.connection.on("connected", ...)` etc. - * - * Used by the generic `ChatClient.on` / `ChatClient.off` overloads for - * type narrowing. Convenience methods (`onMessage`, `onRoomJoined`, ...) - * are thin wrappers over `on(...)` and accept the corresponding payload - * type. - */ -export interface ChatEventMap { - message: MessageEvent; - roomJoined: RoomJoinedEvent; - roomLeft: RoomLeftEvent; - memberJoined: MemberJoinedEvent; - memberLeft: MemberLeftEvent; -} - -export type ChatEventName = keyof ChatEventMap; -export type ChatEventListener = (event: ChatEventMap[K]) => void; - -/** Returned from listener registrations. Call to remove the listener. */ -export type Disposable = () => void; diff --git a/sdk/webpubsub-chat-client/src/index.ts b/sdk/webpubsub-chat-client/src/index.ts index e5553498..a71eceb6 100644 --- a/sdk/webpubsub-chat-client/src/index.ts +++ b/sdk/webpubsub-chat-client/src/index.ts @@ -1,27 +1,60 @@ import { ChatClient, ChatError } from './chatClient.js'; +import { ERRORS } from './constant.js'; export type { + components, MessageInfo, RoomInfo, RoomInfoWithMembers, + Schemas, UserProfile, } from './generatedTypes.js'; export type { ChatMessage, - ChatEventMap, - ChatEventName, - ChatEventListener, - Disposable, - MessageEvent, - RoomJoinedEvent, - RoomLeftEvent, - MemberJoinedEvent, - MemberLeftEvent, + OnMessageArgs, + OnRoomJoinedArgs, + OnRoomLeftArgs, + OnMemberJoinedArgs, + OnMemberLeftArgs, + OnStartedArgs, + OnStoppedArgs, } from './events.js'; export type { + OperationOptions, + StartOptions, + GetRoomOptions, + CreateRoomOptions, + SendToRoomOptions, + GetUserProfileOptions, + AddUserToRoomOptions, + RemoveUserFromRoomOptions, ListRoomMessagesOptions, } from './options.js'; +/** + * Known values of `ChatError.code`. Following the Azure SDK + * `Known` convention, this is a runtime constants object whose + * values are the wire codes returned by the chat service. Compare + * `error.code` against members of this object rather than against + * string literals. + * + * @example + * ```ts + * try { + * await client.sendToRoom(roomId, "hi"); + * } catch (err) { + * if (err instanceof ChatError && err.code === KnownChatErrorCode.UnknownRoom) { + * // ... + * } + * } + * ``` + * + * The service may add codes in newer versions, so always handle the + * unknown-code case as well. + */ +export const KnownChatErrorCode = ERRORS; + export { ChatClient, ChatError }; + diff --git a/sdk/webpubsub-chat-client/src/options.ts b/sdk/webpubsub-chat-client/src/options.ts index 4a7f7986..4784c6f7 100644 --- a/sdk/webpubsub-chat-client/src/options.ts +++ b/sdk/webpubsub-chat-client/src/options.ts @@ -1,14 +1,60 @@ /** * Options-object types for `ChatClient` methods. * - * As more methods migrate to the options-object pattern, additional - * interfaces will live here. + * Every asynchronous method accepts an options bag extending + * {@link OperationOptions} with at least an `abortSignal`. Future + * per-operation knobs (custom headers, retry policy, ...) live on + * these interfaces too. */ -/** Options for {@link ChatClient.listRoomMessages}. */ -export interface ListRoomMessagesOptions { - /** Room to list messages from. Must be a room this client has created or joined. */ - roomId: string; +import type { AbortSignalLike } from "@azure/abort-controller"; + +/** Base options accepted by every asynchronous `ChatClient` operation. */ +export interface OperationOptions { + /** + * Signal used to cancel the operation. Accepts either a browser + * `AbortSignal` or `@azure/abort-controller`'s polyfill. + */ + abortSignal?: AbortSignalLike; +} + +/** Options for `ChatClient.start()`. */ +export interface StartOptions extends OperationOptions {} + +/** Options for `ChatClient.getRoomDetail()`. */ +export interface GetRoomOptions extends OperationOptions { + /** + * When `true`, the returned `RoomInfoWithMembers.members` array is + * populated. Defaults to `false`; fetching members is an additional + * service round-trip and is skipped unless asked for. + */ + withMembers?: boolean; +} + +/** Options for `ChatClient.createRoom()`. */ +export interface CreateRoomOptions extends OperationOptions { + /** + * Optional client-chosen room id. If omitted, the service assigns a + * random id. The id must be unique within the hub; reusing an + * existing id rejects with `KnownChatErrorCode.RoomAlreadyExists`. + */ + roomId?: string; +} + +/** Options for `ChatClient.sendToRoom()`. */ +export interface SendToRoomOptions extends OperationOptions {} + +/** Options for `ChatClient.getUserProfile()`. */ +export interface GetUserProfileOptions extends OperationOptions {} + +/** Options for `ChatClient.addUserToRoom()`. */ +export interface AddUserToRoomOptions extends OperationOptions {} + +/** Options for `ChatClient.removeUserFromRoom()`. */ +export interface RemoveUserFromRoomOptions extends OperationOptions {} + +/** Options for `ChatClient.listRoomMessages()`. */ +export interface ListRoomMessagesOptions extends OperationOptions { /** * Inclusive lower bound on message id; omit to start from the earliest available message. */ @@ -18,10 +64,11 @@ export interface ListRoomMessagesOptions { */ endId?: string; /** - * Maximum number of messages to request per service round-trip when - * iterating with `for await`. Defaults to 100. Callers using - * `byPage(...)` can override this per page via - * `byPage({ maxPageSize })`. + * Default maximum number of messages to request per service + * round-trip when iterating with `for await`. Defaults to 100. + * Callers using `byPage(...)` can override this per page via + * `byPage({ maxPageSize })`; the name matches the + * `@azure/core-paging` `PageSettings.maxPageSize` convention. */ - pageSize?: number; + maxPageSize?: number; } diff --git a/sdk/webpubsub-chat-client/tests/integration.test.ts b/sdk/webpubsub-chat-client/tests/integration.test.ts index 5eabf4ec..a68b5e69 100644 --- a/sdk/webpubsub-chat-client/tests/integration.test.ts +++ b/sdk/webpubsub-chat-client/tests/integration.test.ts @@ -32,13 +32,13 @@ test("same user id start twice", { timeout: LONG_TEST_TIMEOUT }, async (t) => { chat1 = await createTestClient(); const chat1UserId = chat1.userId; let messageReceived = 0; - chat1.onMessage((notification) => { + chat1.on("message", (notification) => { messageReceived++; }); assert.equal(chat1.userId, chat1UserId, `chat1 userId should be '${chat1UserId}'`); const roomName = `room-${Math.floor(Math.random() * 10000)}`; - const createdRoom = await chat0.createRoom(roomName, [chat1.userId], `uid_${roomName}`); + const createdRoom = await chat0.createRoom(roomName, [chat1.userId], { roomId: `uid_${roomName}` }); await chat0.sendToRoom(createdRoom.roomId, `Hello from chat0`); // sleep 100ms await new Promise((resolve) => setTimeout(resolve, 100)); @@ -49,7 +49,7 @@ test("same user id start twice", { timeout: LONG_TEST_TIMEOUT }, async (t) => { // second start with same userId chat1 = await createTestClient(chat1UserId); messageReceived = 0; - chat1.onMessage((notification) => { + chat1.on("message", (notification) => { messageReceived++; }); assert.equal(chat1.userId, chat1UserId, `chat1 userId should still be '${chat1UserId}' after restart`); @@ -75,7 +75,7 @@ test("same user on two clients still receives remote room messages", { timeout: admin = await createTestClient(); const roomId = `room-shared-${randomUUID().substring(0, 6)}`; - const createdRoom = await admin.createRoom("ut-shared-room", [sharedUserId], roomId); + const createdRoom = await admin.createRoom("ut-shared-room", [sharedUserId], { roomId }); sender = await createTestClient(sharedUserId); watcher = await createTestClient(sharedUserId); @@ -85,10 +85,10 @@ test("same user on two clients still receives remote room messages", { timeout: const senderNotifications: any[] = []; const watcherNotifications: any[] = []; - sender.onMessage((notification) => { + sender.on("message", (notification) => { senderNotifications.push(notification); }); - watcher.onMessage((notification) => { + watcher.on("message", (notification) => { watcherNotifications.push(notification); }); @@ -132,14 +132,14 @@ test("single client", { timeout: SHORT_TEST_TIMEOUT }, async (t) => { assert.ok(chat1.userId && typeof chat1.userId === "string"); const roomId = `room-id-${randomUUID().substring(0, 3)}`; - const created = await chat1.createRoom("ut-single-room", [], roomId); + const created = await chat1.createRoom("ut-single-room", [], { roomId }); assert.equal(created.roomId, roomId, "roomId should match"); assert.equal(created.title, "ut-single-room", "room title should match"); assert.ok(Array.isArray(created.members), "members should be an array"); assert.deepEqual(created.members, [chat1.userId], "members should contain only the creator"); assert.ok(created.members.includes(chat1.userId), "members should include the creator"); - const fetched = await chat1.getRoom(created.roomId, true); + const fetched = await chat1.getRoomDetail(created.roomId, { withMembers: true }); assert.equal(fetched.roomId, created.roomId, "fetched roomId should match created"); assert.equal(fetched.title, created.title, "fetched title should match created"); assert.ok(Array.isArray(fetched.members), "fetched members should be an array"); @@ -160,10 +160,10 @@ test("create room with multiple users", { timeout: LONG_TEST_TIMEOUT }, async (t receivedMsgCounts = [0, 0, 0]; for (let i = 0; i < 3; i++) { - chats[i].onRoomJoined((event) => { + chats[i].on("room-joined", (event) => { joinedRoomCounts[i]++; }); - chats[i].onMessage((message) => { + chats[i].on("message", (message) => { receivedMsgCounts[i]++; }); } @@ -176,7 +176,7 @@ test("create room with multiple users", { timeout: LONG_TEST_TIMEOUT }, async (t } const listedMsgs: MessageInfo[] = []; - for await (const message of chats[0].listRoomMessages({ roomId: createdRoom.roomId, startId: "0", pageSize: 100 })) { + for await (const message of chats[0].listRoomMessages(createdRoom.roomId, { startId: "0", maxPageSize: 100 })) { listedMsgs.push(message); } let listedMsgCount = 0; @@ -190,7 +190,7 @@ test("create room with multiple users", { timeout: LONG_TEST_TIMEOUT }, async (t // 5 messages and maxPageSize=2 we expect pages of size [2, 2, 1] (or // possibly with a trailing empty page that the iterator filters out). const pagesIter = chats[0] - .listRoomMessages({ roomId: createdRoom.roomId, startId: "0" }) + .listRoomMessages(createdRoom.roomId, { startId: "0" }) .byPage({ maxPageSize: 2 }); const collectedPages: MessageInfo[][] = []; while (true) { @@ -235,7 +235,7 @@ test("admin adds multiple users to a group", { timeout: LONG_TEST_TIMEOUT }, asy let messageReceivedCounts = new Array(chats.length).fill(0); chats.forEach((chat, index) => { - chat.onMessage((notification) => { + chat.on("message", (notification) => { messageReceivedCounts[index]++; }); }); @@ -282,7 +282,7 @@ test("self remove updates local room cache immediately", { timeout: LONG_TEST_TI chat1 = await createTestClient(); const roomId = `room-leave-${randomUUID().substring(0, 6)}`; - const created = await chat1.createRoom("ut-self-leave", [], roomId); + const created = await chat1.createRoom("ut-self-leave", [], { roomId }); assert.equal(chat1.hasJoinedRoom(created.roomId), true, "room should be cached after creation"); await chat1.removeUserFromRoom(created.roomId, chat1.userId); @@ -302,11 +302,11 @@ test("self add restores local room cache without RoomJoined event", { timeout: L chat1 = await createTestClient(); const roomId = `room-self-add-${randomUUID().substring(0, 6)}`; - const created = await chat1.createRoom("ut-self-add", [], roomId); + const created = await chat1.createRoom("ut-self-add", [], { roomId }); assert.equal(chat1.hasJoinedRoom(created.roomId), true, "room should be cached after creation"); let roomJoinedEvents = 0; - chat1.onRoomJoined((event) => { + chat1.on("room-joined", (event) => { if (event.room.roomId === created.roomId) { roomJoinedEvents += 1; } @@ -336,7 +336,7 @@ test("adding non-self user already in room throws ChatError with UserAlreadyInRo user1 = await createTestClient(); const roomId = `room-dup-add-${randomUUID().substring(0, 6)}`; - await admin.createRoom("ut-dup-add", [user1.userId], roomId); + await admin.createRoom("ut-dup-add", [user1.userId], { roomId }); // Second add of the same non-self user must throw a ChatError wrapping the server's UserAlreadyInRoom code. let thrown: unknown; @@ -364,7 +364,7 @@ test("removing non-member user throws ChatError with UserNotInRoom code", { time stranger = await createTestClient(); const roomId = `room-rm-nonmember-${randomUUID().substring(0, 6)}`; - await admin.createRoom("ut-rm-nonmember", [], roomId); + await admin.createRoom("ut-rm-nonmember", [], { roomId }); let thrown: unknown; try { diff --git a/sdk/webpubsub-chat-client/tests/lifecycle.test.ts b/sdk/webpubsub-chat-client/tests/lifecycle.test.ts index f74ce0fd..1bdf221c 100644 --- a/sdk/webpubsub-chat-client/tests/lifecycle.test.ts +++ b/sdk/webpubsub-chat-client/tests/lifecycle.test.ts @@ -6,6 +6,13 @@ import { ChatClient } from "../src/chatClient.js"; import { INVOCATION_NAME } from "../src/constant.js"; import type { RoomInfoWithMembers, UserProfile } from "../src/generatedTypes.js"; +/** + * `_isStarted` is private; accessor used by tests to assert the post-login + * state-machine flag without re-exposing it on the public surface. + */ +const isStarted = (client: ChatClient): boolean => + (client as unknown as { _isStarted: boolean })._isStarted; + class Deferred { public promise: Promise; public resolve!: (value: T | PromiseLike) => void; @@ -124,7 +131,7 @@ test("stop before start is a no-op", async () => { await client.stop(); - assert.equal(client.isStarted, false); + assert.equal(isStarted(client), false); assert.equal(fakeClient.stopCalls, 0); }); @@ -148,13 +155,13 @@ test("concurrent start calls wait for room hydration", async () => { }); await Promise.resolve(); - assert.equal(client.isStarted, false, "client should not be marked started until room hydration completes"); + assert.equal(isStarted(client), false, "client should not be marked started until room hydration completes"); assert.equal(secondStartResolved, false, "second start should wait for the in-flight start promise"); fakeClient.getRoomDelay.resolve(); await Promise.all([firstStart, secondStart]); - assert.equal(client.isStarted, true); + assert.equal(isStarted(client), true); assert.deepEqual(client.rooms.map((roomInfo) => roomInfo.roomId), ["room1"]); assert.equal(fakeClient.startCalls, 1); }); @@ -167,7 +174,7 @@ test("failed start rolls back chat state and stops the connection", async () => await assert.rejects(client.start(), /get room failed/); - assert.equal(client.isStarted, false); + assert.equal(isStarted(client), false); assert.throws(() => client.userId, /start\(\)/); assert.deepEqual(client.rooms, []); assert.equal(fakeClient.stopCalls, 1); @@ -195,7 +202,7 @@ test("stop waits for stopped event before allowing restart", async () => { await Promise.all([stopPromise, restartPromise]); assert.equal(stopResolved, true); - assert.equal(client.isStarted, true); + assert.equal(isStarted(client), true); assert.equal(fakeClient.startCalls, 2); assert.equal(fakeClient.stopCalls, 1); }); @@ -225,7 +232,7 @@ test("concurrent stop calls wait for the same stopped event", async () => { assert.equal(firstStopResolved, true); assert.equal(secondStopResolved, true); - assert.equal(client.isStarted, false); + assert.equal(isStarted(client), false); }); test("concurrent start calls during stop share a single restart", async () => { @@ -246,7 +253,49 @@ test("concurrent start calls during stop share a single restart", async () => { fakeClient.stopDelay.resolve(); await Promise.all([stopPromise, firstRestart, secondRestart]); - assert.equal(client.isStarted, true); + assert.equal(isStarted(client), true); assert.equal(fakeClient.startCalls, 2, "restart should call connection.start() exactly once"); assert.equal(fakeClient.stopCalls, 1); +}); + +test("started event fires once after start() completes with userId payload", async () => { + const fakeClient = new FakeWebPubSubClient(); + fakeClient.loginResponse = { userId: "alice", roomIds: [], conversationIds: [] }; + const client = createClient(fakeClient); + + const startedEvents: Array<{ userId: string }> = []; + client.on("started", (e) => startedEvents.push(e)); + + await client.start(); + assert.equal(startedEvents.length, 1, "started should fire exactly once on successful start"); + assert.deepEqual(startedEvents[0], { userId: "alice" }, "started payload should carry the chat-domain userId"); + assert.equal(client.userId, "alice", "userId getter should be live by the time started fires"); + + // Re-starting an already-started client must not re-emit. + await client.start(); + assert.equal(startedEvents.length, 1, "started should not fire when start() is a no-op on an already-started client"); +}); + +test("stopped event fires on started→not-started transitions only", async () => { + const fakeClient = new FakeWebPubSubClient(); + const client = createClient(fakeClient); + + const stoppedEvents: unknown[] = []; + client.on("stopped", (e) => stoppedEvents.push(e)); + + // stop() before start() must not emit (no transition). + await client.stop(); + assert.equal(stoppedEvents.length, 0, "stopped should not fire when stop() runs on a never-started client"); + + // start → stop fires exactly once. + await client.start(); + await client.stop(); + assert.equal(stoppedEvents.length, 1, "stopped should fire exactly once per explicit stop()"); + + // restart → network-driven stop also fires once. + await client.start(); + fakeClient.stop(); + // Allow the queued microtask that emits the underlying "stopped" to flush. + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(stoppedEvents.length, 2, "stopped should fire when the transport terminates after a successful start"); }); \ No newline at end of file