@playcademy/sdk 0.0.1-beta.29 → 0.0.1-beta.30

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.
@@ -433,6 +433,7 @@ export declare class PlaycademyClient {
433
433
  token: {
434
434
  get: () => Promise<import("./namespaces/realtime").RealtimeTokenResponse>;
435
435
  };
436
+ open(channel?: string): Promise<import("@playcademy/realtime/server/types").RealtimeChannel>;
436
437
  };
437
438
  /** Auto-initializes a PlaycademyClient with context from the environment */
438
439
  static init: typeof init;
@@ -0,0 +1,129 @@
1
+ import type { RealtimeChannel } from '@playcademy/realtime/server/types';
2
+ /**
3
+ * Client-side implementation of a realtime communication channel.
4
+ * Manages a WebSocket connection to a specific game-scoped realtime channel.
5
+ *
6
+ * **Key Features**:
7
+ * - Automatic connection management with reconnection on token refresh
8
+ * - Type-safe message handling with JSON serialization/deserialization
9
+ * - Proper cleanup and resource management
10
+ * - Integration with the Playcademy messaging system for token updates
11
+ *
12
+ * **Connection Lifecycle**:
13
+ * 1. `connect()` - Establishes WebSocket connection with authentication
14
+ * 2. Message exchange via `send()` and `onMessage()` callbacks
15
+ * 3. Automatic reconnection on token refresh events
16
+ * 4. `close()` - Clean shutdown with proper resource cleanup
17
+ *
18
+ * @example
19
+ * ```typescript
20
+ * const client = new RealtimeChannelClient(
21
+ * 'game-123',
22
+ * 'chat',
23
+ * () => getToken(),
24
+ * 'ws://localhost:3000'
25
+ * )
26
+ *
27
+ * await client.connect()
28
+ *
29
+ * client.onMessage((data) => {
30
+ * console.log('Received:', data)
31
+ * })
32
+ *
33
+ * client.send({ type: 'message', text: 'Hello!' })
34
+ *
35
+ * // Clean up when done
36
+ * client.close()
37
+ * ```
38
+ */
39
+ export declare class RealtimeChannelClient implements RealtimeChannel {
40
+ private gameId;
41
+ private _channelName;
42
+ private getToken;
43
+ private baseUrl;
44
+ private ws?;
45
+ private listeners;
46
+ private isClosing;
47
+ private tokenRefreshUnsubscribe?;
48
+ constructor(gameId: string, _channelName: string, getToken: () => Promise<string>, baseUrl: string);
49
+ /**
50
+ * Establishes the WebSocket connection to the realtime server.
51
+ *
52
+ * **Connection Process**:
53
+ * 1. Obtains fresh authentication token
54
+ * 2. Constructs WebSocket URL with token and channel parameters
55
+ * 3. Creates WebSocket connection
56
+ * 4. Sets up event handlers for messages, errors, and disconnections
57
+ * 5. Registers for token refresh events
58
+ *
59
+ * **URL Format**: `ws://host/path?token=<jwt>&c=<channel>`
60
+ *
61
+ * @throws Error if token retrieval fails or WebSocket connection fails
62
+ */
63
+ connect(): Promise<RealtimeChannel>;
64
+ /**
65
+ * Sets up WebSocket event handlers for message processing and connection management.
66
+ *
67
+ * **Event Handling**:
68
+ * - `message`: Parses JSON and notifies all registered listeners
69
+ * - `close`: Handles both expected and unexpected disconnections
70
+ * - `error`: Logs WebSocket errors for debugging
71
+ */
72
+ private setupEventHandlers;
73
+ /**
74
+ * Sets up listener for token refresh events from the messaging system.
75
+ * When a token refresh occurs, the WebSocket connection is closed and reopened
76
+ * with the new token to maintain authentication.
77
+ */
78
+ private setupTokenRefreshListener;
79
+ /**
80
+ * Reconnects the WebSocket with a new authentication token.
81
+ *
82
+ * **Reconnection Process**:
83
+ * 1. Close existing connection with TOKEN_REFRESH code
84
+ * 2. Wait for close event to complete
85
+ * 3. Re-establish connection with new token
86
+ *
87
+ * @param token - The new authentication token (currently unused, relies on getToken())
88
+ */
89
+ private reconnectWithNewToken;
90
+ /**
91
+ * Sends a message to all members of this channel.
92
+ *
93
+ * **Message Format**: JSON-serialized payload
94
+ * **Delivery**: Best-effort, no acknowledgment or retry logic
95
+ *
96
+ * @param data - Any JSON-serializable data to send
97
+ */
98
+ send(data: unknown): void;
99
+ /**
100
+ * Registers a callback to be invoked when messages are received on this channel.
101
+ *
102
+ * **Message Processing**:
103
+ * - Messages are automatically JSON-parsed before delivery
104
+ * - Listener errors are caught and logged (won't affect other listeners)
105
+ * - Multiple listeners can be registered for the same channel
106
+ *
107
+ * @param callback - Function to call when messages are received
108
+ * @returns Unsubscribe function to remove this listener
109
+ */
110
+ onMessage(callback: (data: unknown) => void): () => void;
111
+ /**
112
+ * Closes the WebSocket connection and cleans up all resources.
113
+ *
114
+ * **Cleanup Process**:
115
+ * 1. Mark as intentionally closing (prevents reconnection attempts)
116
+ * 2. Remove token refresh listener
117
+ * 3. Close WebSocket with normal closure code
118
+ * 4. Clear all message listeners
119
+ */
120
+ close(): void;
121
+ /**
122
+ * Gets the name of this channel.
123
+ */
124
+ get channelName(): string;
125
+ /**
126
+ * Checks if the underlying WebSocket connection is currently open.
127
+ */
128
+ get isConnected(): boolean;
129
+ }
@@ -1,3 +1,4 @@
1
+ import type { RealtimeChannel } from '@playcademy/realtime/server/types';
1
2
  import type { PlaycademyClient } from '../../types';
