@tailuge/messaging 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MESSAGING_SPEC.md +391 -0
- package/README.md +14 -0
- package/SKILL.md +192 -0
- package/dist/types.d.ts +4 -4
- package/package.json +4 -2
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
# Multi-User Messaging Library Specification
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
This document outlines the requirements and contract for a unified messaging library designed to handle presence and real-time synchronization. The library uses the existing `NchanClient` as a transport layer (WebSockets for subscribe, POST for publish) and provides a semantic, stateful API for turn-based multi-user applications.
|
|
6
|
+
|
|
7
|
+
## Goals
|
|
8
|
+
|
|
9
|
+
- **Zero WebSocket Dependencies**: The consumer project should not interact with WebSockets directly.
|
|
10
|
+
- **Unified Client**: A single entry point for both global presence (lobby) and specific table messaging.
|
|
11
|
+
- **Stateful Presence**: Internal management of online users, including heartbeats and stale user pruning.
|
|
12
|
+
- **Semantic API**: Interaction through high-level methods rather than raw channel/URL manipulation.
|
|
13
|
+
- **Platform Agnostic**: Compatible with both Browser and Node.js environments.
|
|
14
|
+
- **Transport Reuse**: Leverages existing `NchanClient` from `src/lobby/nchanclient.ts` as the underlying transport.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Core API Contract
|
|
19
|
+
|
|
20
|
+
### `MessagingClient`
|
|
21
|
+
|
|
22
|
+
The main class exposed by the library. Uses `NchanClient` internally for all transport operations.
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
interface MessagingClient {
|
|
26
|
+
/**
|
|
27
|
+
* Initialize and start the client.
|
|
28
|
+
* Handles initial connection and automatic reconnection logic.
|
|
29
|
+
* In browser environments, attaches lifecycle event listeners.
|
|
30
|
+
*/
|
|
31
|
+
start(): void;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Stop all connections, timers (heartbeats), and clean up resources.
|
|
35
|
+
*/
|
|
36
|
+
stop(): Promise<void>;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Joins the global lobby to broadcast presence and see other online users.
|
|
40
|
+
*/
|
|
41
|
+
joinLobby(user: PresenceMessage, options?: LobbyOptions): Promise<Lobby>;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Joins a specific table (game room) for 2-player/spectator communication.
|
|
45
|
+
*/
|
|
46
|
+
joinTable<T = any>(tableId: string, userId: string): Promise<Table<T>>;
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### `Lobby`
|
|
51
|
+
|
|
52
|
+
Represents the global presence state and matchmaking.
|
|
53
|
+
|
|
54
|
+
```typescript
|
|
55
|
+
interface Lobby {
|
|
56
|
+
/**
|
|
57
|
+
* Stream of the current online users.
|
|
58
|
+
* Emits the full list whenever users join, leave, or time out.
|
|
59
|
+
* The list must be sorted alphabetically by `userName` (case-insensitive).
|
|
60
|
+
*/
|
|
61
|
+
onUsersChange(callback: (users: PresenceMessage[]) => void): void;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Update the current user's presence information (e.g., change name).
|
|
65
|
+
*/
|
|
66
|
+
updatePresence(update: Partial<PresenceMessage>): Promise<void>;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Challenge another user to a game.
|
|
70
|
+
* Returns the ID of the table created for the challenge.
|
|
71
|
+
*/
|
|
72
|
+
challenge(userId: string, ruleType: string): Promise<string>;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Accept an incoming challenge.
|
|
76
|
+
* Returns the Table instance for the accepted game.
|
|
77
|
+
*/
|
|
78
|
+
acceptChallenge(userId: string, ruleType: string, tableId: string): Promise<Table>;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Decline an incoming challenge.
|
|
82
|
+
*/
|
|
83
|
+
declineChallenge(userId: string, ruleType: string): Promise<void>;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Cancel an outgoing challenge.
|
|
87
|
+
*/
|
|
88
|
+
cancelChallenge(userId: string, ruleType: string): Promise<void>;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Subscribe to incoming challenges directed at the current user.
|
|
92
|
+
*/
|
|
93
|
+
onChallenge(callback: (challenge: ChallengeMessage) => void): void;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Leave the lobby.
|
|
97
|
+
*/
|
|
98
|
+
leave(): Promise<void>;
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### `Table`
|
|
103
|
+
|
|
104
|
+
Represents a specific communication channel for a 2-player/spectator scenario at a table.
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
interface Table<T = any> {
|
|
108
|
+
/**
|
|
109
|
+
* Broadcast an event to all participants at the table.
|
|
110
|
+
*/
|
|
111
|
+
publish(type: string, data: T): Promise<void>;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Subscribe to events published by other participants.
|
|
115
|
+
*/
|
|
116
|
+
onMessage(callback: (event: TableMessage<T>) => void): void;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Subscribe to changes in the spectator list.
|
|
120
|
+
*/
|
|
121
|
+
onSpectatorChange(callback: (spectators: PresenceMessage[]) => void): void;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Leave the table.
|
|
125
|
+
*/
|
|
126
|
+
leave(): Promise<void>;
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Data Models
|
|
133
|
+
|
|
134
|
+
### `_meta` (Server-Enriched Metadata)
|
|
135
|
+
|
|
136
|
+
All messages published through the transport layer are automatically enriched by the server with metadata from HTTP headers and connection info. This `_meta` object is **added by the server** and should be used by clients as the absolute source of truth for timing (`ts`) and origin.
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
interface Meta {
|
|
140
|
+
ts: string; // ISO timestamp of the request (Source of Truth for time)
|
|
141
|
+
locale: string; // Accept-Language header (use for flag rendering)
|
|
142
|
+
ua: string; // User-Agent header
|
|
143
|
+
ip: string; // Client remote address
|
|
144
|
+
origin: string; // Origin header value
|
|
145
|
+
host: string; // Host header value
|
|
146
|
+
path: string; // Request URI path
|
|
147
|
+
method: string; // HTTP method (always POST for publish)
|
|
148
|
+
country: string; // Country code from IP (e.g., "US", "GB", "XX")
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
**Note**: The client should NOT include `locale` or `ua` in published messages — the server adds these automatically from HTTP headers. This ensures reliable, tamper-resistant metadata for UI features like flag rendering.
|
|
153
|
+
|
|
154
|
+
### `PresenceMessage`
|
|
155
|
+
|
|
156
|
+
Information about a user in the lobby. The `locale` and `ua` fields are **not** set by the client — they are provided by the server via `_meta`.
|
|
157
|
+
|
|
158
|
+
```typescript
|
|
159
|
+
interface PresenceMessage {
|
|
160
|
+
messageType: "presence";
|
|
161
|
+
type: "join" | "heartbeat" | "leave";
|
|
162
|
+
userId: string;
|
|
163
|
+
userName: string;
|
|
164
|
+
ruleType?: string;
|
|
165
|
+
opponentId?: string | null;
|
|
166
|
+
seek?: Seek;
|
|
167
|
+
lastSeen?: number; // Managed internally for pruning (derived from _meta.ts)
|
|
168
|
+
_meta?: Meta; // Server-enriched metadata (received messages only)
|
|
169
|
+
|
|
170
|
+
// Current game state:
|
|
171
|
+
// - If present: user is playing or spectating at that table (available for spectating)
|
|
172
|
+
// - If absent: user is available for new games
|
|
173
|
+
tableId?: string;
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### `ChallengeMessage`
|
|
178
|
+
|
|
179
|
+
Represents a peer-to-peer challenge request.
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
interface ChallengeMessage {
|
|
183
|
+
messageType: "challenge";
|
|
184
|
+
type: "offer" | "accept" | "decline" | "cancel";
|
|
185
|
+
challengerId: string;
|
|
186
|
+
challengerName: string;
|
|
187
|
+
recipientId: string;
|
|
188
|
+
ruleType: string;
|
|
189
|
+
tableId?: string; // Optional: table created by challenger
|
|
190
|
+
_meta?: Meta; // Server-enriched metadata (received messages only)
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### `TableInfo` (UNDER REVIEW)
|
|
195
|
+
|
|
196
|
+
Lobby-level information about an active game table.
|
|
197
|
+
|
|
198
|
+
```typescript
|
|
199
|
+
interface TableInfo {
|
|
200
|
+
tableId: string;
|
|
201
|
+
ruleType: string;
|
|
202
|
+
players: { id: string; name: string }[];
|
|
203
|
+
spectatorCount: number;
|
|
204
|
+
status: "waiting" | "playing" | "finished";
|
|
205
|
+
createdAt: number;
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### `TableMessage`
|
|
210
|
+
|
|
211
|
+
A generic structure for table/game events. Replaces raw payloads with a structured, meta-aware event.
|
|
212
|
+
|
|
213
|
+
```typescript
|
|
214
|
+
interface TableMessage<T = any> {
|
|
215
|
+
type: string;
|
|
216
|
+
senderId: string;
|
|
217
|
+
data: T; // Application-specific payload
|
|
218
|
+
_meta?: Meta; // Server-enriched metadata (received messages only)
|
|
219
|
+
}
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## Derived State & Logic
|
|
225
|
+
|
|
226
|
+
Clients can derive additional state from the presence data provided by the `Lobby`.
|
|
227
|
+
|
|
228
|
+
### 1. Active Games List
|
|
229
|
+
|
|
230
|
+
#### `activeGames(users: PresenceMessage[]): ActiveGame[]`
|
|
231
|
+
|
|
232
|
+
Filters users with a `tableId` and returns one entry per unique table.
|
|
233
|
+
|
|
234
|
+
```typescript
|
|
235
|
+
interface ActiveGame {
|
|
236
|
+
tableId: string;
|
|
237
|
+
players: { id: string; name: string }[];
|
|
238
|
+
ruleType?: string;
|
|
239
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
**Note**: Spectator vs player distinction is not available in presence data - all users with `tableId` are included as "players".
|
|
243
|
+
|
|
244
|
+
### 2. Predicates
|
|
245
|
+
|
|
246
|
+
Exported helper functions for consumer use.
|
|
247
|
+
|
|
248
|
+
#### `canChallenge(target: PresenceMessage, currentUserId: string): boolean`
|
|
249
|
+
Returns true if:
|
|
250
|
+
- `target.userId !== currentUserId` (not self)
|
|
251
|
+
- `!target.tableId` (not already at a table)
|
|
252
|
+
- `!target.seek` (not seeking a game)
|
|
253
|
+
|
|
254
|
+
#### `canSpectate(target: PresenceMessage, currentTableId?: string): boolean`
|
|
255
|
+
Returns true if:
|
|
256
|
+
- `target.tableId` exists (is at a table)
|
|
257
|
+
- `target.tableId !== currentTableId` (not already at this table)
|
|
258
|
+
|
|
259
|
+
---
|
|
260
|
+
|
|
261
|
+
## Internal Requirements
|
|
262
|
+
|
|
263
|
+
### 1. Presence Management
|
|
264
|
+
|
|
265
|
+
- **Heartbeat**: The library must automatically send periodic "heartbeat" messages (e.g., every 60 seconds) to the lobby while active.
|
|
266
|
+
- **Pruning**: The library must maintain an internal map of users and automatically remove users who haven't sent a heartbeat within a specific TTL (e.g., 90 seconds).
|
|
267
|
+
- **Unload Handling**: In browser environments, the library should attempt to send a "leave" message on `beforeunload` or `pagehide`.
|
|
268
|
+
|
|
269
|
+
### 2. Transport & Reconnection
|
|
270
|
+
|
|
271
|
+
- **Transport Layer**: Uses `NchanClient` from `src/lobby/nchanclient.ts` as the underlying transport. The WebSocket/POST abstraction is handled internally. `NchanClient` remains transport-agnostic and platform-neutral (Browser + Node.js).
|
|
272
|
+
- **Resilience**:
|
|
273
|
+
- Automatic exponential backoff for reconnection on WebSocket failure.
|
|
274
|
+
- Transparently handle transition between online/offline states.
|
|
275
|
+
- **Concurrency**: Ensure multiple `Table` instances can coexist if needed (though typically one game at a time).
|
|
276
|
+
|
|
277
|
+
### 3. Message Retention
|
|
278
|
+
|
|
279
|
+
The Nchan server retains presence messages for a configurable duration (default: 90 seconds, 2000 messages). This ensures:
|
|
280
|
+
- Late subscribers receive buffered presence messages from existing users
|
|
281
|
+
- Clients reconnecting can see active users without waiting for the next heartbeat
|
|
282
|
+
- The system is resilient to brief network interruptions
|
|
283
|
+
|
|
284
|
+
**Note**: This is handled by the Nchan server configuration (`nchan_message_timeout` and `nchan_message_buffer_length`).
|
|
285
|
+
|
|
286
|
+
### 3. Page Visibility & Browser Lifecycle
|
|
287
|
+
|
|
288
|
+
Page visibility handling (`pagehide`, `pageshow`, `visibilitychange`) is the responsibility of `MessagingClient` (application layer), **not** `NchanClient` (transport layer). This keeps the transport layer platform-agnostic.
|
|
289
|
+
|
|
290
|
+
`MessagingClient` should:
|
|
291
|
+
|
|
292
|
+
- Listen for `pagehide` to close connections and send a "leave" presence message
|
|
293
|
+
- Listen for `pageshow` (with `event.persisted`) to restore connections from bfcache
|
|
294
|
+
- Track `document.hidden` state to pause/resume heartbeats
|
|
295
|
+
|
|
296
|
+
```typescript
|
|
297
|
+
// Example pattern for MessagingClient
|
|
298
|
+
// Note: MessagingClient does NOT have direct socket access.
|
|
299
|
+
// It calls stop() on subscriptions returned by NchanClient, or exposes its own stop() method.
|
|
300
|
+
|
|
301
|
+
private handlePageHide = (): void => {
|
|
302
|
+
this.stop(); // Stops all subscriptions and sends "leave" presence
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
private handlePageShow = (event: PageTransitionEvent): void => {
|
|
306
|
+
if (event.persisted) {
|
|
307
|
+
this.start().then(() => this.joinLobby(this.currentUser));
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
### 3. State Synchronization
|
|
313
|
+
|
|
314
|
+
- The library should ensure that when a user joins a lobby, they receive the current "state of the world" or quickly populate it via incoming heartbeats.
|
|
315
|
+
- For tables, it should provide a reliable pipe for sequence-sensitive events (optionally implementing sequence numbering if required by the transport).
|
|
316
|
+
|
|
317
|
+
---
|
|
318
|
+
|
|
319
|
+
## Accepted Concerns
|
|
320
|
+
|
|
321
|
+
### 1. Race Conditions in Matchmaking
|
|
322
|
+
|
|
323
|
+
### 2. Presence Scaling ($O(N^2)$)
|
|
324
|
+
|
|
325
|
+
### 3. State Reconstruction
|
|
326
|
+
|
|
327
|
+
Since the transport retains messages server-side, a new client joining the lobby will receive buffered presence messages from existing users.
|
|
328
|
+
|
|
329
|
+
---
|
|
330
|
+
|
|
331
|
+
## Usage Example (Conceptual)
|
|
332
|
+
|
|
333
|
+
```typescript
|
|
334
|
+
const client = new MessagingClient({
|
|
335
|
+
baseUrl: "billiards-network.onrender.com",
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
await client.start();
|
|
339
|
+
|
|
340
|
+
// Lobby interaction
|
|
341
|
+
const lobby = await client.joinLobby({
|
|
342
|
+
messageType: "presence",
|
|
343
|
+
type: "join",
|
|
344
|
+
userId: "user-123",
|
|
345
|
+
userName: "Alice",
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
lobby.onUsersChange((users) => {
|
|
349
|
+
console.log("Online users:", users.length);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
lobby.onChallenge((challenge) => {
|
|
353
|
+
if (confirm(`Accept challenge from ${challenge.challengerName}?`)) {
|
|
354
|
+
lobby.acceptChallenge(challenge.challengerId, challenge.ruleType, challenge.tableId);
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// Table interaction with a generic move type
|
|
359
|
+
interface Move { x: number; y: number }
|
|
360
|
+
const table = await client.joinTable<Move>("table-xyz", "user-123");
|
|
361
|
+
|
|
362
|
+
table.onMessage((msg) => {
|
|
363
|
+
if (msg.type === "MOVE") {
|
|
364
|
+
// msg.data is typed as Move
|
|
365
|
+
applyMove(msg.data);
|
|
366
|
+
console.log("Move received at:", msg._meta?.ts);
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
table.publish("MOVE", { x: 10, y: 20 });
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
---
|
|
374
|
+
|
|
375
|
+
## Future Considerations
|
|
376
|
+
|
|
377
|
+
### 1. `beforeunload` Handler
|
|
378
|
+
|
|
379
|
+
The current implementation uses `pagehide` to handle browser page dismissal. An alternative approach is to use `beforeunload` to send a synchronous (or near-synchronous) "leave" presence message before the page unloads. This provides a fallback for browsers where `pagehide` may not fire reliably.
|
|
380
|
+
|
|
381
|
+
Trade-offs:
|
|
382
|
+
- `beforeunload` is synchronous and may delay page dismissal
|
|
383
|
+
- Not supported in all browsers (e.g., Safari iOS)
|
|
384
|
+
- `pagehide` is the modern recommended approach, but may not send network requests in some cases
|
|
385
|
+
|
|
386
|
+
### 2. `joinTable` Behavior
|
|
387
|
+
|
|
388
|
+
The current `joinTable` method automatically updates the user's presence to include `tableId` (marking them as "at a table"). Future iterations could:
|
|
389
|
+
- Make this behavior optional via a configuration flag
|
|
390
|
+
- Allow spectating without affecting the user's availability status
|
|
391
|
+
- Provide separate methods: `joinTableAsPlayer()` vs. `joinTableAsSpectator()`
|
package/README.md
CHANGED
|
@@ -77,5 +77,19 @@ npm run docker:build
|
|
|
77
77
|
|
|
78
78
|
- **Linting**: `npm run lint`
|
|
79
79
|
- **Formatting**: `npm run format`
|
|
80
|
+
|
|
81
|
+
## Publishing
|
|
82
|
+
|
|
83
|
+
To release a new version to npm:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
npm run release # Builds and bumps version
|
|
87
|
+
npm login # Login to npm (if needed)
|
|
88
|
+
npm publish # Publish to npm registry
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Additional Documentation
|
|
92
|
+
|
|
80
93
|
- **Specification**: See [MESSAGING_SPEC.md](./MESSAGING_SPEC.md) for the API contract and data models.
|
|
94
|
+
- **Usage Guide**: See [SKILL.md](./SKILL.md) for a quick reference guide.
|
|
81
95
|
- **Architectural Overview**: See [AGENTS.md](./AGENTS.md) for the design patterns used.
|
package/SKILL.md
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# @tailuge/messaging Agent Skill
|
|
2
|
+
|
|
3
|
+
Use this skill when integrating the `@tailuge/messaging` library into a new project or codebase.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @tailuge/messaging
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Initialization
|
|
12
|
+
|
|
13
|
+
Create a `MessagingClient` instance with your Nchan server base URL:
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
import { MessagingClient } from '@tailuge/messaging';
|
|
17
|
+
|
|
18
|
+
const client = new MessagingClient({
|
|
19
|
+
baseUrl: "https://your-nchan-server.com",
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
await client.start();
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Getting Online User Count
|
|
26
|
+
|
|
27
|
+
Join the lobby and listen for user changes:
|
|
28
|
+
|
|
29
|
+
```typescript
|
|
30
|
+
const lobby = await client.joinLobby({
|
|
31
|
+
messageType: "presence",
|
|
32
|
+
type: "join",
|
|
33
|
+
userId: "user-123",
|
|
34
|
+
userName: "Alice",
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
lobby.onUsersChange((users) => {
|
|
38
|
+
console.log("Online users:", users.length);
|
|
39
|
+
});
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Getting User List
|
|
43
|
+
|
|
44
|
+
The same `onUsersChange` callback provides the full user list:
|
|
45
|
+
|
|
46
|
+
```typescript
|
|
47
|
+
lobby.onUsersChange((users) => {
|
|
48
|
+
// Users are sorted alphabetically by userName
|
|
49
|
+
users.forEach(user => {
|
|
50
|
+
console.log(`${user.userName} (${user.userId})`);
|
|
51
|
+
if (user.tableId) {
|
|
52
|
+
console.log(` Playing at table: ${user.tableId}`);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Sending a Challenge
|
|
59
|
+
|
|
60
|
+
To challenge another user to a game:
|
|
61
|
+
|
|
62
|
+
```typescript
|
|
63
|
+
// challenge(userId, ruleType) returns the table ID
|
|
64
|
+
const tableId = await lobby.challenge(targetUserId, "billiards");
|
|
65
|
+
console.log(`Challenge sent, table created: ${tableId}`);
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Receiving Challenges
|
|
69
|
+
|
|
70
|
+
Listen for incoming challenges:
|
|
71
|
+
|
|
72
|
+
```typescript
|
|
73
|
+
lobby.onChallenge((challenge) => {
|
|
74
|
+
console.log(`${challenge.challengerName} challenged you to ${challenge.ruleType}`);
|
|
75
|
+
|
|
76
|
+
if (challenge.type === "offer") {
|
|
77
|
+
// Accept the challenge
|
|
78
|
+
lobby.acceptChallenge(challenge.challengerId, challenge.ruleType, challenge.tableId);
|
|
79
|
+
|
|
80
|
+
// Or decline
|
|
81
|
+
// lobby.declineChallenge(challenge.challengerId, challenge.ruleType);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Joining a Table
|
|
87
|
+
|
|
88
|
+
Connect to a specific game table:
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
interface Move { x: number; y: number }
|
|
92
|
+
const table = await client.joinTable<Move>("table-xyz", "user-123");
|
|
93
|
+
|
|
94
|
+
table.onMessage((msg) => {
|
|
95
|
+
if (msg.type === "MOVE") {
|
|
96
|
+
const move = msg.data as Move;
|
|
97
|
+
console.log(`Move received at: ${msg._meta?.ts}`);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Send a move
|
|
102
|
+
await table.publish("MOVE", { x: 10, y: 20 });
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Spectator Updates
|
|
106
|
+
|
|
107
|
+
Listen for spectator changes at a table:
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
table.onSpectatorChange((spectators) => {
|
|
111
|
+
console.log(`Spectators: ${spectators.length}`);
|
|
112
|
+
});
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Cleanup
|
|
116
|
+
|
|
117
|
+
When done, stop the client:
|
|
118
|
+
|
|
119
|
+
```typescript
|
|
120
|
+
await client.stop();
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Key Interfaces
|
|
124
|
+
|
|
125
|
+
### PresenceMessage
|
|
126
|
+
```typescript
|
|
127
|
+
interface PresenceMessage {
|
|
128
|
+
messageType: "presence";
|
|
129
|
+
type: "join" | "heartbeat" | "leave";
|
|
130
|
+
userId: string;
|
|
131
|
+
userName: string;
|
|
132
|
+
ruleType?: string;
|
|
133
|
+
tableId?: string;
|
|
134
|
+
// ... other fields
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Meta
|
|
139
|
+
```typescript
|
|
140
|
+
interface Meta {
|
|
141
|
+
ts: string; // ISO timestamp from server
|
|
142
|
+
locale: string; // Accept-Language header
|
|
143
|
+
ua: string; // User-Agent header
|
|
144
|
+
ip: string; // Client remote address
|
|
145
|
+
origin: string; // Origin header value
|
|
146
|
+
host: string; // Host header value
|
|
147
|
+
path: string; // Request URI path
|
|
148
|
+
method: string; // HTTP method
|
|
149
|
+
country: string; // Country code from IP (e.g., "US", "GB", "XX")
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### ChallengeMessage
|
|
154
|
+
```typescript
|
|
155
|
+
interface ChallengeMessage {
|
|
156
|
+
messageType: "challenge";
|
|
157
|
+
type: "offer" | "accept" | "decline" | "cancel";
|
|
158
|
+
challengerId: string;
|
|
159
|
+
challengerName: string;
|
|
160
|
+
recipientId: string;
|
|
161
|
+
ruleType: string;
|
|
162
|
+
tableId?: string;
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### TableMessage<T>
|
|
167
|
+
```typescript
|
|
168
|
+
interface TableMessage<T = any> {
|
|
169
|
+
type: string;
|
|
170
|
+
senderId: string;
|
|
171
|
+
data: T;
|
|
172
|
+
_meta?: Meta;
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Helper Predicates
|
|
177
|
+
|
|
178
|
+
The library exports helper functions:
|
|
179
|
+
|
|
180
|
+
```typescript
|
|
181
|
+
import { canChallenge, canSpectate } from '@tailuge/messaging';
|
|
182
|
+
|
|
183
|
+
// Check if you can challenge a user
|
|
184
|
+
if (canChallenge(targetUser, currentUserId)) {
|
|
185
|
+
lobby.challenge(targetUser.userId, "billiards");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Check if you can spectate a user
|
|
189
|
+
if (canSpectate(targetUser, currentTableId)) {
|
|
190
|
+
const table = await client.joinTable(targetUser.tableId, currentUserId);
|
|
191
|
+
}
|
|
192
|
+
```
|
package/dist/types.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Server-enriched metadata added to all messages by Nchan.
|
|
3
3
|
* This is the absolute source of truth for timing and origin.
|
|
4
4
|
*/
|
|
5
|
-
export interface
|
|
5
|
+
export interface Meta {
|
|
6
6
|
ts: string;
|
|
7
7
|
locale: string;
|
|
8
8
|
ua: string;
|
|
@@ -32,7 +32,7 @@ export interface PresenceMessage {
|
|
|
32
32
|
opponentId?: string | null;
|
|
33
33
|
seek?: Seek;
|
|
34
34
|
lastSeen?: number;
|
|
35
|
-
_meta?:
|
|
35
|
+
_meta?: Meta;
|
|
36
36
|
tableId?: string;
|
|
37
37
|
}
|
|
38
38
|
/**
|
|
@@ -46,7 +46,7 @@ export interface ChallengeMessage {
|
|
|
46
46
|
recipientId: string;
|
|
47
47
|
ruleType: string;
|
|
48
48
|
tableId?: string;
|
|
49
|
-
_meta?:
|
|
49
|
+
_meta?: Meta;
|
|
50
50
|
}
|
|
51
51
|
/**
|
|
52
52
|
* Generic structure for table/game events
|
|
@@ -55,7 +55,7 @@ export interface TableMessage<T = unknown> {
|
|
|
55
55
|
type: string;
|
|
56
56
|
senderId: string;
|
|
57
57
|
data: T;
|
|
58
|
-
_meta?:
|
|
58
|
+
_meta?: Meta;
|
|
59
59
|
}
|
|
60
60
|
/**
|
|
61
61
|
* Lobby-level information about an active game table
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tailuge/messaging",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A stateful messaging library for Nchan-powered real-time applications.",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
}
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
|
-
"dist"
|
|
15
|
+
"dist",
|
|
16
|
+
"MESSAGING_SPEC.md",
|
|
17
|
+
"SKILL.md"
|
|
16
18
|
],
|
|
17
19
|
"scripts": {
|
|
18
20
|
"test": "jest --config test/jest.config.cjs --verbose",
|