From 747d4fba1b4494614f01e13bdd407b763b60d87c Mon Sep 17 00:00:00 2001 From: "Liangying.Wei" Date: Tue, 9 Jun 2026 16:19:57 +1000 Subject: [PATCH 1/6] chat-client: curate public models - hide generated schemas & conversation concept, document public API Stops the auto-generated OpenAPI aggregates from leaking into the public API and hides the internal "conversation" concept, then documents every remaining public member. The generated API report shrinks by ~280 lines. - Add src/models.ts: hand-curated, standalone public domain types (MessageInfo, RoomInfo, RoomInfoWithMembers, UserProfile). index.ts no longer re-exports the generated `components`/`Schemas` aggregates and has zero reference to generatedTypes. - Add src/modelGuards.ts: a type-only, compile-time guard that fails the build if a field the public models expose drifts from the generated wire schema. One-directional (wire must satisfy the model) so internal-only fields can be intentionally omitted. - Hide the conversation concept from the public surface: drop RoomInfo.defaultConversationId and UserProfile.conversationIds. Internal code keeps them via aliased wire types (WireRoomInfo, WireUserProfile, WireRoomInfoWithMembers); public signatures use the curated models. Login room hydration keeps its explicit `withMembers: false`. - Document all previously-undocumented public APIs: the ChatClient and ChatError classes, `connection`, `getUserProfile`, `sendToRoom`, `getRoomDetail`, `userId`, the event-argument fields, and the on/off/static-start overloads. Verified: build, tsc --noEmit, api-extractor (no ae-forgotten-export, no undocumented members), and the lifecycle unit tests (8/8) all pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../review/web-pubsub-chat-client.api.md | 317 ++---------------- sdk/webpubsub-chat-client/src/chatClient.ts | 143 ++++++-- sdk/webpubsub-chat-client/src/events.ts | 11 +- sdk/webpubsub-chat-client/src/index.ts | 4 +- sdk/webpubsub-chat-client/src/modelGuards.ts | 27 ++ sdk/webpubsub-chat-client/src/models.ts | 61 ++++ 6 files changed, 237 insertions(+), 326 deletions(-) create mode 100644 sdk/webpubsub-chat-client/src/modelGuards.ts create mode 100644 sdk/webpubsub-chat-client/src/models.ts 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..1a3ea1d9 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 @@ -14,63 +14,44 @@ import { WebPubSubClientOptions } from '@azure/web-pubsub-client'; export interface AddUserToRoomOptions extends OperationOptions { } -// @public (undocumented) +// @public 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) +// @public export class ChatError extends Error { constructor(message: string, code: string); - // (undocumented) readonly code: string; } @@ -78,252 +59,6 @@ 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; @@ -355,26 +90,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 +126,12 @@ export interface OnMessageArgs { // @public export interface OnRoomJoinedArgs { - // (undocumented) room: RoomInfo; } // @public export interface OnRoomLeftArgs { - // (undocumented) roomId: string; - // (undocumented) title: string; } @@ -416,14 +153,17 @@ export interface OperationOptions { export interface RemoveUserFromRoomOptions extends OperationOptions { } -// @public (undocumented) -export type RoomInfo = Schemas["RoomInfo"]; - -// @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 RoomInfoWithMembers extends RoomInfo { + members: string[]; +} // @public export interface SendToRoomOptions extends OperationOptions { @@ -433,8 +173,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..475fe308 100644 --- a/sdk/webpubsub-chat-client/src/chatClient.ts +++ b/sdk/webpubsub-chat-client/src/chatClient.ts @@ -2,11 +2,10 @@ import { InvocationError, WebPubSubClient, WebPubSubClientCredential, WebPubSubC 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, RoomInfoWithMembers, UserProfile } from "./models.js"; import type { ChatMessage, OnMemberJoinedArgs, @@ -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,39 @@ 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 an existing `WebPubSubClient`, or use the static + * {@link ChatClient.start} factory to build and start in one step from a + * client-access URL or `WebPubSubClientCredential`. + * + * @example + * ```ts + * const client = await ChatClient.start(clientAccessUrl); + * 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 { + /** + * The underlying `WebPubSubClient` transport. Use it to subscribe to + * connection-lifecycle events (`connected`, `disconnected`, `stopped`) + * via `connection.on(...)`. Prefer the `ChatClient` methods over driving + * the connection directly, since bypassing them can desynchronize chat + * state. + */ public 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; @@ -136,7 +170,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 }; @@ -246,11 +280,13 @@ class ChatClient { webPubSubClientOptions?: WebPubSubClientOptions, options?: StartOptions, ): Promise; + /** Build the underlying `WebPubSubClient` from a credential, then start. See the primary overload for details. */ public static async start( credential: WebPubSubClientCredential, webPubSubClientOptions?: WebPubSubClientOptions, options?: StartOptions, ): Promise; + /** Start using an already-constructed `WebPubSubClient`. See the primary overload for details. */ public static async start(wpsClient: WebPubSubClient, options?: StartOptions): Promise; public static async start( arg1: string | WebPubSubClientCredential | WebPubSubClient, @@ -316,7 +352,7 @@ class ChatClient { this._connectionStoppedTCS = new PromiseCompletionSource(); this._isConnectionStopping = false; - const loginResponse = await this.invokeWithReturnType( + const loginResponse = await this.invokeWithReturnType( INVOCATION_NAME.LOGIN, "", "text", @@ -326,12 +362,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 +391,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", @@ -414,6 +454,16 @@ class ChatClient { return msgId; } + /** + * 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 }`. + */ public async sendToRoom(roomId: string, message: string, options?: SendToRoomOptions): Promise { this.ensureStarted(); const conversationId = this._rooms.get(roomId)?.defaultConversationId; @@ -423,6 +473,17 @@ class ChatClient { return await this.sendToConversation(conversationId, message, options); } + // 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?: GetRoomOptions): Promise { + return this.invokeWithReturnType( + INVOCATION_NAME.GET_ROOM, + { id: roomId, withMembers: options?.withMembers ?? false }, + "json", + options, + ); + } + /** * Fetch the latest service-side view of a room. * @@ -433,12 +494,7 @@ class ChatClient { */ public async getRoomDetail(roomId: string, options?: GetRoomOptions): Promise { this.ensureStarted(); - return this.invokeWithReturnType( - INVOCATION_NAME.GET_ROOM, - { id: roomId, withMembers: options?.withMembers ?? false }, - "json", - options, - ); + return this.fetchRoomDetail(roomId, options); } /** @@ -464,7 +520,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 +543,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 +662,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); @@ -633,25 +694,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); } 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..21246da7 100644 --- a/sdk/webpubsub-chat-client/src/index.ts +++ b/sdk/webpubsub-chat-client/src/index.ts @@ -2,13 +2,11 @@ import { ChatClient, ChatError } from './chatClient.js'; import { ERRORS } from './constant.js'; export type { - components, MessageInfo, RoomInfo, RoomInfoWithMembers, - Schemas, UserProfile, -} from './generatedTypes.js'; +} from './models.js'; export type { ChatMessage, diff --git a/sdk/webpubsub-chat-client/src/modelGuards.ts b/sdk/webpubsub-chat-client/src/modelGuards.ts new file mode 100644 index 00000000..b6b45c1b --- /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, RoomInfoWithMembers, 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 _GuardRoomInfoWithMembers = 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..dca1b964 --- /dev/null +++ b/sdk/webpubsub-chat-client/src/models.ts @@ -0,0 +1,61 @@ +/** + * 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; +} + +/** A {@link RoomInfo} together with its member list. */ +export interface RoomInfoWithMembers extends RoomInfo { + /** User ids of the room's members. */ + 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[]; +} From 90e9cafc57ff0260d8fc49169a6be53f0f2e4222 Mon Sep 17 00:00:00 2001 From: "Liangying.Wei" Date: Tue, 9 Jun 2026 16:51:26 +1000 Subject: [PATCH 2/6] chat-client: make getRoomDetail members contract type-safe; rename RoomInfoWithMembers -> RoomDetail getRoomDetail() previously always returned RoomInfoWithMembers even though `members` is only populated when called with `withMembers: true`, so the type guaranteed a field that was usually absent. Split it into overloads so the return type reflects the argument: getRoomDetail(id, { withMembers: true }) -> RoomDetail (members populated) getRoomDetail(id) -> RoomInfo (lightweight) Also: - Rename the public type RoomInfoWithMembers -> RoomDetail (future-proof: reserved to carry further room detail such as conversations). The generated wire schema name is unchanged; internal code keeps using the WireRoomInfoWithMembers alias. - Rename GetRoomOptions -> GetRoomDetailOptions for method/type consistency. - Fix the misleading "extra round-trip" note (withMembers is a response-shaping flag on a single call) in the options doc and README. Verified: build, tsc --noEmit, api-extractor (no ae-forgotten-export, 0 undocumented), and the lifecycle unit tests (8/8) all pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk/webpubsub-chat-client/README.md | 2 +- .../review/web-pubsub-chat-client.api.md | 19 ++++++----- sdk/webpubsub-chat-client/src/chatClient.ts | 33 ++++++++++++++----- sdk/webpubsub-chat-client/src/index.ts | 4 +-- sdk/webpubsub-chat-client/src/modelGuards.ts | 4 +-- sdk/webpubsub-chat-client/src/models.ts | 8 +++-- sdk/webpubsub-chat-client/src/options.ts | 8 ++--- 7 files changed, 51 insertions(+), 27 deletions(-) diff --git a/sdk/webpubsub-chat-client/README.md b/sdk/webpubsub-chat-client/README.md index 1d992932..4f7d16ae 100644 --- a/sdk/webpubsub-chat-client/README.md +++ b/sdk/webpubsub-chat-client/README.md @@ -93,7 +93,7 @@ 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 room info. Options: `{ withMembers?, abortSignal? }` — by default returns a lightweight `RoomInfo`; pass `withMembers: true` to get a `RoomDetail` with the `members` list populated. | | `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 | 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 1a3ea1d9..d722611b 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 @@ -19,8 +19,11 @@ export class ChatClient { constructor(wpsClient: WebPubSubClient); addUserToRoom(roomId: string, userId: string, options?: AddUserToRoomOptions): Promise; readonly connection: WebPubSubClient; - createRoom(title: string, members: string[], options?: CreateRoomOptions): Promise; - getRoomDetail(roomId: string, options?: GetRoomOptions): Promise; + createRoom(title: string, members: string[], options?: CreateRoomOptions): Promise; + getRoomDetail(roomId: string, options: GetRoomDetailOptions & { + withMembers: true; + }): Promise; + getRoomDetail(roomId: string, options?: GetRoomDetailOptions): Promise; getUserProfile(userId: string, options?: GetUserProfileOptions): Promise; hasJoinedRoom(roomId: string): boolean; listRoomMessages(roomId: string, options?: ListRoomMessagesOptions): PagedAsyncIterableIterator; @@ -65,7 +68,7 @@ export interface CreateRoomOptions extends OperationOptions { } // @public -export interface GetRoomOptions extends OperationOptions { +export interface GetRoomDetailOptions extends OperationOptions { withMembers?: boolean; } @@ -153,6 +156,11 @@ export interface OperationOptions { export interface RemoveUserFromRoomOptions extends OperationOptions { } +// @public +export interface RoomDetail extends RoomInfo { + members: string[]; +} + // @public export interface RoomInfo { properties?: Record | null; @@ -160,11 +168,6 @@ export interface RoomInfo { title: string; } -// @public -export interface RoomInfoWithMembers extends RoomInfo { - members: string[]; -} - // @public export interface SendToRoomOptions extends OperationOptions { } diff --git a/sdk/webpubsub-chat-client/src/chatClient.ts b/sdk/webpubsub-chat-client/src/chatClient.ts index 475fe308..a387b9f7 100644 --- a/sdk/webpubsub-chat-client/src/chatClient.ts +++ b/sdk/webpubsub-chat-client/src/chatClient.ts @@ -15,7 +15,7 @@ import { MemberLeftNotificationBody, RoomLeftNotificationBody, } from "./generatedTypes.js"; -import type { MessageInfo, RoomInfo, RoomInfoWithMembers, UserProfile } from "./models.js"; +import type { MessageInfo, RoomInfo, RoomDetail, UserProfile } from "./models.js"; import type { ChatMessage, OnMemberJoinedArgs, @@ -30,7 +30,7 @@ import type { ListRoomMessagesOptions, OperationOptions, StartOptions, - GetRoomOptions, + GetRoomDetailOptions, CreateRoomOptions, SendToRoomOptions, GetUserProfileOptions, @@ -475,7 +475,7 @@ class ChatClient { // 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?: GetRoomOptions): Promise { + private async fetchRoomDetail(roomId: string, options?: GetRoomDetailOptions): Promise { return this.invokeWithReturnType( INVOCATION_NAME.GET_ROOM, { id: roomId, withMembers: options?.withMembers ?? false }, @@ -484,15 +484,32 @@ class ChatClient { ); } + /** + * Fetch the detailed view of a room, including its member list. + * + * @param roomId - Room to query. + * @param options - Pass `{ withMembers: true }` to populate `members`. + * @returns A {@link RoomDetail} with `members` populated. + */ + public async getRoomDetail( + roomId: string, + options: GetRoomDetailOptions & { withMembers: true }, + ): Promise; /** * Fetch the latest service-side view of a room. * + * By default the lightweight {@link RoomInfo} is returned. Pass + * `{ withMembers: true }` (see the other overload) to instead get the + * fuller {@link RoomDetail} with its `members` list populated. + * * @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. + * @param options - Optional `{ withMembers, abortSignal }`. */ - public async getRoomDetail(roomId: string, options?: GetRoomOptions): Promise { + public async getRoomDetail(roomId: string, options?: GetRoomDetailOptions): Promise; + public async getRoomDetail( + roomId: string, + options?: GetRoomDetailOptions, + ): Promise { this.ensureStarted(); return this.fetchRoomDetail(roomId, options); } @@ -511,7 +528,7 @@ class ChatClient { title: string, members: string[], options?: CreateRoomOptions, - ): Promise { + ): Promise { this.ensureStarted(); let roomDetails = { title: title, diff --git a/sdk/webpubsub-chat-client/src/index.ts b/sdk/webpubsub-chat-client/src/index.ts index 21246da7..403169c2 100644 --- a/sdk/webpubsub-chat-client/src/index.ts +++ b/sdk/webpubsub-chat-client/src/index.ts @@ -4,7 +4,7 @@ import { ERRORS } from './constant.js'; export type { MessageInfo, RoomInfo, - RoomInfoWithMembers, + RoomDetail, UserProfile, } from './models.js'; @@ -22,7 +22,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 index b6b45c1b..167fa4d1 100644 --- a/sdk/webpubsub-chat-client/src/modelGuards.ts +++ b/sdk/webpubsub-chat-client/src/modelGuards.ts @@ -15,7 +15,7 @@ * It has no runtime effect (type-only). */ import type { Schemas } from "./generatedTypes.js"; -import type { MessageInfo, RoomInfo, RoomInfoWithMembers, UserProfile } from "./models.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`. */ @@ -23,5 +23,5 @@ type WireSatisfies = [W] extends [M] ? true : false; type _GuardMessageInfo = Assert>; type _GuardRoomInfo = Assert>; -type _GuardRoomInfoWithMembers = 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 index dca1b964..2f8b411f 100644 --- a/sdk/webpubsub-chat-client/src/models.ts +++ b/sdk/webpubsub-chat-client/src/models.ts @@ -46,8 +46,12 @@ export interface RoomInfo { properties?: Record | null; } -/** A {@link RoomInfo} together with its member list. */ -export interface RoomInfoWithMembers extends RoomInfo { +/** + * The detailed view of a room: a {@link RoomInfo} plus its member list. + * Returned by `createRoom()` and by `getRoomDetail(..., { withMembers: true })`. + * Reserved to grow with further room detail (e.g. conversations) over time. + */ +export interface RoomDetail extends RoomInfo { /** User ids of the room's members. */ members: string[]; } diff --git a/sdk/webpubsub-chat-client/src/options.ts b/sdk/webpubsub-chat-client/src/options.ts index 4784c6f7..cc46d8c1 100644 --- a/sdk/webpubsub-chat-client/src/options.ts +++ b/sdk/webpubsub-chat-client/src/options.ts @@ -22,11 +22,11 @@ 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`, `getRoomDetail` returns a {@link RoomDetail} with its + * `members` array populated. Defaults to `false`, returning the + * lightweight {@link RoomInfo}. */ withMembers?: boolean; } From aed3a7973f31874fd73cd014bd2e04a689a32366 Mon Sep 17 00:00:00 2001 From: "Liangying.Wei" Date: Tue, 9 Jun 2026 16:57:32 +1000 Subject: [PATCH 3/6] chat-client: getRoomDetail always returns RoomDetail; make RoomDetail.members optional Drop the separate RoomInfo-returning overload of getRoomDetail in favor of a single signature that always returns RoomDetail, and make RoomDetail.members optional so the type stays honest whether or not members were requested. The `withMembers` option is retained and now simply controls whether `members` is populated (left undefined otherwise). Verified: build, tsc --noEmit, integration test type-check, api-extractor (0 undocumented), and the lifecycle unit tests (8/8) all pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk/webpubsub-chat-client/README.md | 2 +- .../review/web-pubsub-chat-client.api.md | 7 ++--- sdk/webpubsub-chat-client/src/chatClient.ts | 27 ++++--------------- sdk/webpubsub-chat-client/src/models.ts | 14 ++++++---- sdk/webpubsub-chat-client/src/options.ts | 5 ++-- .../tests/integration.test.ts | 2 +- 6 files changed, 20 insertions(+), 37 deletions(-) diff --git a/sdk/webpubsub-chat-client/README.md b/sdk/webpubsub-chat-client/README.md index 4f7d16ae..04e345f3 100644 --- a/sdk/webpubsub-chat-client/README.md +++ b/sdk/webpubsub-chat-client/README.md @@ -93,7 +93,7 @@ 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? }` — by default returns a lightweight `RoomInfo`; pass `withMembers: true` to get a `RoomDetail` with the `members` list populated. | +| `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 | 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 d722611b..a3e2b66e 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 @@ -20,10 +20,7 @@ export class ChatClient { addUserToRoom(roomId: string, userId: string, options?: AddUserToRoomOptions): Promise; readonly connection: WebPubSubClient; createRoom(title: string, members: string[], options?: CreateRoomOptions): Promise; - getRoomDetail(roomId: string, options: GetRoomDetailOptions & { - withMembers: true; - }): Promise; - getRoomDetail(roomId: string, options?: GetRoomDetailOptions): Promise; + getRoomDetail(roomId: string, options?: GetRoomDetailOptions): Promise; getUserProfile(userId: string, options?: GetUserProfileOptions): Promise; hasJoinedRoom(roomId: string): boolean; listRoomMessages(roomId: string, options?: ListRoomMessagesOptions): PagedAsyncIterableIterator; @@ -158,7 +155,7 @@ export interface RemoveUserFromRoomOptions extends OperationOptions { // @public export interface RoomDetail extends RoomInfo { - members: string[]; + members?: string[]; } // @public diff --git a/sdk/webpubsub-chat-client/src/chatClient.ts b/sdk/webpubsub-chat-client/src/chatClient.ts index a387b9f7..f675974b 100644 --- a/sdk/webpubsub-chat-client/src/chatClient.ts +++ b/sdk/webpubsub-chat-client/src/chatClient.ts @@ -485,31 +485,14 @@ class ChatClient { } /** - * Fetch the detailed view of a room, including its member list. + * Fetch the detailed view of a room. * * @param roomId - Room to query. - * @param options - Pass `{ withMembers: true }` to populate `members`. - * @returns A {@link RoomDetail} with `members` populated. + * @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 & { withMembers: true }, - ): Promise; - /** - * Fetch the latest service-side view of a room. - * - * By default the lightweight {@link RoomInfo} is returned. Pass - * `{ withMembers: true }` (see the other overload) to instead get the - * fuller {@link RoomDetail} with its `members` list populated. - * - * @param roomId - Room to query. - * @param options - Optional `{ withMembers, abortSignal }`. - */ - public async getRoomDetail(roomId: string, options?: GetRoomDetailOptions): Promise; - public async getRoomDetail( - roomId: string, - options?: GetRoomDetailOptions, - ): Promise { + public async getRoomDetail(roomId: string, options?: GetRoomDetailOptions): Promise { this.ensureStarted(); return this.fetchRoomDetail(roomId, options); } diff --git a/sdk/webpubsub-chat-client/src/models.ts b/sdk/webpubsub-chat-client/src/models.ts index 2f8b411f..539ad920 100644 --- a/sdk/webpubsub-chat-client/src/models.ts +++ b/sdk/webpubsub-chat-client/src/models.ts @@ -47,13 +47,17 @@ export interface RoomInfo { } /** - * The detailed view of a room: a {@link RoomInfo} plus its member list. - * Returned by `createRoom()` and by `getRoomDetail(..., { withMembers: true })`. - * Reserved to grow with further room detail (e.g. conversations) over time. + * 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. */ - members: string[]; + /** + * 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. */ diff --git a/sdk/webpubsub-chat-client/src/options.ts b/sdk/webpubsub-chat-client/src/options.ts index cc46d8c1..b8a9078e 100644 --- a/sdk/webpubsub-chat-client/src/options.ts +++ b/sdk/webpubsub-chat-client/src/options.ts @@ -24,9 +24,8 @@ export interface StartOptions extends OperationOptions {} /** Options for `ChatClient.getRoomDetail()`. */ export interface GetRoomDetailOptions extends OperationOptions { /** - * When `true`, `getRoomDetail` returns a {@link RoomDetail} with its - * `members` array populated. Defaults to `false`, returning the - * lightweight {@link RoomInfo}. + * 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..66ffa1b8 100644 --- a/sdk/webpubsub-chat-client/tests/integration.test.ts +++ b/sdk/webpubsub-chat-client/tests/integration.test.ts @@ -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"); From 6395faeb087411f9a3f00ee45b82b4dcda706d28 Mon Sep 17 00:00:00 2001 From: "Liangying.Wei" Date: Tue, 9 Jun 2026 17:38:06 +1000 Subject: [PATCH 4/6] chat-client: hide WebPubSubClient; construct from credential/URL, drop WebPubSubClientOptions Encapsulate the transport so consumers no longer deal with WebPubSubClient or its options: - The public constructor now takes a client-access URL or WebPubSubClientCredential (constructor(credential: string | WebPubSubClientCredential)) and builds the connection internally. The WebPubSubClient-injecting constructor is retained only as an @internal test seam. - The WebPubSubClient instance is now a private `_connection` field (previously the public `connection` property). - ChatClient.start collapses to a single start(credential, options?) signature; the WebPubSubClientOptions and pre-built-WebPubSubClient forms are removed. - testUtils and the quickstart example now construct via a credential; README updated (construction, static methods, properties, and the connection-lifecycle section). Verified: build, tsc --noEmit, api-extractor (no ae-forgotten-export, 0 undocumented), and the lifecycle unit tests (8/8) all pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk/webpubsub-chat-client/README.md | 32 ++--- .../examples/quickstart/client.js | 18 +-- .../review/web-pubsub-chat-client.api.md | 10 +- sdk/webpubsub-chat-client/src/chatClient.ts | 119 ++++++------------ sdk/webpubsub-chat-client/tests/testUtils.ts | 4 +- 5 files changed, 59 insertions(+), 124 deletions(-) diff --git a/sdk/webpubsub-chat-client/README.md b/sdk/webpubsub-chat-client/README.md index 04e345f3..5eb5ec60 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,16 @@ await client.stop(); #### Constructor ```typescript -new ChatClient(wpsClient: WebPubSubClient) +new ChatClient(credential: string | 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 client-access URL or 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 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(credential, options?)` | Construct from a client-access URL or `WebPubSubClientCredential` and start (`options?: StartOptions`) | #### Properties @@ -84,7 +79,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 @@ -166,14 +160,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 a3e2b66e..4cd24961 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 @@ -8,7 +8,6 @@ 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 { @@ -16,9 +15,10 @@ export interface AddUserToRoomOptions extends OperationOptions { // @public export class ChatClient { - constructor(wpsClient: WebPubSubClient); + constructor(credential: string | WebPubSubClientCredential); + // @internal + constructor(connection: WebPubSubClient); addUserToRoom(roomId: string, userId: string, options?: AddUserToRoomOptions): Promise; - readonly connection: WebPubSubClient; createRoom(title: string, members: string[], options?: CreateRoomOptions): Promise; getRoomDetail(roomId: string, options?: GetRoomDetailOptions): Promise; getUserProfile(userId: string, options?: GetUserProfileOptions): Promise; @@ -41,9 +41,7 @@ export class ChatClient { removeUserFromRoom(roomId: string, userId: string, options?: RemoveUserFromRoomOptions): Promise; get rooms(): RoomInfo[]; sendToRoom(roomId: string, message: string, options?: SendToRoomOptions): Promise; - static start(clientAccessUrl: string, webPubSubClientOptions?: WebPubSubClientOptions, options?: StartOptions): Promise; - static start(credential: WebPubSubClientCredential, webPubSubClientOptions?: WebPubSubClientOptions, options?: StartOptions): Promise; - static start(wpsClient: WebPubSubClient, options?: StartOptions): Promise; + static start(credential: string | WebPubSubClientCredential, options?: StartOptions): Promise; start(options?: StartOptions): Promise; stop(): Promise; get userId(): string; diff --git a/sdk/webpubsub-chat-client/src/chatClient.ts b/sdk/webpubsub-chat-client/src/chatClient.ts index f675974b..ac006052 100644 --- a/sdk/webpubsub-chat-client/src/chatClient.ts +++ b/sdk/webpubsub-chat-client/src/chatClient.ts @@ -1,4 +1,4 @@ -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 { @@ -98,14 +98,8 @@ class PromiseCompletionSource { * ``` */ class ChatClient { - /** - * The underlying `WebPubSubClient` transport. Use it to subscribe to - * connection-lifecycle events (`connected`, `disconnected`, `stopped`) - * via `connection.on(...)`. Prefer the `ChatClient` methods over driving - * the connection directly, since bypassing them can desynchronize chat - * state. - */ - 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(); @@ -118,27 +112,35 @@ class ChatClient { private _isConnectionStopping = false; /** - * Create a `ChatClient` that wraps an existing `WebPubSubClient`. + * Create a `ChatClient` from a client-access URL or 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()`, or use the static + * {@link ChatClient.start} factory to construct-and-start in one step. * - * 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 client-access URL, or a + * `WebPubSubClientCredential` that returns one. */ - constructor(wpsClient: WebPubSubClient) { - this.connection = wpsClient; - this.connection.on("group-message", (e) => { + constructor(credential: string | WebPubSubClientCredential); + /** @internal Construct from a pre-built connection. Test seam; not part of the public API. */ + constructor(connection: WebPubSubClient); + constructor(credentialOrConnection: string | WebPubSubClientCredential | WebPubSubClient) { + if (isWebPubSubClient(credentialOrConnection)) { + this._connection = credentialOrConnection; + } else if (typeof credentialOrConnection === "string") { + this._connection = new WebPubSubClient(credentialOrConnection); + } else { + this._connection = 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; @@ -225,7 +227,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); @@ -244,70 +246,23 @@ 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 credential - A client-access URL, or a + * `WebPubSubClientCredential` that returns one. + * @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); * ``` - * - * 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, - webPubSubClientOptions?: WebPubSubClientOptions, - options?: StartOptions, - ): Promise; - /** Build the underlying `WebPubSubClient` from a credential, then start. See the primary overload for details. */ - public static async start( - credential: WebPubSubClientCredential, - webPubSubClientOptions?: WebPubSubClientOptions, + credential: string | WebPubSubClientCredential, options?: StartOptions, - ): Promise; - /** Start using an already-constructed `WebPubSubClient`. See the primary overload for details. */ - 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 chatClient = new ChatClient(credential); + await chatClient.start({ abortSignal: options?.abortSignal }); return chatClient; } @@ -348,7 +303,7 @@ 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; @@ -779,7 +734,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/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 { From 9644a248cb1132b8d07db4b3c138a9717de8dc63 Mon Sep 17 00:00:00 2001 From: "Liangying.Wei" Date: Tue, 9 Jun 2026 17:47:58 +1000 Subject: [PATCH 5/6] chat-client: single credential constructor; start(url) and start(credential) overloads Per API review, narrow the public construction surface: - One public constructor: constructor(credential: WebPubSubClientCredential). WebPubSubClient no longer appears anywhere in the public API. The WebPubSubClient-injecting form remains only as a hidden implementation signature (test seam), distinguished at runtime via isWebPubSubClient. - Two static start overloads: start(clientAccessUrl: string, options?) and start(credential: WebPubSubClientCredential, options?). A string URL is wrapped into a credential internally so both funnel through the single constructor. - Update the lifecycle test injection cast and the README accordingly. Verified: build, tsc --noEmit, api-extractor (no ae-forgotten-export, 0 undocumented), and the lifecycle unit tests (8/8) all pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk/webpubsub-chat-client/README.md | 7 +-- .../review/web-pubsub-chat-client.api.md | 8 ++-- sdk/webpubsub-chat-client/src/chatClient.ts | 46 +++++++++++-------- .../tests/lifecycle.test.ts | 4 +- 4 files changed, 36 insertions(+), 29 deletions(-) diff --git a/sdk/webpubsub-chat-client/README.md b/sdk/webpubsub-chat-client/README.md index 5eb5ec60..915e276f 100644 --- a/sdk/webpubsub-chat-client/README.md +++ b/sdk/webpubsub-chat-client/README.md @@ -62,16 +62,17 @@ await client.stop(); #### Constructor ```typescript -new ChatClient(credential: string | WebPubSubClientCredential) +new ChatClient(credential: WebPubSubClientCredential) ``` -`ChatClient` is constructed from a client-access URL or 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 to construct-and-start in one step. +`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(credential, options?)` | Construct from a client-access URL or `WebPubSubClientCredential` and start (`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 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 4cd24961..878166a0 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,7 +6,6 @@ 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'; // @public @@ -15,9 +14,7 @@ export interface AddUserToRoomOptions extends OperationOptions { // @public export class ChatClient { - constructor(credential: string | WebPubSubClientCredential); - // @internal - constructor(connection: WebPubSubClient); + constructor(credential: WebPubSubClientCredential); addUserToRoom(roomId: string, userId: string, options?: AddUserToRoomOptions): Promise; createRoom(title: string, members: string[], options?: CreateRoomOptions): Promise; getRoomDetail(roomId: string, options?: GetRoomDetailOptions): Promise; @@ -41,7 +38,8 @@ export class ChatClient { removeUserFromRoom(roomId: string, userId: string, options?: RemoveUserFromRoomOptions): Promise; get rooms(): RoomInfo[]; sendToRoom(roomId: string, message: string, options?: SendToRoomOptions): Promise; - static start(credential: string | WebPubSubClientCredential, options?: StartOptions): Promise; + static start(clientAccessUrl: string, options?: StartOptions): Promise; + static start(credential: WebPubSubClientCredential, options?: StartOptions): Promise; start(options?: StartOptions): Promise; stop(): Promise; get userId(): string; diff --git a/sdk/webpubsub-chat-client/src/chatClient.ts b/sdk/webpubsub-chat-client/src/chatClient.ts index ac006052..7d83ffa9 100644 --- a/sdk/webpubsub-chat-client/src/chatClient.ts +++ b/sdk/webpubsub-chat-client/src/chatClient.ts @@ -112,28 +112,24 @@ class ChatClient { private _isConnectionStopping = false; /** - * Create a `ChatClient` from a client-access URL or a - * {@link WebPubSubClientCredential}. + * Create a `ChatClient` from a {@link WebPubSubClientCredential}. * * `ChatClient` builds and owns the underlying transport: `start()` * connects and authenticates, `stop()` disconnects. The instance is * created but not started — call `start()`, or use the static - * {@link ChatClient.start} factory to construct-and-start in one step. + * {@link ChatClient.start} factory (which also accepts a plain + * client-access URL) to construct-and-start in one step. * - * @param credential - A client-access URL, or a - * `WebPubSubClientCredential` that returns one. + * @param credential - A `WebPubSubClientCredential` that yields a + * client-access URL. */ - constructor(credential: string | WebPubSubClientCredential); - /** @internal Construct from a pre-built connection. Test seam; not part of the public API. */ - constructor(connection: WebPubSubClient); - constructor(credentialOrConnection: string | WebPubSubClientCredential | WebPubSubClient) { - if (isWebPubSubClient(credentialOrConnection)) { - this._connection = credentialOrConnection; - } else if (typeof credentialOrConnection === "string") { - this._connection = new WebPubSubClient(credentialOrConnection); - } else { - this._connection = new WebPubSubClient(credentialOrConnection); - } + 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); }); @@ -248,8 +244,7 @@ class ChatClient { /** * Create a `ChatClient` and `start()` it in one step. * - * @param credential - A client-access URL, or a - * `WebPubSubClientCredential` that returns one. + * @param clientAccessUrl - A client-access URL. * @param options - Optional cancellation token for the start operation. * * @example @@ -257,10 +252,23 @@ class ChatClient { * const chat = await ChatClient.start(clientAccessUrl); * ``` */ + public static async start(clientAccessUrl: string, options?: StartOptions): Promise; + /** + * Create a `ChatClient` and `start()` it in one step. + * + * @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( - credential: string | WebPubSubClientCredential, + clientAccessUrlOrCredential: string | WebPubSubClientCredential, options?: StartOptions, ): Promise { + const credential: WebPubSubClientCredential = + typeof clientAccessUrlOrCredential === "string" + ? { getClientAccessUrl: async () => clientAccessUrlOrCredential } + : clientAccessUrlOrCredential; const chatClient = new ChatClient(credential); await chatClient.start({ abortSignal: options?.abortSignal }); return chatClient; 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 () => { From 07f949696a83493ce4db1414577ae43e9e0a22ca Mon Sep 17 00:00:00 2001 From: xingsy97 Date: Wed, 10 Jun 2026 10:49:09 +0800 Subject: [PATCH 6/6] add sendMessageResult and fix wrong method comment --- sdk/webpubsub-chat-client/README.md | 2 +- .../review/web-pubsub-chat-client.api.md | 7 ++++- sdk/webpubsub-chat-client/src/chatClient.ts | 30 +++++++++---------- sdk/webpubsub-chat-client/src/index.ts | 1 + sdk/webpubsub-chat-client/src/models.ts | 6 ++++ .../tests/integration.test.ts | 10 +++---- 6 files changed, 34 insertions(+), 22 deletions(-) diff --git a/sdk/webpubsub-chat-client/README.md b/sdk/webpubsub-chat-client/README.md index 915e276f..0db58eee 100644 --- a/sdk/webpubsub-chat-client/README.md +++ b/sdk/webpubsub-chat-client/README.md @@ -91,7 +91,7 @@ new ChatClient(credential: WebPubSubClientCredential) | `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 | 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 878166a0..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 @@ -37,7 +37,7 @@ export class ChatClient { on(event: "member-left", listener: (e: OnMemberLeftArgs) => void): void; removeUserFromRoom(roomId: string, userId: string, options?: RemoveUserFromRoomOptions): Promise; get rooms(): RoomInfo[]; - sendToRoom(roomId: string, message: string, options?: SendToRoomOptions): 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; @@ -161,6 +161,11 @@ export interface RoomInfo { title: string; } +// @public +export interface SendMessageResult { + messageId: string; +} + // @public export interface SendToRoomOptions extends OperationOptions { } diff --git a/sdk/webpubsub-chat-client/src/chatClient.ts b/sdk/webpubsub-chat-client/src/chatClient.ts index 7d83ffa9..73d310d6 100644 --- a/sdk/webpubsub-chat-client/src/chatClient.ts +++ b/sdk/webpubsub-chat-client/src/chatClient.ts @@ -15,7 +15,7 @@ import { MemberLeftNotificationBody, RoomLeftNotificationBody, } from "./generatedTypes.js"; -import type { MessageInfo, RoomInfo, RoomDetail, UserProfile } from "./models.js"; +import type { MessageInfo, RoomInfo, RoomDetail, UserProfile, SendMessageResult } from "./models.js"; import type { ChatMessage, OnMemberJoinedArgs, @@ -85,13 +85,13 @@ class PromiseCompletionSource { * underlying connection's lifecycle — call {@link ChatClient.start} to * connect and authenticate, and {@link ChatClient.stop} to disconnect. * - * Construct from an existing `WebPubSubClient`, or use the static - * {@link ChatClient.start} factory to build and start in one step from a - * client-access URL or `WebPubSubClientCredential`. + * Construct from a `WebPubSubClientCredential`, then call `start()` to + * connect and authenticate. * * @example * ```ts - * const client = await ChatClient.start(clientAccessUrl); + * 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!"); @@ -116,9 +116,7 @@ class ChatClient { * * `ChatClient` builds and owns the underlying transport: `start()` * connects and authenticates, `stop()` disconnects. The instance is - * created but not started — call `start()`, or use the static - * {@link ChatClient.start} factory (which also accepts a plain - * client-access URL) to construct-and-start in one step. + * created but not started — call `start()` to connect. * * @param credential - A `WebPubSubClientCredential` that yields a * client-access URL. @@ -374,7 +372,7 @@ class ChatClient { conversationId: string, message: string, options?: OperationOptions, - ): Promise { + ): Promise { this.ensureStarted(); const payload = { conversation: { conversationId: conversationId }, @@ -398,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 = { @@ -414,7 +412,7 @@ class ChatClient { } as ChatMessage, }; this._emitter.emit("message", event); - return msgId; + return { messageId: msgId }; } /** @@ -426,8 +424,9 @@ class ChatClient { * @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 { + public async sendToRoom(roomId: string, message: string, options?: SendToRoomOptions): Promise { this.ensureStarted(); const conversationId = this._rooms.get(roomId)?.defaultConversationId; if (!conversationId) { @@ -645,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 diff --git a/sdk/webpubsub-chat-client/src/index.ts b/sdk/webpubsub-chat-client/src/index.ts index 403169c2..0af02c2f 100644 --- a/sdk/webpubsub-chat-client/src/index.ts +++ b/sdk/webpubsub-chat-client/src/index.ts @@ -6,6 +6,7 @@ export type { RoomInfo, RoomDetail, UserProfile, + SendMessageResult, } from './models.js'; export type { diff --git a/sdk/webpubsub-chat-client/src/models.ts b/sdk/webpubsub-chat-client/src/models.ts index 539ad920..0a8f7146 100644 --- a/sdk/webpubsub-chat-client/src/models.ts +++ b/sdk/webpubsub-chat-client/src/models.ts @@ -67,3 +67,9 @@ export interface UserProfile { /** 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/tests/integration.test.ts b/sdk/webpubsub-chat-client/tests/integration.test.ts index 66ffa1b8..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), @@ -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());