2
3
  /**
3
4
  * Response type for the realtime token API
@@ -10,7 +11,7 @@ export interface RealtimeTokenResponse {
10
11
  * Provides methods for realtime connectivity and features.
11
12
  *
12
13
  * @param client - The PlaycademyClient instance
13
- * @returns Realtime namespace with token-related methods
14
+ * @returns Realtime namespace with token-related methods and channel API
14
15
  */
15
16
  export declare function createRealtimeNamespace(client: PlaycademyClient): {
16
17
  /**
@@ -37,4 +38,47 @@ export declare function createRealtimeNamespace(client: PlaycademyClient): {
37
38
  */
38
39
  get: () => Promise<RealtimeTokenResponse>;
39
40
  };
41
+ /**
42
+ * Opens a realtime channel for game-scoped communication.
43
+ *
44
+ * **Channel Naming**:
45
+ * - Channels are automatically scoped to the current game
46
+ * - Channel names should be descriptive (e.g., "chat", "lobby", "map_forest")
47
+ * - The "default" channel is used if no name is specified
48
+ *
49
+ * **Connection Management**:
50
+ * - Each channel opens its own WebSocket connection
51
+ * - Connections automatically handle token refresh
52
+ * - Remember to call `channel.close()` when done to prevent resource leaks
53
+ *
54
+ * **Error Handling**:
55
+ * - Throws if no gameId is configured on the client
56
+ * - Throws if token retrieval fails
57
+ * - Throws if WebSocket connection fails
58
+ *
59
+ * @param channel - Channel name (defaults to "default")
60
+ * @returns Promise resolving to a RealtimeChannel instance
61
+ *
62
+ * @example
63
+ * ```typescript
64
+ * // Open the default channel
65
+ * const channel = await client.realtime.open()
66
+ *
67
+ * // Listen for messages
68
+ * const unsubscribe = channel.onMessage((data) => {
69
+ * console.log('Received:', data)
70
+ * })
71
+ *
72
+ * // Send a message
73
+ * channel.send({ type: 'chat', message: 'Hello world!' })
74
+ *
75
+ * // Clean up when done
76
+ * unsubscribe()
77
+ * channel.close()
78
+ *
79
+ * // Open a named channel
80
+ * const chatChannel = await client.realtime.open('chat')
81
+ * ```
82
+ */
83
+ open(channel?: string): Promise<RealtimeChannel>;
40
84
  };
package/dist/index.js CHANGED
@@ -1479,6 +1479,198 @@ function createSpritesNamespace(client) {
1479
1479
  };
1480
1480
  }
1481
1481
 
