Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions sdk/webpubsub-chat-client/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@ tsconfig.tsbuildinfo
.env
.yarn
*.tgz
# api-extractor working directory
temp
package-lock.json
114 changes: 74 additions & 40 deletions sdk/webpubsub-chat-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,12 @@ const client = await ChatClient.start(wpsClient);
console.log(`Started as: ${client.userId}`);

// Listen for events
client.onMessage((event) => {
client.on('message', (event) => {
const msg = event.message;
console.log(`${msg.createdBy}: ${msg.content.text}`);
});

client.onRoomJoined((event) => {
client.on('room-joined', (event) => {
console.log(`Joined room: ${event.room.title}`);
});

Expand All @@ -44,7 +44,7 @@ const room = await client.createRoom('My Room', ['bob']);
await client.sendToRoom(room.roomId, 'Hello!');

// Get message history (auto-paginating async iterator)
for await (const msg of client.listRoomMessages({ roomId: room.roomId })) {
for await (const msg of client.listRoomMessages(room.roomId)) {
console.log(`${msg.createdBy}: ${msg.content.text}`);
}

Expand All @@ -63,74 +63,108 @@ await client.stop();
#### Constructor

```typescript
// With existing WebPubSubClient
new ChatClient(wpsClient: WebPubSubClient)

// With client access URL
new ChatClient(clientAccessUrl: string, options?: WebPubSubClientOptions)

// With credential
new ChatClient(credential: WebPubSubClientCredential, options?: WebPubSubClientOptions)
```

To construct and start in one step, prefer the static `ChatClient.start(...)` factory below.
`ChatClient` always wraps a pre-constructed `WebPubSubClient` and owns its lifecycle: `start()` starts the transport, `stop()` stops it.

When constructed from an existing `WebPubSubClient`, `ChatClient` owns that client's lifecycle: `start()` starts it and `stop()` stops it.
To construct from a client-access URL or `WebPubSubClientCredential`, use the static `ChatClient.start(...)` factory below — it builds the underlying `WebPubSubClient` and starts the chat client atomically, so callers never observe a half-constructed instance.

#### Static Methods

| Method | Description |
|--------|-------------|
| `ChatClient.start(clientAccessUrl, options?)` | Create a client and start it in one step |
| `ChatClient.start(credential, options?)` | Create a client from a credential and start it |
| `ChatClient.start(wpsClient)` | Create a client from an existing `WebPubSubClient` and start it |
| `ChatClient.start(clientAccessUrl, webPubSubClientOptions?, options?)` | Construct from a URL and start (`webPubSubClientOptions?: WebPubSubClientOptions`, `options?: StartOptions`) |
| `ChatClient.start(credential, webPubSubClientOptions?, options?)` | Construct from a credential and start (same option shape as above) |
| `ChatClient.start(wpsClient, options?)` | Start a pre-constructed `WebPubSubClient` (`options?: StartOptions`) |

#### Properties

| Property | Type | Description |
|----------|------|-------------|
| `userId` | `string` | Current user's ID (throws if not started) |
| `isStarted` | `boolean` | `true` once `start()` has completed and `stop()` has not been called since |
| `userId` | `string` | Current user's ID (throws if not started). Read-only — set internally on `start()`. |
| `rooms` | `RoomInfo[]` | Snapshot of currently joined rooms (not live-updated) |
| `connection` | `WebPubSubClient` | Underlying WebPubSub connection owned by this chat client |

#### Methods

| Method | Description |
|--------|-------------|
| `start()` | Connect and authenticate. Idempotent; concurrent calls share one in-flight promise. After `stop()` the client can be started again. |
| `start(options?)` | Connect and authenticate. Idempotent; concurrent calls share one in-flight promise. After `stop()` the client can be started again. Accepts `{ abortSignal }`. |
| `stop()` | Disconnect and reset client state. Returns `Promise<void>`. |
| `createRoom(title, members, roomId?)` | Create a new room with initial members. The current user is automatically added to the members list. |
| `getRoom(roomId, withMembers)` | Get room info |
| `addUserToRoom(roomId, userId)` | Add user to room (admin operation) |
| `removeUserFromRoom(roomId, userId)` | Remove user from room (admin operation) |
| `sendToRoom(roomId, message)` | Send text message to room, returns message ID |
| `listRoomMessages(options)` | Paged async iterator over room message history (auto-paginates). Use `for await` to stream every message, or `.byPage({ maxPageSize })` to load up to `maxPageSize` messages at a time. `options = { roomId, startId?, endId?, pageSize? }` |
| `getUserInfo(userId)` | Get user profile |
| `createRoom(title, members, options?)` | Create a new room with initial members. The current user is automatically added to the members list. Options: `{ roomId?, abortSignal? }` — supply `roomId` to choose an explicit id, otherwise the service assigns one. |
| `getRoomDetail(roomId, options?)` | Get room info. Options: `{ withMembers?, abortSignal? }` — set `withMembers: true` to populate the members list (extra round-trip). |
| `addUserToRoom(roomId, userId, options?)` | Add user to room (admin operation) |
| `removeUserFromRoom(roomId, userId, options?)` | Remove user from room (admin operation) |
| `sendToRoom(roomId, message, options?)` | Send text message to room, returns message ID |
| `listRoomMessages(roomId, options?)` | Paged async iterator over room message history (auto-paginates). Use `for await` to stream every message, or `.byPage({ maxPageSize })` to load up to `maxPageSize` messages at a time. `options = { startId?, endId?, maxPageSize?, abortSignal? }` |
| `getUserProfile(userId, options?)` | Get user profile |

Every asynchronous method accepts an optional final `options` argument
extending `OperationOptions` (`{ abortSignal?: AbortSignalLike }`) to
cancel in-flight invocations:

```ts
const ac = new AbortController();
setTimeout(() => ac.abort(), 5000);
await client.sendToRoom(roomId, "hi", { abortSignal: ac.signal });
```

#### Errors

Operations reject with a `ChatError` (extends `Error`) carrying a
service-defined `code`. Known codes are exposed as `KnownChatErrorCode`;
the service may return additional codes in newer versions, so always
handle the unknown-code case.

```ts
import { ChatError, KnownChatErrorCode } from "@azure/web-pubsub-chat-client";

try {
await client.sendToRoom(roomId, "hi");
} catch (err) {
if (err instanceof ChatError && err.code === KnownChatErrorCode.UnknownRoom) {
// re-join or refresh local state
} else {
throw err;
}
}
```

| Code | Meaning |
|------|---------|
| `KnownChatErrorCode.RoomAlreadyExists` | Tried to create a room whose `roomId` is already in use. |
| `KnownChatErrorCode.UserAlreadyInRoom` | Adding a user that is already a member. |
| `KnownChatErrorCode.NoPermissionInRoom` | Caller lacks permission to perform the operation. |
| `KnownChatErrorCode.NotStarted` | An API was called before `start()` resolved. |
| `KnownChatErrorCode.UnknownRoom` | The room is not in the client's local cache (joined or created). |
| `KnownChatErrorCode.InvalidServerResponse` | The service returned a malformed response. |

#### Event Listeners

Chat events are subscribed via a single generic `on(event, callback)` API
or typed convenience methods (`onMessage`, `onRoomJoined`, ...). All
listener registrations return a `Disposable` (`() => void`) that removes
the listener when called. Use `off(event, callback)` to unsubscribe a
specific callback.
Chat events use the same shape as the underlying `WebPubSubClient`:
one `on(event, listener)` overload per event, returning `void`, paired
with `off(event, listener)` for removal. Pass the same callback
reference to `off()` to unregister.

```ts
const dispose = client.on('message', (event) => {
const onMsg = (event) => {
console.log(event.message.content.text);
});
};
client.on('message', onMsg);
// later
dispose();
client.off('message', onMsg);
```

| Event name | Convenience method | Payload | Description |
|------------|-------------------|---------|-------------|
| `message` | `onMessage(cb)` | `MessageEvent` | New message received (or sent by this client). |
| `roomJoined` | `onRoomJoined(cb)` | `RoomJoinedEvent` | This client joined a room. |
| `roomLeft` | `onRoomLeft(cb)` | `RoomLeftEvent` | This client left a room. |
| `memberJoined` | `onMemberJoined(cb)` | `MemberJoinedEvent` | Another user joined a room this client is in. |
| `memberLeft` | `onMemberLeft(cb)` | `MemberLeftEvent` | Another user left a room this client is in. |
| Event name | Listener argument | Description |
|------------|-------------------|-------------|
| `started` | `OnStartedArgs` | `start()` completed successfully — `userId` and `rooms` are populated. |
| `stopped` | `OnStoppedArgs` | The client transitioned to not-started (explicit `stop()` or transport-driven). |
| `message` | `OnMessageArgs` | New message received (or sent by this client). |
| `room-joined` | `OnRoomJoinedArgs` | This client joined a room. |
| `room-left` | `OnRoomLeftArgs` | This client left a room. |
| `member-joined` | `OnMemberJoinedArgs` | Another user joined a room this client is in. |
| `member-left` | `OnMemberLeftArgs` | Another user left a room this client is in. |

Connection-lifecycle events (`connected`, `disconnected`, `stopped`) live
on the underlying `WebPubSubClient`. Subscribe through `client.connection`:
Expand Down
36 changes: 36 additions & 0 deletions sdk/webpubsub-chat-client/api-extractor.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",

"mainEntryPointFilePath": "<projectFolder>/dist/index.d.ts",

"apiReport": {
"enabled": true,
"reportFolder": "<projectFolder>/review/",
"reportFileName": "<unscopedPackageName>.api.md"
},

"docModel": {
"enabled": false
},

"dtsRollup": {
"enabled": false
},

"tsdocMetadata": {
"enabled": false
},

"messages": {
"compilerMessageReporting": {
"default": { "logLevel": "warning" }
},
"extractorMessageReporting": {
"default": { "logLevel": "warning" },
"ae-missing-release-tag": { "logLevel": "none" }
},
"tsdocMessageReporting": {
"default": { "logLevel": "warning" }
}
}
}
4 changes: 2 additions & 2 deletions sdk/webpubsub-chat-client/examples/quickstart/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,15 +77,15 @@ async function main() {

// List message history (auto-paginating async iterator)
console.log('\n--- Message History ---');
for await (const msg of alice.listRoomMessages({ roomId: room.roomId })) {
for await (const msg of alice.listRoomMessages(room.roomId)) {
console.log(` [${msg.createdBy}] [${msg.createdAt}] ${msg.content.text}`);
}

// Or load history one page at a time (Teams-style scroll-back).
// `byPage` lets the caller decide when to load the next batch — handy
// for "load 50 latest, then 50 more on scroll-up" UI patterns.
console.log('\n--- Message History (pages of 3) ---');
const pages = alice.listRoomMessages({ roomId: room.roomId }).byPage({ maxPageSize: 3 });
const pages = alice.listRoomMessages(room.roomId).byPage({ maxPageSize: 3 });
let pageNum = 0;
while (true) {
const { value, done } = await pages.next();
Expand Down
6 changes: 5 additions & 1 deletion sdk/webpubsub-chat-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,16 +49,20 @@
"test:start-server": "node .\\examples\\quickstart\\server.js",
"test": "npx tsx --test tests/**/*.ts",
"test:integration": "tsx --test tests/integration.test.ts",
"test:one": "tsx --test --test-name-pattern"
"test:one": "tsx --test --test-name-pattern",
"extract-api": "api-extractor run --local",
"check-api": "api-extractor run"
},
"dependencies": {
"@azure/abort-controller": "^2.1.2",
"@azure/core-paging": "^1.6.2",
"@azure/logger": "^1.3.0",
"@azure/web-pubsub-client": "1.0.4",
"events": "^3.3.0",
"ws": "^8.0.0"
},
"devDependencies": {
"@microsoft/api-extractor": "^7.58.7",
"@types/events": "^3.0.3",
"@types/node": "^25.0.3",
"esbuild": "^0.27.3",
Expand Down
Loading
Loading