diff --git a/sdk/webpubsub-chat-client/README.md b/sdk/webpubsub-chat-client/README.md index 1d992932..0db58eee 100644 --- a/sdk/webpubsub-chat-client/README.md +++ b/sdk/webpubsub-chat-client/README.md @@ -20,12 +20,11 @@ import { ChatClient } from '@azure/web-pubsub-chat-client'; // Get client access URL from your server const url = await fetch('/negotiate?userId=alice').then(r => r.json()).then(d => d.url); -// Option 1: Start with an existing WebPubSubClient -const wpsClient = new WebPubSubClient(url); -const client = await ChatClient.start(wpsClient); +// Start directly with a client access URL... +const client = await ChatClient.start(url); -// Option 2: Start directly with URL -// const client = await ChatClient.start(url); +// ...or with a credential (a callback that returns a client access URL): +// const client = await ChatClient.start({ getClientAccessUrl: async () => url }); console.log(`Started as: ${client.userId}`); @@ -63,20 +62,17 @@ await client.stop(); #### Constructor ```typescript -new ChatClient(wpsClient: WebPubSubClient) +new ChatClient(credential: WebPubSubClientCredential) ``` -`ChatClient` always wraps a pre-constructed `WebPubSubClient` and owns its lifecycle: `start()` starts the transport, `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. +`ChatClient` is constructed from a `WebPubSubClientCredential`. It builds and owns the underlying transport: `start()` connects and authenticates, `stop()` disconnects. The instance is constructed but not started — call `start()`, or use the static `ChatClient.start(...)` factory below (which also accepts a plain client-access URL) to construct-and-start in one step. #### Static Methods | Method | Description | |--------|-------------| -| `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`) | +| `ChatClient.start(clientAccessUrl, options?)` | Construct from a client-access URL and start (`options?: StartOptions`) | +| `ChatClient.start(credential, options?)` | Construct from a `WebPubSubClientCredential` and start (`options?: StartOptions`) | #### Properties @@ -84,7 +80,6 @@ To construct from a client-access URL or `WebPubSubClientCredential`, use the st |----------|------|-------------| | `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 | #### Methods @@ -93,10 +88,10 @@ To construct from a client-access URL or `WebPubSubClientCredential`, use the st | `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, 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). | +| `getRoomDetail(roomId, options?)` | Get the detailed view of a room (`RoomDetail`). Options: `{ withMembers?, abortSignal? }` — pass `withMembers: true` to populate the `members` list (omitted otherwise). | | `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 | +| `sendToRoom(roomId, message, options?)` | Send text message to room, returns a `SendMessageResult` (`{ messageId }`) | | `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 | @@ -166,14 +161,10 @@ client.off('message', onMsg); | `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`: - -```ts -client.connection.on('connected', (e) => console.log('connected', e)); -client.connection.on('disconnected', (e) => console.log('disconnected', e)); -client.connection.on('stopped', () => console.log('stopped')); -``` +The underlying connection is managed internally and is not exposed. Use the +chat-level `started` / `stopped` events for lifecycle notifications; +lower-level connection events (`connected`, `disconnected`) are not +currently surfaced. ## Examples diff --git a/sdk/webpubsub-chat-client/examples/quickstart/client.js b/sdk/webpubsub-chat-client/examples/quickstart/client.js index b51ec010..e32b3761 100644 --- a/sdk/webpubsub-chat-client/examples/quickstart/client.js +++ b/sdk/webpubsub-chat-client/examples/quickstart/client.js @@ -1,5 +1,4 @@ import { ChatClient } from '@azure/web-pubsub-chat-client'; -import { WebPubSubClient } from '@azure/web-pubsub-client'; const SERVER_URL = process.env.SERVER_URL || 'http://localhost:3000'; @@ -25,25 +24,20 @@ function setupListeners(client) { client.onRoomLeft((event) => { console.log(`[${client.userId}] left room ${event.roomId}`); }); - // underlying connection lifecycle listeners (on the wpsClient) - client.connection.on("stopped", () => { - console.log(`connection used by ${client.userId} stopped`); - }); - client.connection.on("disconnected", () => { - console.log(`connection used by ${client.userId} disconnected`); + // chat lifecycle listener + client.on("stopped", () => { + console.log(`chat client for ${client.userId} stopped`); }); } async function main() { // Create chat clients for Alice, Bob, and Mike - // Option 1: create a chat client with a existing WebPubSubClient - const url1 = await getClientAccessUrl('alice'); - const webPubSubClient = new WebPubSubClient(url1); - const alice = await ChatClient.start(webPubSubClient); + // Option 1: create a chat client with a credential (a callback returning a client access URL) + const alice = await ChatClient.start({ getClientAccessUrl: () => getClientAccessUrl('alice') }); console.log(`Alice started as: ${alice.userId}`); - // Option 2: create a chat client directly with client access URL + // Option 2: create a chat client directly with a client access URL const url2 = await getClientAccessUrl('bob'), url3 = await getClientAccessUrl('mike'); const bob = await ChatClient.start(url2); const mike = await ChatClient.start(url3); 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 index 2cc579e9..a49b3c3f 100644 --- a/sdk/webpubsub-chat-client/review/web-pubsub-chat-client.api.md +++ b/sdk/webpubsub-chat-client/review/web-pubsub-chat-client.api.md @@ -6,71 +6,48 @@ 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) +// @public export class ChatClient { - constructor(wpsClient: WebPubSubClient); + constructor(credential: WebPubSubClientCredential); 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) + createRoom(title: string, members: string[], options?: CreateRoomOptions): Promise; + getRoomDetail(roomId: string, options?: GetRoomDetailOptions): Promise; 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; + sendToRoom(roomId: string, message: string, options?: SendToRoomOptions): Promise; + static start(clientAccessUrl: string, options?: StartOptions): Promise; + static start(credential: WebPubSubClientCredential, options?: StartOptions): Promise; start(options?: StartOptions): Promise; stop(): Promise; - // (undocumented) get userId(): string; } -// @public (undocumented) +// @public export class ChatError extends Error { constructor(message: string, code: string); - // (undocumented) readonly code: string; } @@ -78,259 +55,13 @@ export class ChatError extends Error { 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 { +export interface GetRoomDetailOptions extends OperationOptions { withMembers?: boolean; } @@ -355,26 +86,31 @@ export interface ListRoomMessagesOptions extends OperationOptions { startId?: string; } -// @public (undocumented) -export type MessageInfo = Schemas["MessageInfo"]; +// @public +export interface MessageInfo { + bodyType?: string; + content: { + text?: string | null; + binary?: string | null; + }; + createdAt?: string; + createdBy?: string; + messageBodyType: string; + messageId: string; + refMessageId?: string | null; +} // @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; } @@ -386,15 +122,12 @@ export interface OnMessageArgs { // @public export interface OnRoomJoinedArgs { - // (undocumented) room: RoomInfo; } // @public export interface OnRoomLeftArgs { - // (undocumented) roomId: string; - // (undocumented) title: string; } @@ -416,14 +149,22 @@ export interface OperationOptions { export interface RemoveUserFromRoomOptions extends OperationOptions { } -// @public (undocumented) -export type RoomInfo = Schemas["RoomInfo"]; +// @public +export interface RoomDetail extends RoomInfo { + members?: string[]; +} -// @public (undocumented) -export type RoomInfoWithMembers = Schemas["RoomInfoWithMembers"]; +// @public +export interface RoomInfo { + properties?: Record | null; + roomId: string; + title: string; +} -// @public (undocumented) -export type Schemas = components["schemas"]; +// @public +export interface SendMessageResult { + messageId: string; +} // @public export interface SendToRoomOptions extends OperationOptions { @@ -433,8 +174,11 @@ export interface SendToRoomOptions extends OperationOptions { export interface StartOptions extends OperationOptions { } -// @public (undocumented) -export type UserProfile = Schemas["UserProfile"]; +// @public +export interface UserProfile { + roomIds?: string[]; + userId: string; +} // (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 bf0c359a..73d310d6 100644 --- a/sdk/webpubsub-chat-client/src/chatClient.ts +++ b/sdk/webpubsub-chat-client/src/chatClient.ts @@ -1,12 +1,11 @@ -import { InvocationError, WebPubSubClient, WebPubSubClientCredential, WebPubSubClientOptions, WebPubSubDataType } from "@azure/web-pubsub-client"; +import { InvocationError, WebPubSubClient, WebPubSubClientCredential, WebPubSubDataType } from "@azure/web-pubsub-client"; import { getPagedAsyncIterator, type PagedAsyncIterableIterator, type PageSettings } from "@azure/core-paging"; import { EventEmitter } from "events"; import { - MessageInfo, + RoomInfo as WireRoomInfo, + RoomInfoWithMembers as WireRoomInfoWithMembers, + UserProfile as WireUserProfile, MessageRangeQuery, - RoomInfo, - UserProfile, - RoomInfoWithMembers, Notification, NewMessageNotificationBody, NewRoomNotificationBody, @@ -16,6 +15,7 @@ import { MemberLeftNotificationBody, RoomLeftNotificationBody, } from "./generatedTypes.js"; +import type { MessageInfo, RoomInfo, RoomDetail, UserProfile, SendMessageResult } from "./models.js"; import type { ChatMessage, OnMemberJoinedArgs, @@ -30,7 +30,7 @@ import type { ListRoomMessagesOptions, OperationOptions, StartOptions, - GetRoomOptions, + GetRoomDetailOptions, CreateRoomOptions, SendToRoomOptions, GetUserProfileOptions, @@ -42,7 +42,13 @@ import { ERRORS, INVOCATION_NAME } from "./constant.js"; import { logger } from "./logger.js"; import { isWebPubSubClient } from "./utils.js"; +/** + * Error thrown by `ChatClient` operations. Inspect {@link ChatError.code} + * for a stable, machine-readable error code and compare it against + * {@link KnownChatErrorCode} members rather than matching on the message. + */ class ChatError extends Error { + /** Stable, machine-readable error code. Compare against {@link KnownChatErrorCode}. */ public readonly code: string; constructor(message: string, code: string) { super(message); @@ -70,11 +76,33 @@ class PromiseCompletionSource { } } +/** + * Client for building chat applications on Azure Web PubSub. + * + * A `ChatClient` wraps a `WebPubSubClient` and exposes a room-based chat + * API: create and inspect rooms, manage members, send messages, page + * through history, and subscribe to real-time chat events. It owns the + * underlying connection's lifecycle — call {@link ChatClient.start} to + * connect and authenticate, and {@link ChatClient.stop} to disconnect. + * + * Construct from a `WebPubSubClientCredential`, then call `start()` to + * connect and authenticate. + * + * @example + * ```ts + * const client = new ChatClient(credential); + * await client.start(); + * client.on("message", (e) => console.log(e.message.content.text)); + * const room = await client.createRoom("My Room", ["bob"]); + * await client.sendToRoom(room.roomId, "Hello!"); + * ``` + */ class ChatClient { - public readonly connection: WebPubSubClient; + /** The underlying transport. Private — `ChatClient` builds and owns it. */ + private readonly _connection: WebPubSubClient; private readonly _emitter = new EventEmitter(); - private readonly _rooms = new Map(); + private readonly _rooms = new Map(); private _conversationIds = new Set(); private _userId: string | undefined; private _isStarted = false; @@ -84,27 +112,29 @@ class ChatClient { private _isConnectionStopping = false; /** - * Create a `ChatClient` that wraps an existing `WebPubSubClient`. + * 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. + * `ChatClient` builds and owns the underlying transport: `start()` + * connects and authenticates, `stop()` disconnects. The instance is + * created but not started — call `start()` to connect. * - * 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. + * @param credential - A `WebPubSubClientCredential` that yields a + * client-access URL. */ - constructor(wpsClient: WebPubSubClient) { - this.connection = wpsClient; - this.connection.on("group-message", (e) => { + constructor(credential: WebPubSubClientCredential); + // Implementation also accepts a pre-built connection (test seam); this is + // not a public overload, so it does not appear in the public API surface. + constructor(credentialOrConnection: WebPubSubClientCredential | WebPubSubClient) { + this._connection = isWebPubSubClient(credentialOrConnection) + ? credentialOrConnection + : new WebPubSubClient(credentialOrConnection); + this._connection.on("group-message", (e) => { this._handleNotification(e.message.data as Notification); }); - this.connection.on("server-message", (e) => { + this._connection.on("server-message", (e) => { this._handleNotification(e.message.data as Notification); }); - this.connection.on("stopped", () => { + this._connection.on("stopped", () => { this._connectionStoppedTCS?.setResult(); this._connectionStoppedTCS = undefined; this._isConnectionStopping = false; @@ -136,7 +166,7 @@ class ChatClient { break; } case "RoomJoined": { - const roomInfo = data.body as NewRoomNotificationBody as RoomInfo; + const roomInfo = data.body as NewRoomNotificationBody as WireRoomInfo; // Add to _rooms first so listeners can use listRoomMessages this._rooms.set(roomInfo.roomId, roomInfo); const event: OnRoomJoinedArgs = { room: roomInfo }; @@ -191,7 +221,7 @@ class ChatClient { ): 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); @@ -210,68 +240,35 @@ class ChatClient { } /** - * Create a chat client and `start()` it in one step. + * Create a `ChatClient` and `start()` it in one step. * - * 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. + * @param clientAccessUrl - A client-access URL. + * @param options - Optional cancellation token for the start operation. * + * @example * ```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 }); + * const chat = await ChatClient.start(clientAccessUrl); * ``` + */ + public static async start(clientAccessUrl: string, options?: StartOptions): Promise; + /** + * Create a `ChatClient` and `start()` it in one step. * - * 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. + * @param credential - A `WebPubSubClientCredential` that yields a + * client-access URL. + * @param options - Optional cancellation token for the start operation. */ + public static async start(credential: WebPubSubClientCredential, options?: StartOptions): Promise; public static async start( - clientAccessUrl: string, - webPubSubClientOptions?: WebPubSubClientOptions, - options?: StartOptions, - ): Promise; - public static async start( - credential: WebPubSubClientCredential, - webPubSubClientOptions?: WebPubSubClientOptions, + clientAccessUrlOrCredential: string | WebPubSubClientCredential, 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; - 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 { - const wpsClient = new WebPubSubClient(arg1, arg2 as WebPubSubClientOptions | undefined); - chatClient = new ChatClient(wpsClient); - startOptions = arg3; - } - await chatClient.start({ abortSignal: startOptions?.abortSignal }); + const credential: WebPubSubClientCredential = + typeof clientAccessUrlOrCredential === "string" + ? { getClientAccessUrl: async () => clientAccessUrlOrCredential } + : clientAccessUrlOrCredential; + const chatClient = new ChatClient(credential); + await chatClient.start({ abortSignal: options?.abortSignal }); return chatClient; } @@ -312,11 +309,11 @@ class ChatClient { private async startCore(options?: StartOptions): Promise { this.resetState(); try { - await this.connection.start({ abortSignal: options?.abortSignal }); + await this._connection.start({ abortSignal: options?.abortSignal }); this._connectionStoppedTCS = new PromiseCompletionSource(); this._isConnectionStopping = false; - const loginResponse = await this.invokeWithReturnType( + const loginResponse = await this.invokeWithReturnType( INVOCATION_NAME.LOGIN, "", "text", @@ -326,12 +323,10 @@ class ChatClient { 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", - options, - ); + const roomInfo = await this.fetchRoomDetail(roomId, { + abortSignal: options?.abortSignal, + withMembers: false, + }); return { roomId, roomInfo }; }), ); @@ -357,9 +352,15 @@ class ChatClient { } } + /** + * Fetch a user's profile. + * + * @param userId - Id of the user to look up. + * @param options - Optional `{ abortSignal }`. + */ public async getUserProfile(userId: string, options?: GetUserProfileOptions): Promise { this.ensureStarted(); - return this.invokeWithReturnType( + return this.invokeWithReturnType( INVOCATION_NAME.GET_USER_PROPERTIES, { userId: userId }, "json", @@ -371,7 +372,7 @@ class ChatClient { conversationId: string, message: string, options?: OperationOptions, - ): Promise { + ): Promise { this.ensureStarted(); const payload = { conversation: { conversationId: conversationId }, @@ -395,7 +396,7 @@ class ChatClient { logger.warning( `Failed to find roomId for conversationId ${conversationId} when sending message; skipping local sender-echo emit.`, ); - return msgId; + return { messageId: msgId }; } // sender won't receive conversation message via notification mechanism, so emit event here const event: OnMessageArgs = { @@ -411,10 +412,21 @@ class ChatClient { } as ChatMessage, }; this._emitter.emit("message", event); - return msgId; + return { messageId: msgId }; } - public async sendToRoom(roomId: string, message: string, options?: SendToRoomOptions): Promise { + /** + * Send a text message to a room and return the service-assigned message id. + * + * The room must be one this client has created or joined. The sender also + * observes the message through the `"message"` event. + * + * @param roomId - Target room. + * @param message - Message text to send. + * @param options - Optional `{ abortSignal }`. + * @returns A {@link SendMessageResult} with the service-assigned `messageId`. + */ + public async sendToRoom(roomId: string, message: string, options?: SendToRoomOptions): Promise { this.ensureStarted(); const conversationId = this._rooms.get(roomId)?.defaultConversationId; if (!conversationId) { @@ -423,17 +435,10 @@ class ChatClient { return await this.sendToConversation(conversationId, message, options); } - /** - * 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( + // Internal: fetch a room's full wire shape (including its conversation id) + // to populate the local cache. Public callers go through getRoomDetail(). + private async fetchRoomDetail(roomId: string, options?: GetRoomDetailOptions): Promise { + return this.invokeWithReturnType( INVOCATION_NAME.GET_ROOM, { id: roomId, withMembers: options?.withMembers ?? false }, "json", @@ -441,6 +446,19 @@ class ChatClient { ); } + /** + * Fetch the detailed view of a room. + * + * @param roomId - Room to query. + * @param options - Optional `{ withMembers, abortSignal }`. Pass + * `withMembers: true` to populate the returned `members` list; it is + * left undefined otherwise. + */ + public async getRoomDetail(roomId: string, options?: GetRoomDetailOptions): Promise { + this.ensureStarted(); + return this.fetchRoomDetail(roomId, options); + } + /** * Create a room and its initial members. The current user is always * included in the resulting member list. @@ -455,7 +473,7 @@ class ChatClient { title: string, members: string[], options?: CreateRoomOptions, - ): Promise { + ): Promise { this.ensureStarted(); let roomDetails = { title: title, @@ -464,7 +482,7 @@ class ChatClient { if (options?.roomId) { roomDetails = { ...roomDetails, roomId: options.roomId }; } - const roomInfo = await this.invokeWithReturnType( + const roomInfo = await this.invokeWithReturnType( INVOCATION_NAME.CREATE_ROOM, roomDetails, "json", @@ -487,7 +505,7 @@ class ChatClient { if (this._rooms.has(roomId)) { return; } - const roomInfo = await this.getRoomDetail(roomId, options); + const roomInfo = await this.fetchRoomDetail(roomId, options); this._rooms.set(roomId, roomInfo); } @@ -606,6 +624,11 @@ class ChatClient { return this._rooms.has(roomId); } + /** + * The chat-domain identity of this client, established by `start()`. + * + * @throws `ChatError` with code `NotStarted` if the client is not started. + */ public get userId(): string { if (!this._userId) { throw new ChatError("User ID is not set. Please call start() first.", ERRORS.NotStarted); @@ -621,9 +644,10 @@ class ChatClient { * `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. + * Chat-lifecycle events `started` and `stopped` are exposed here. + * Lower-level transport-connection events (`connected`, + * `disconnected`) are managed internally and are not exposed on the + * public surface. * * @example * ```ts @@ -633,25 +657,37 @@ class ChatClient { * client.off("message", onMsg); * ``` */ - 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: "started", listener: (e: OnStartedArgs) => void): void; + /** Subscribe to the `stopped` event, fired when the client transitions from started to not-started. */ + public on(event: "stopped", listener: (e: OnStoppedArgs) => void): void; + /** Subscribe to the `message` event, fired when a message arrives in a joined room. */ + public on(event: "message", listener: (e: OnMessageArgs) => void): void; + /** Subscribe to the `room-joined` event, fired when this client joins a room. */ + public on(event: "room-joined", listener: (e: OnRoomJoinedArgs) => void): void; + /** Subscribe to the `room-left` event, fired when this client leaves a room. */ + public on(event: "room-left", listener: (e: OnRoomLeftArgs) => void): void; + /** Subscribe to the `member-joined` event, fired when another user joins a room this client is in. */ public on(event: "member-joined", listener: (e: OnMemberJoinedArgs) => void): void; - public on(event: "member-left", listener: (e: OnMemberLeftArgs) => void): void; + /** Subscribe to the `member-left` event, fired when another user leaves a room this client is in. */ + 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; + /** Remove a `started` listener previously registered with `on()`. */ + public off(event: "started", listener: (e: OnStartedArgs) => void): void; + /** Remove a `stopped` listener previously registered with `on()`. */ + public off(event: "stopped", listener: (e: OnStoppedArgs) => void): void; + /** Remove a `message` listener previously registered with `on()`. */ + public off(event: "message", listener: (e: OnMessageArgs) => void): void; + /** Remove a `room-joined` listener previously registered with `on()`. */ + public off(event: "room-joined", listener: (e: OnRoomJoinedArgs) => void): void; + /** Remove a `room-left` listener previously registered with `on()`. */ + public off(event: "room-left", listener: (e: OnRoomLeftArgs) => void): void; + /** Remove a `member-joined` listener previously registered with `on()`. */ public off(event: "member-joined", listener: (e: OnMemberJoinedArgs) => void): void; - public off(event: "member-left", listener: (e: OnMemberLeftArgs) => void): void; + /** Remove a `member-left` listener previously registered with `on()`. */ + public off(event: "member-left", listener: (e: OnMemberLeftArgs) => void): void; public off(event: string, listener: (e: any) => void): void { this._emitter.off(event, listener); } @@ -706,7 +742,7 @@ class ChatClient { if (!this._isConnectionStopping) { this._isConnectionStopping = true; try { - this.connection.stop(); + this._connection.stop(); } catch (err) { this._isConnectionStopping = false; throw err; diff --git a/sdk/webpubsub-chat-client/src/events.ts b/sdk/webpubsub-chat-client/src/events.ts index 17a27c78..cc739652 100644 --- a/sdk/webpubsub-chat-client/src/events.ts +++ b/sdk/webpubsub-chat-client/src/events.ts @@ -1,4 +1,4 @@ -import type { MessageInfo, RoomInfo } from "./generatedTypes.js"; +import type { MessageInfo, RoomInfo } from "./models.js"; /** * A chat message payload. Extends the wire `MessageInfo` so existing @@ -42,25 +42,34 @@ export interface OnMessageArgs { /** Argument of the `"room-joined"` event listener. Fired when the current client joins a room. */ export interface OnRoomJoinedArgs { + /** The room that was joined. */ room: RoomInfo; } /** Argument of the `"room-left"` event listener. Fired when the current client leaves a room. */ export interface OnRoomLeftArgs { + /** Id of the room that was left. */ roomId: string; + /** Title of the room that was left. */ title: string; } /** Argument of the `"member-joined"` event listener. Fired when another user joins a room this client is in. */ export interface OnMemberJoinedArgs { + /** Id of the room the member joined. */ roomId: string; + /** Title of the room the member joined. */ title: string; + /** Id of the user who joined. */ userId: string; } /** Argument of the `"member-left"` event listener. Fired when another user leaves a room this client is in. */ export interface OnMemberLeftArgs { + /** Id of the room the member left. */ roomId: string; + /** Title of the room the member left. */ title: string; + /** Id of the user who left. */ userId: string; } diff --git a/sdk/webpubsub-chat-client/src/index.ts b/sdk/webpubsub-chat-client/src/index.ts index a71eceb6..0af02c2f 100644 --- a/sdk/webpubsub-chat-client/src/index.ts +++ b/sdk/webpubsub-chat-client/src/index.ts @@ -2,13 +2,12 @@ import { ChatClient, ChatError } from './chatClient.js'; import { ERRORS } from './constant.js'; export type { - components, MessageInfo, RoomInfo, - RoomInfoWithMembers, - Schemas, + RoomDetail, UserProfile, -} from './generatedTypes.js'; + SendMessageResult, +} from './models.js'; export type { ChatMessage, @@ -24,7 +23,7 @@ export type { export type { OperationOptions, StartOptions, - GetRoomOptions, + GetRoomDetailOptions, CreateRoomOptions, SendToRoomOptions, GetUserProfileOptions, diff --git a/sdk/webpubsub-chat-client/src/modelGuards.ts b/sdk/webpubsub-chat-client/src/modelGuards.ts new file mode 100644 index 00000000..167fa4d1 --- /dev/null +++ b/sdk/webpubsub-chat-client/src/modelGuards.ts @@ -0,0 +1,27 @@ +/** + * Compile-time guards that keep the curated public models in {@link ./models.js} + * in sync with the auto-generated wire schemas in {@link ./generatedTypes.js}. + * + * The check is one-directional: each wire type must be assignable to its + * curated public model. This lets the public models intentionally *omit* + * internal-only fields (e.g. conversation ids) while still catching real + * drift — if the OpenAPI spec changes a field the public model *does* + * expose (renamed, retyped, or removed), `npm run generate:types` makes the + * corresponding alias below stop resolving to `true` and the build fails, + * signalling that `models.ts` needs updating. + * + * This file is intentionally NOT re-exported from `index.ts`, so the + * generated `Schemas`/`components` aggregates never reach the public API. + * It has no runtime effect (type-only). + */ +import type { Schemas } from "./generatedTypes.js"; +import type { MessageInfo, RoomInfo, RoomDetail, UserProfile } from "./models.js"; + +type Assert = T; +/** `true` when wire type `W` structurally satisfies (is assignable to) the curated model `M`. */ +type WireSatisfies = [W] extends [M] ? true : false; + +type _GuardMessageInfo = Assert>; +type _GuardRoomInfo = Assert>; +type _GuardRoomDetail = Assert>; +type _GuardUserProfile = Assert>; diff --git a/sdk/webpubsub-chat-client/src/models.ts b/sdk/webpubsub-chat-client/src/models.ts new file mode 100644 index 00000000..0a8f7146 --- /dev/null +++ b/sdk/webpubsub-chat-client/src/models.ts @@ -0,0 +1,75 @@ +/** + * Public domain model types for the chat client. + * + * These are hand-curated, standalone shapes that make up the SDK's public + * surface. They expose a curated *subset* of the wire schemas in + * `generatedTypes.ts` — an internal, auto-generated file — declared + * independently so the generated OpenAPI aggregates (`components`, + * `Schemas`, `paths`, ...) never leak into the public API. Internal-only + * concepts (e.g. conversation ids) are intentionally omitted here even + * though the wire types still carry them. + * + * A compile-time guard in `modelGuards.ts` fails the build if any field + * these models *do* expose drifts out of sync with the generated wire + * types, so regenerating the schema (`npm run generate:types`) surfaces + * shape changes here instead of silently diverging. + */ + +/** A single chat message. */ +export interface MessageInfo { + /** Service-assigned message id, unique and monotonically increasing within a conversation. */ + messageId: string; + /** User id of the sender. */ + createdBy?: string; + /** Creation timestamp, as returned by the service. */ + createdAt?: string; + /** Body type of the message (service-defined). */ + bodyType?: string; + /** Concrete body type of the message (service-defined). */ + messageBodyType: string; + /** Message payload. Text messages populate `text`; binary messages populate `binary`. */ + content: { + text?: string | null; + binary?: string | null; + }; + /** Id of the message this one references (e.g. a reply), when applicable. */ + refMessageId?: string | null; +} + +/** Metadata describing a room. */ +export interface RoomInfo { + /** Unique room id. */ + roomId: string; + /** Display title of the room. */ + title: string; + /** Free-form room properties, when set. */ + properties?: Record | null; +} + +/** + * The detailed view of a room: a {@link RoomInfo} plus, optionally, its + * member list. Returned by `createRoom()` and `getRoomDetail()`. Reserved + * to grow with further room detail (e.g. conversations) over time. + */ +export interface RoomDetail extends RoomInfo { + /** + * User ids of the room's members. Populated when the members were + * requested (e.g. `getRoomDetail(..., { withMembers: true })`) and + * `undefined` otherwise. + */ + members?: string[]; +} + +/** Profile of a chat user. */ +export interface UserProfile { + /** The user's id. */ + userId: string; + /** Ids of rooms the user belongs to. */ + roomIds?: string[]; +} + +/** Result of sending a message, returned by `sendToRoom()`. */ +export interface SendMessageResult { + /** Service-assigned id of the sent message. */ + messageId: string; +} diff --git a/sdk/webpubsub-chat-client/src/options.ts b/sdk/webpubsub-chat-client/src/options.ts index 4784c6f7..b8a9078e 100644 --- a/sdk/webpubsub-chat-client/src/options.ts +++ b/sdk/webpubsub-chat-client/src/options.ts @@ -22,11 +22,10 @@ export interface OperationOptions { export interface StartOptions extends OperationOptions {} /** Options for `ChatClient.getRoomDetail()`. */ -export interface GetRoomOptions extends OperationOptions { +export interface GetRoomDetailOptions 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. + * When `true`, the returned {@link RoomDetail}'s `members` array is + * populated. Defaults to `false`, leaving `members` undefined. */ withMembers?: boolean; } diff --git a/sdk/webpubsub-chat-client/tests/integration.test.ts b/sdk/webpubsub-chat-client/tests/integration.test.ts index a68b5e69..bcd7b82e 100644 --- a/sdk/webpubsub-chat-client/tests/integration.test.ts +++ b/sdk/webpubsub-chat-client/tests/integration.test.ts @@ -92,7 +92,7 @@ test("same user on two clients still receives remote room messages", { timeout: watcherNotifications.push(notification); }); - const sentMsgId = await sender.sendToRoom(createdRoom.roomId, "Hello from sender"); + const sentMsgId = (await sender.sendToRoom(createdRoom.roomId, "Hello from sender")).messageId; await waitForCondition( () => senderNotifications.some((notification) => notification.message.messageId === sentMsgId), @@ -137,7 +137,7 @@ test("single client", { timeout: SHORT_TEST_TIMEOUT }, async (t) => { 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"); + assert.ok(created.members?.includes(chat1.userId), "members should include the creator"); const fetched = await chat1.getRoomDetail(created.roomId, { withMembers: true }); assert.equal(fetched.roomId, created.roomId, "fetched roomId should match created"); @@ -171,7 +171,7 @@ test("create room with multiple users", { timeout: LONG_TEST_TIMEOUT }, async (t const createdRoom = await chats[0].createRoom("test-room", [chats[1].userId, chats[2].userId]); for (let i = 1; i <= 5; i++) { - const msgId: string = await chats[0].sendToRoom(createdRoom.roomId, `HelloMessage,#${i}`); + const msgId: string = (await chats[0].sendToRoom(createdRoom.roomId, `HelloMessage,#${i}`)).messageId; assert.equal(msgId, i.toString(), `sent message id should be ${i} but got ${msgId}`); } @@ -243,7 +243,7 @@ test("admin adds multiple users to a group", { timeout: LONG_TEST_TIMEOUT }, asy // client 0..n-1 send message, should be received by all others for (let i = 1; i < chats.length; i++) { - const sentMsgId = await chats[i].sendToRoom(createdRoom.roomId, `Hello from chat${i}`); + const sentMsgId = (await chats[i].sendToRoom(createdRoom.roomId, `Hello from chat${i}`)).messageId; assert.equal(sentMsgId, i.toString(), `sent message id should be ${i} but got ${sentMsgId}`); } @@ -256,7 +256,7 @@ test("admin adds multiple users to a group", { timeout: LONG_TEST_TIMEOUT }, asy assert.equal(messageReceivedCounts[0], chats.length - 1, `creator should receive ${chats.length - 1} messages`); // client 0 send message - const finalMsgId = await chats[0].sendToRoom(createdRoom.roomId, "final message"); + const finalMsgId = (await chats[0].sendToRoom(createdRoom.roomId, "final message")).messageId; assert.equal(finalMsgId, chats.length.toString(), `sent message id should be ${chats.length} but got ${finalMsgId}`); // sleep 100ms @@ -319,7 +319,7 @@ test("self add restores local room cache without RoomJoined event", { timeout: L assert.equal(chat1.hasJoinedRoom(created.roomId), true, "self add should restore the missing local room cache entry"); assert.equal(roomJoinedEvents, 0, "self cache restore should not emit a synthetic RoomJoined event"); - const sentMsgId = await chat1.sendToRoom(created.roomId, "self-add-cache-restored"); + const sentMsgId = (await chat1.sendToRoom(created.roomId, "self-add-cache-restored")).messageId; assert.ok(sentMsgId, "sendToRoom should succeed after the cache is restored"); } catch (e) { t.diagnostic((e as any).toString()); diff --git a/sdk/webpubsub-chat-client/tests/lifecycle.test.ts b/sdk/webpubsub-chat-client/tests/lifecycle.test.ts index 1bdf221c..ed7e3681 100644 --- a/sdk/webpubsub-chat-client/tests/lifecycle.test.ts +++ b/sdk/webpubsub-chat-client/tests/lifecycle.test.ts @@ -1,7 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { EventEmitter } from "node:events"; -import type { WebPubSubClient, WebPubSubDataType } from "@azure/web-pubsub-client"; +import type { WebPubSubClientCredential, WebPubSubDataType } from "@azure/web-pubsub-client"; import { ChatClient } from "../src/chatClient.js"; import { INVOCATION_NAME } from "../src/constant.js"; import type { RoomInfoWithMembers, UserProfile } from "../src/generatedTypes.js"; @@ -122,7 +122,7 @@ class FakeWebPubSubClient { } function createClient(fakeClient: FakeWebPubSubClient): ChatClient { - return new ChatClient(fakeClient as unknown as WebPubSubClient); + return new ChatClient(fakeClient as unknown as WebPubSubClientCredential); } test("stop before start is a no-op", async () => { diff --git a/sdk/webpubsub-chat-client/tests/testUtils.ts b/sdk/webpubsub-chat-client/tests/testUtils.ts index 96dece28..48df6c00 100644 --- a/sdk/webpubsub-chat-client/tests/testUtils.ts +++ b/sdk/webpubsub-chat-client/tests/testUtils.ts @@ -1,4 +1,3 @@ -import { WebPubSubClient } from "@azure/web-pubsub-client"; import { ChatClient } from "../src/chatClient.js"; import { randomInt as secureRandomInt } from "crypto"; @@ -22,7 +21,7 @@ export async function createTestClient(userId?: string): Promise { if (!userId) { userId = `uid-${randomInt()}`; } - const wpsClient = new WebPubSubClient({ + return await ChatClient.start({ getClientAccessUrl: async () => { const res = await fetch(negotiateUrl + (userId ? `?userId=${encodeURIComponent(userId)}` : "")); const value = (await res.json()) as { url?: string }; @@ -30,7 +29,6 @@ export async function createTestClient(userId?: string): Promise { return value.url; }, }); - return await ChatClient.start(wpsClient); } export async function getMultipleClients(count: number): Promise {