1482
+ // src/core/namespaces/realtime.client.ts
1483
+ class RealtimeChannelClient {
1484
+ gameId;
1485
+ _channelName;
1486
+ getToken;
1487
+ baseUrl;
1488
+ ws;
1489
+ listeners = new Set;
1490
+ isClosing = false;
1491
+ tokenRefreshUnsubscribe;
1492
+ constructor(gameId, _channelName, getToken, baseUrl) {
1493
+ this.gameId = gameId;
1494
+ this._channelName = _channelName;
1495
+ this.getToken = getToken;
1496
+ this.baseUrl = baseUrl;
1497
+ }
1498
+ async connect() {
1499
+ try {
1500
+ const token = await this.getToken();
1501
+ const wsUrl = this.baseUrl.replace(/^http/, "ws");
1502
+ const url = `${wsUrl}?token=${encodeURIComponent(token)}&c=${encodeURIComponent(this._channelName)}`;
1503
+ this.ws = new WebSocket(url);
1504
+ this.setupEventHandlers();
1505
+ this.setupTokenRefreshListener();
1506
+ await new Promise((resolve, reject) => {
1507
+ if (!this.ws) {
1508
+ reject(new Error("WebSocket creation failed"));
1509
+ return;
1510
+ }
1511
+ const onOpen = () => {
1512
+ this.ws?.removeEventListener("open", onOpen);
1513
+ this.ws?.removeEventListener("error", onError);
1514
+ resolve();
1515
+ };
1516
+ const onError = (event) => {
1517
+ this.ws?.removeEventListener("open", onOpen);
1518
+ this.ws?.removeEventListener("error", onError);
1519
+ reject(new Error(`WebSocket connection failed: ${event}`));
1520
+ };
1521
+ this.ws.addEventListener("open", onOpen);
1522
+ this.ws.addEventListener("error", onError);
1523
+ });
1524
+ log.debug("[RealtimeChannelClient] Connected to channel", {
1525
+ gameId: this.gameId,
1526
+ channel: this._channelName
1527
+ });
1528
+ return this;
1529
+ } catch (error) {
1530
+ log.error("[RealtimeChannelClient] Connection failed", {
1531
+ gameId: this.gameId,
1532
+ channel: this._channelName,
1533
+ error
1534
+ });
1535
+ throw error;
1536
+ }
1537
+ }
1538
+ setupEventHandlers() {
1539
+ if (!this.ws)
1540
+ return;
1541
+ this.ws.onmessage = (event) => {
1542
+ try {
1543
+ const data = JSON.parse(event.data);
1544
+ this.listeners.forEach((callback) => {
1545
+ try {
1546
+ callback(data);
1547
+ } catch (error) {
1548
+ log.warn("[RealtimeChannelClient] Message listener error", {
1549
+ channel: this._channelName,
1550
+ error
1551
+ });
1552
+ }
1553
+ });
1554
+ } catch (error) {
1555
+ log.warn("[RealtimeChannelClient] Failed to parse message", {
1556
+ channel: this._channelName,
1557
+ message: event.data,
1558
+ error
1559
+ });
1560
+ }
1561
+ };
1562
+ this.ws.onclose = (event) => {
1563
+ log.debug("[RealtimeChannelClient] Connection closed", {
1564
+ channel: this._channelName,
1565
+ code: event.code,
1566
+ reason: event.reason,
1567
+ wasClean: event.wasClean
1568
+ });
1569
+ if (!this.isClosing && event.code !== CLOSE_CODES.TOKEN_REFRESH) {
1570
+ log.warn("[RealtimeChannelClient] Unexpected disconnection", {
1571
+ channel: this._channelName,
1572
+ code: event.code,
1573
+ reason: event.reason
1574
+ });
1575
+ }
1576
+ };
1577
+ this.ws.onerror = (event) => {
1578
+ log.error("[RealtimeChannelClient] WebSocket error", {
1579
+ channel: this._channelName,
1580
+ event
1581
+ });
1582
+ };
1583
+ }
1584
+ setupTokenRefreshListener() {
1585
+ const tokenRefreshHandler = async ({ token }) => {
1586
+ log.debug("[RealtimeChannelClient] Token refresh received, reconnecting", {
1587
+ channel: this._channelName
1588
+ });
1589
+ try {
1590
+ await this.reconnectWithNewToken(token);
1591
+ } catch (error) {
1592
+ log.error("[RealtimeChannelClient] Token refresh reconnection failed", {
1593
+ channel: this._channelName,
1594
+ error
1595
+ });
1596
+ }
1597
+ };
1598
+ messaging.listen("PLAYCADEMY_TOKEN_REFRESH" /* TOKEN_REFRESH */, tokenRefreshHandler);
1599
+ this.tokenRefreshUnsubscribe = () => {
1600
+ messaging.unlisten("PLAYCADEMY_TOKEN_REFRESH" /* TOKEN_REFRESH */, tokenRefreshHandler);
1601
+ };
1602
+ }
1603
+ async reconnectWithNewToken(_token) {
1604
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
1605
+ this.ws.close(CLOSE_CODES.TOKEN_REFRESH, "token_refresh");
1606
+ await new Promise((resolve) => {
1607
+ const checkClosed = () => {
1608
+ if (!this.ws || this.ws.readyState === WebSocket.CLOSED) {
1609
+ resolve();
1610
+ } else {
1611
+ setTimeout(checkClosed, 10);
1612
+ }
1613
+ };
1614
+ checkClosed();
1615
+ });
1616
+ }
1617
+ await this.connect();
1618
+ }
1619
+ send(data) {
1620
+ if (this.ws?.readyState === WebSocket.OPEN) {
1621
+ try {
1622
+ const message = JSON.stringify(data);
1623
+ this.ws.send(message);
1624
+ } catch (error) {
1625
+ log.error("[RealtimeChannelClient] Failed to send message", {
1626
+ channel: this._channelName,
1627
+ error,
1628
+ data
1629
+ });
1630
+ }
1631
+ } else {
1632
+ log.warn("[RealtimeChannelClient] Cannot send message - connection not open", {
1633
+ channel: this._channelName,
1634
+ readyState: this.ws?.readyState
1635
+ });
1636
+ }
1637
+ }
1638
+ onMessage(callback) {
1639
+ this.listeners.add(callback);
1640
+ return () => this.listeners.delete(callback);
1641
+ }
1642
+ close() {
1643
+ this.isClosing = true;
1644
+ if (this.tokenRefreshUnsubscribe) {
1645
+ this.tokenRefreshUnsubscribe();
1646
+ this.tokenRefreshUnsubscribe = undefined;
1647
+ }
1648
+ if (this.ws) {
1649
+ this.ws.close(CLOSE_CODES.NORMAL_CLOSURE, "client_close");
1650
+ this.ws = undefined;
1651
+ }
1652
+ this.listeners.clear();
1653
+ log.debug("[RealtimeChannelClient] Channel closed", {
1654
+ channel: this._channelName
1655
+ });
1656
+ }
1657
+ get channelName() {
1658
+ return this._channelName;
1659
+ }
1660
+ get isConnected() {
1661
+ return this.ws?.readyState === WebSocket.OPEN;
1662
+ }
1663
+ }
1664
+ var CLOSE_CODES;
1665
+ var init_realtime_client = __esm(() => {
1666
+ init_src();
1667
+ init_messaging();
1668
+ CLOSE_CODES = {
1669
+ NORMAL_CLOSURE: 1000,
1670
+ TOKEN_REFRESH: 4000
1671
+ };
1672
+ });
1673
+
1482
1674
  // src/core/namespaces/realtime.ts
