@tailuge/messaging 1.1.0 → 1.3.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.
@@ -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,113 @@
1
+ ---
2
+ name: tailuge-messaging
3
+ description: |
4
+ Integration guide for @tailuge/messaging library. Use when building applications with:
5
+ - Real-time presence/lobby systems
6
+ - User matchmaking via challenges
7
+ - Game table communication with players and spectators
8
+ - Nchan-powered transport layer
9
+ ---
10
+
11
+ # @tailuge/messaging
12
+
13
+ Quick integration guide. See [MESSAGING_SPEC.md](./MESSAGING_SPEC.md) for full API contract.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ npm install @tailuge/messaging
19
+ ```
20
+
21
+ ## Quick Start
22
+
23
+ ```typescript
24
+ import { MessagingClient } from '@tailuge/messaging';
25
+
26
+ const client = new MessagingClient({ baseUrl: "https://your-nchan-server.com" });
27
+ client.start();
28
+ ```
29
+
30
+ ## Lobby & Presence
31
+
32
+ ```typescript
33
+ const lobby = await client.joinLobby({
34
+ messageType: "presence",
35
+ type: "join",
36
+ userId: "user-123",
37
+ userName: "Alice",
38
+ });
39
+
40
+ lobby.onUsersChange((users) => {
41
+ console.log(`Online: ${users.length}`);
42
+ users.forEach(u => {
43
+ const flag = countryToFlag(u._meta?.country);
44
+ console.log(`${flag} ${u.userName}`);
45
+ });
46
+ });
47
+ ```
48
+
49
+ ## Challenge Opponent
50
+
51
+ ```typescript
52
+ const tableId = await lobby.challenge(targetUserId, "billiards");
53
+
54
+ lobby.onChallenge((challenge) => {
55
+ if (challenge.type === "offer") {
56
+ lobby.acceptChallenge(challenge.challengerId, challenge.ruleType, challenge.tableId);
57
+ }
58
+ });
59
+ ```
60
+
61
+ ## Table Messaging
62
+
63
+ ```typescript
64
+ interface Move { x: number; y: number }
65
+ const table = await client.joinTable<Move>("table-xyz", "user-123");
66
+
67
+ table.onMessage((msg) => {
68
+ if (msg.type === "MOVE") {
69
+ console.log(`Move at: ${msg._meta?.ts}`);
70
+ }
71
+ });
72
+
73
+ await table.publish("MOVE", { x: 10, y: 20 });
74
+ ```
75
+
76
+ ## Spectators
77
+
78
+ ```typescript
79
+ table.onSpectatorChange((spectators) => {
80
+ console.log(`Spectators: ${spectators.length}`);
81
+ });
82
+ ```
83
+
84
+ ## Cleanup
85
+
86
+ ```typescript
87
+ await client.stop();
88
+ ```
89
+
90
+ ## Key Imports
91
+
92
+ ```typescript
93
+ import {
94
+ MessagingClient,
95
+ canChallenge,
96
+ canSpectate,
97
+ activeGames,
98
+ } from '@tailuge/messaging';
99
+ ```
100
+
101
+ ## Predicates
102
+
103
+ ```typescript
104
+ if (canChallenge(targetUser, currentUserId)) {
105
+ await lobby.challenge(targetUser.userId, "billiards");
106
+ }
107
+
108
+ if (canSpectate(targetUser, currentTableId)) {
109
+ await client.joinTable(targetUser.tableId, currentUserId);
110
+ }
111
+ ```
112
+
113
+ See [MESSAGING_SPEC.md](./MESSAGING_SPEC.md) for complete interface definitions.
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 _Meta {
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?: _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?: _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?: _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.1.0",
3
+ "version": "1.3.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",