@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.
package/dist/core/client.d.ts
CHANGED
|
@@ -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
|