1483
1675
  function createRealtimeNamespace(client) {
1484
1676
  return {
@@ -1486,15 +1678,26 @@ function createRealtimeNamespace(client) {
1486
1678
  get: async () => {
1487
1679
  return client["request"]("/realtime/token", "POST");
1488
1680
  }
1681
+ },
1682
+ async open(channel = "default") {
1683
+ if (!client["gameId"]) {
1684
+ throw new Error("gameId is required for realtime channels");
1685
+ }
1686
+ const realtimeClient = new RealtimeChannelClient(client["gameId"], channel, () => client.realtime.token.get().then((r) => r.token), client.getBaseUrl());
1687
+ return realtimeClient.connect();
1489
1688
  }
1490
1689
  };
1491
1690
  }
1691
+ var init_realtime = __esm(() => {
1692
+ init_realtime_client();
1693
+ });
1492
1694
 
1493
1695
  // src/core/namespaces/index.ts
1494
1696
  var init_namespaces = __esm(() => {
1495
1697
  init_runtime();
1496
1698
  init_games();
1497
1699
  init_credits();
1700
+ init_realtime();
1498
1701
  });
1499
1702
 
1500
1703
  // src/core/static/init.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcademy/sdk",
3
- "version": "0.0.1-beta.29",
3
+ "version": "0.0.1-beta.30",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {