@kokimoki/app 2.0.0 → 2.0.2
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/index.d.ts +3 -0
- package/dist/core/index.js +3 -0
- package/dist/core/kokimoki-client.d.ts +361 -0
- package/dist/core/kokimoki-client.js +819 -0
- package/dist/core/room-subscription-mode.d.ts +5 -0
- package/dist/core/room-subscription-mode.js +6 -0
- package/dist/core/room-subscription.d.ts +15 -0
- package/dist/core/room-subscription.js +53 -0
- package/dist/index.d.ts +4 -7
- package/dist/index.js +4 -7
- package/dist/kokimoki.min.d.ts +55 -59
- package/dist/kokimoki.min.js +3076 -1790
- package/dist/kokimoki.min.js.map +1 -1
- package/dist/llms.txt +75 -67
- package/dist/protocol/ws-message/index.d.ts +3 -0
- package/dist/protocol/ws-message/index.js +3 -0
- package/dist/protocol/ws-message/reader.d.ts +11 -0
- package/dist/protocol/ws-message/reader.js +36 -0
- package/dist/protocol/ws-message/type.d.ts +11 -0
- package/dist/protocol/ws-message/type.js +12 -0
- package/dist/protocol/ws-message/writer.d.ts +9 -0
- package/dist/protocol/ws-message/writer.js +45 -0
- package/dist/services/index.d.ts +3 -0
- package/dist/services/index.js +3 -0
- package/dist/services/kokimoki-ai.d.ts +153 -0
- package/dist/services/kokimoki-ai.js +164 -0
- package/dist/services/kokimoki-leaderboard.d.ts +175 -0
- package/dist/services/kokimoki-leaderboard.js +203 -0
- package/dist/services/kokimoki-storage.d.ts +155 -0
- package/dist/services/kokimoki-storage.js +208 -0
- package/dist/stores/index.d.ts +3 -0
- package/dist/stores/index.js +3 -0
- package/dist/stores/kokimoki-local-store.d.ts +11 -0
- package/dist/stores/kokimoki-local-store.js +40 -0
- package/dist/stores/kokimoki-store.d.ts +22 -0
- package/dist/stores/kokimoki-store.js +117 -0
- package/dist/stores/kokimoki-transaction.d.ts +18 -0
- package/dist/stores/kokimoki-transaction.js +143 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/index.js +3 -0
- package/dist/utils/valtio.d.ts +7 -0
- package/dist/utils/valtio.js +6 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +2 -1
- package/package.json +4 -3
|
@@ -0,0 +1,819 @@
|
|
|
1
|
+
import EventEmitter from "events";
|
|
2
|
+
import * as Y from "yjs";
|
|
3
|
+
import { WsMessageReader, WsMessageType, WsMessageWriter, } from "../protocol/ws-message";
|
|
4
|
+
import { KokimokiAiService, KokimokiLeaderboardService, KokimokiStorageService, } from "../services";
|
|
5
|
+
import { KokimokiLocalStore, KokimokiStore, KokimokiTransaction, } from "../stores";
|
|
6
|
+
import { KOKIMOKI_APP_VERSION } from "../version";
|
|
7
|
+
import { RoomSubscription } from "./room-subscription";
|
|
8
|
+
/**
|
|
9
|
+
* Kokimoki Client - Real-time Collaborative Game Development SDK
|
|
10
|
+
*
|
|
11
|
+
* The main entry point for building multiplayer games and collaborative applications.
|
|
12
|
+
* Provides real-time state synchronization, AI integration, cloud storage, leaderboards,
|
|
13
|
+
* and more - all without complex backend setup.
|
|
14
|
+
*
|
|
15
|
+
* **Core Capabilities:**
|
|
16
|
+
* - **Real-time Stores**: Synchronized state with automatic conflict resolution (powered by Valtio + Y.js)
|
|
17
|
+
* - **Atomic Transactions**: Update multiple stores consistently with automatic batching
|
|
18
|
+
* - **AI Integration**: Built-in text generation, structured JSON output, and image modification
|
|
19
|
+
* - **Cloud Storage**: File uploads with CDN delivery and tag-based organization
|
|
20
|
+
* - **Leaderboards**: Efficient player ranking with database indexes and pagination
|
|
21
|
+
* - **Presence Tracking**: Real-time connection status for all players
|
|
22
|
+
* - **Time Sync**: Server-synchronized timestamps across all clients
|
|
23
|
+
* - **Webhooks**: Send data to external services for backend processing
|
|
24
|
+
*
|
|
25
|
+
* **Quick Start:**
|
|
26
|
+
* ```typescript
|
|
27
|
+
* import { KokimokiClient } from '@kokimoki/app';
|
|
28
|
+
*
|
|
29
|
+
* // Initialize the client
|
|
30
|
+
* const kmClient = new KokimokiClient(
|
|
31
|
+
* 'your-host.kokimoki.com',
|
|
32
|
+
* 'your-app-id',
|
|
33
|
+
* 'optional-access-code'
|
|
34
|
+
* );
|
|
35
|
+
*
|
|
36
|
+
* // Connect to the server
|
|
37
|
+
* await kmClient.connect();
|
|
38
|
+
*
|
|
39
|
+
* // Create a synchronized store
|
|
40
|
+
* interface GameState {
|
|
41
|
+
* players: Record<string, { name: string; score: number }>;
|
|
42
|
+
* round: number;
|
|
43
|
+
* }
|
|
44
|
+
*
|
|
45
|
+
* const gameStore = kmClient.store<GameState>('game', {
|
|
46
|
+
* players: {},
|
|
47
|
+
* round: 1
|
|
48
|
+
* });
|
|
49
|
+
*
|
|
50
|
+
* // Update state atomically
|
|
51
|
+
* await kmClient.transact([gameStore], ([game]) => {
|
|
52
|
+
* game.players[kmClient.id] = { name: 'Player 1', score: 0 };
|
|
53
|
+
* game.round = 2;
|
|
54
|
+
* });
|
|
55
|
+
*
|
|
56
|
+
* // Use in React components with Valtio
|
|
57
|
+
* import { useSnapshot } from 'valtio';
|
|
58
|
+
*
|
|
59
|
+
* function GameComponent() {
|
|
60
|
+
* const game = useSnapshot(gameStore.proxy);
|
|
61
|
+
* return <div>Round: {game.round}</div>;
|
|
62
|
+
* }
|
|
63
|
+
* ```
|
|
64
|
+
*
|
|
65
|
+
* **Key Features:**
|
|
66
|
+
*
|
|
67
|
+
* **1. Real-time State Management**
|
|
68
|
+
* - Create global stores shared across all players: `kmClient.store()`
|
|
69
|
+
* - Create local stores for client-side data: `kmClient.localStore()`
|
|
70
|
+
* - Automatic synchronization and conflict resolution
|
|
71
|
+
* - Use `useSnapshot()` from Valtio for reactive React components
|
|
72
|
+
*
|
|
73
|
+
* **2. Atomic Transactions**
|
|
74
|
+
* ```typescript
|
|
75
|
+
* // Update multiple stores atomically
|
|
76
|
+
* await kmClient.transact([playerStore, gameStore], ([player, game]) => {
|
|
77
|
+
* player.score += 10;
|
|
78
|
+
* game.lastUpdate = kmClient.serverTimestamp();
|
|
79
|
+
* });
|
|
80
|
+
* ```
|
|
81
|
+
*
|
|
82
|
+
* **3. AI Integration (No API keys required)**
|
|
83
|
+
* ```typescript
|
|
84
|
+
* // Generate text
|
|
85
|
+
* const story = await kmClient.ai.chat({
|
|
86
|
+
* model: 'gpt-4o',
|
|
87
|
+
* userPrompt: 'Write a quest description',
|
|
88
|
+
* temperature: 0.8
|
|
89
|
+
* });
|
|
90
|
+
*
|
|
91
|
+
* // Generate structured data
|
|
92
|
+
* interface Quest { title: string; reward: number; }
|
|
93
|
+
* const quest = await kmClient.ai.generateJson<Quest>({
|
|
94
|
+
* userPrompt: 'Create a level 5 quest'
|
|
95
|
+
* });
|
|
96
|
+
*
|
|
97
|
+
* // Modify images
|
|
98
|
+
* const modified = await kmClient.ai.modifyImage(url, 'Make it pixel art');
|
|
99
|
+
* ```
|
|
100
|
+
*
|
|
101
|
+
* **4. Cloud Storage**
|
|
102
|
+
* ```typescript
|
|
103
|
+
* // Upload files with tags
|
|
104
|
+
* const upload = await kmClient.storage.upload('avatar.jpg', blob, ['profile']);
|
|
105
|
+
*
|
|
106
|
+
* // Query uploads
|
|
107
|
+
* const images = await kmClient.storage.listUploads({
|
|
108
|
+
* clientId: kmClient.id,
|
|
109
|
+
* mimeTypes: ['image/jpeg', 'image/png']
|
|
110
|
+
* });
|
|
111
|
+
* ```
|
|
112
|
+
*
|
|
113
|
+
* **5. Leaderboards**
|
|
114
|
+
* ```typescript
|
|
115
|
+
* // Submit score (replaces previous entry)
|
|
116
|
+
* await kmClient.leaderboard.upsertEntry(
|
|
117
|
+
* 'high-scores',
|
|
118
|
+
* 'desc',
|
|
119
|
+
* 1500,
|
|
120
|
+
* { playerName: 'Alice' },
|
|
121
|
+
* {}
|
|
122
|
+
* );
|
|
123
|
+
*
|
|
124
|
+
* // Get top 10
|
|
125
|
+
* const top10 = await kmClient.leaderboard.listEntries('high-scores', 'desc', 0, 10);
|
|
126
|
+
* ```
|
|
127
|
+
*
|
|
128
|
+
* **6. Presence Tracking**
|
|
129
|
+
* ```typescript
|
|
130
|
+
* // Track online players
|
|
131
|
+
* const onlineClientIds = useSnapshot(gameStore.connections).clientIds;
|
|
132
|
+
* const isPlayerOnline = onlineClientIds.has(playerId);
|
|
133
|
+
* ```
|
|
134
|
+
*
|
|
135
|
+
* **Best Practices:**
|
|
136
|
+
* - Always use `kmClient.serverTimestamp()` for time-sensitive operations
|
|
137
|
+
* - Prefer Records over Arrays: `Record<string, T>` with timestamp keys
|
|
138
|
+
* - Use `kmClient.transact()` for all state updates to ensure atomicity
|
|
139
|
+
* - Tag uploads for easy filtering and organization
|
|
140
|
+
* - Use local stores for client-side settings and preferences
|
|
141
|
+
* - Leverage TypeScript generics for type-safe stores
|
|
142
|
+
*
|
|
143
|
+
* **Events:**
|
|
144
|
+
* - `connected`: Fired when client connects/reconnects to server
|
|
145
|
+
* - `disconnected`: Fired when connection is lost
|
|
146
|
+
*
|
|
147
|
+
* @template ClientContextT The type of client context data (custom user data from your backend)
|
|
148
|
+
*
|
|
149
|
+
* @example
|
|
150
|
+
* ```typescript
|
|
151
|
+
* // Listen for connection events
|
|
152
|
+
* kmClient.on('connected', () => {
|
|
153
|
+
* console.log('Connected to Kokimoki!');
|
|
154
|
+
* });
|
|
155
|
+
*
|
|
156
|
+
* kmClient.on('disconnected', () => {
|
|
157
|
+
* console.log('Connection lost, will auto-reconnect...');
|
|
158
|
+
* });
|
|
159
|
+
* ```
|
|
160
|
+
*/
|
|
161
|
+
export class KokimokiClient extends EventEmitter {
|
|
162
|
+
host;
|
|
163
|
+
appId;
|
|
164
|
+
code;
|
|
165
|
+
_wsUrl;
|
|
166
|
+
_apiUrl;
|
|
167
|
+
_id;
|
|
168
|
+
_connectionId;
|
|
169
|
+
_token;
|
|
170
|
+
_apiHeaders;
|
|
171
|
+
_serverTimeOffset = 0;
|
|
172
|
+
_clientContext;
|
|
173
|
+
_ws;
|
|
174
|
+
_subscriptionsByName = new Map();
|
|
175
|
+
_subscriptionsByHash = new Map();
|
|
176
|
+
_subscribeReqPromises = new Map();
|
|
177
|
+
_unsubscribeReqPromises = new Map();
|
|
178
|
+
_transactionPromises = new Map();
|
|
179
|
+
_connected = false;
|
|
180
|
+
_connectPromise;
|
|
181
|
+
_messageId = 0;
|
|
182
|
+
_autoReconnect = true;
|
|
183
|
+
_reconnectTimeout = 0;
|
|
184
|
+
_pingInterval;
|
|
185
|
+
_clientTokenKey = "KM_TOKEN";
|
|
186
|
+
_editorContext;
|
|
187
|
+
_ai;
|
|
188
|
+
_storage;
|
|
189
|
+
_leaderboard;
|
|
190
|
+
constructor(host, appId, code = "") {
|
|
191
|
+
super();
|
|
192
|
+
this.host = host;
|
|
193
|
+
this.appId = appId;
|
|
194
|
+
this.code = code;
|
|
195
|
+
// Set up the URLs
|
|
196
|
+
const secure = this.host.indexOf(":") === -1;
|
|
197
|
+
this._wsUrl = `ws${secure ? "s" : ""}://${this.host}`;
|
|
198
|
+
this._apiUrl = `http${secure ? "s" : ""}://${this.host}`;
|
|
199
|
+
// Initialize modules
|
|
200
|
+
this._ai = new KokimokiAiService(this);
|
|
201
|
+
this._storage = new KokimokiStorageService(this);
|
|
202
|
+
this._leaderboard = new KokimokiLeaderboardService(this);
|
|
203
|
+
// Set up ping interval
|
|
204
|
+
const pingMsg = new WsMessageWriter();
|
|
205
|
+
pingMsg.writeInt32(WsMessageType.Ping);
|
|
206
|
+
const pingBuffer = pingMsg.getBuffer();
|
|
207
|
+
this._pingInterval = setInterval(() => {
|
|
208
|
+
if (this.connected) {
|
|
209
|
+
this.ws.send(pingBuffer);
|
|
210
|
+
}
|
|
211
|
+
}, 5000);
|
|
212
|
+
// Listen for devtools messages
|
|
213
|
+
if (window.parent && window.self !== window.parent) {
|
|
214
|
+
window.addEventListener("message", (e) => {
|
|
215
|
+
console.log(`[KM TOOLS] ${e.data}`);
|
|
216
|
+
this._editorContext = e.data;
|
|
217
|
+
if (e.data === "km:clearStorage") {
|
|
218
|
+
localStorage.removeItem(this._clientTokenKey);
|
|
219
|
+
window.location.reload();
|
|
220
|
+
}
|
|
221
|
+
else if (e.data === "km:reload") {
|
|
222
|
+
window.location.reload();
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
get id() {
|
|
228
|
+
if (!this._id) {
|
|
229
|
+
throw new Error("Client not connected");
|
|
230
|
+
}
|
|
231
|
+
return this._id;
|
|
232
|
+
}
|
|
233
|
+
get connectionId() {
|
|
234
|
+
if (!this._connectionId) {
|
|
235
|
+
throw new Error("Client not connected");
|
|
236
|
+
}
|
|
237
|
+
return this._connectionId;
|
|
238
|
+
}
|
|
239
|
+
get token() {
|
|
240
|
+
if (!this._token) {
|
|
241
|
+
throw new Error("Client not connected");
|
|
242
|
+
}
|
|
243
|
+
return this._token;
|
|
244
|
+
}
|
|
245
|
+
get apiUrl() {
|
|
246
|
+
if (!this._apiUrl) {
|
|
247
|
+
throw new Error("Client not connected");
|
|
248
|
+
}
|
|
249
|
+
return this._apiUrl;
|
|
250
|
+
}
|
|
251
|
+
get apiHeaders() {
|
|
252
|
+
if (!this._apiHeaders) {
|
|
253
|
+
throw new Error("Client not connected");
|
|
254
|
+
}
|
|
255
|
+
return this._apiHeaders;
|
|
256
|
+
}
|
|
257
|
+
get clientContext() {
|
|
258
|
+
if (this._clientContext === undefined) {
|
|
259
|
+
throw new Error("Client not connected");
|
|
260
|
+
}
|
|
261
|
+
return this._clientContext;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Indicates whether the client is currently connected to the server.
|
|
265
|
+
*/
|
|
266
|
+
get connected() {
|
|
267
|
+
return this._connected;
|
|
268
|
+
}
|
|
269
|
+
get ws() {
|
|
270
|
+
if (!this._ws) {
|
|
271
|
+
throw new Error("Not connected");
|
|
272
|
+
}
|
|
273
|
+
return this._ws;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Indicates whether the client is running in editor/development mode.
|
|
277
|
+
*/
|
|
278
|
+
get isEditor() {
|
|
279
|
+
return !!this._editorContext;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Establishes a connection to the Kokimoki server.
|
|
283
|
+
*
|
|
284
|
+
* Handles authentication, WebSocket setup, and automatic reconnection.
|
|
285
|
+
* If already connecting, returns the existing connection promise.
|
|
286
|
+
*
|
|
287
|
+
* @returns A promise that resolves when the connection is established.
|
|
288
|
+
* @throws Error if the connection fails.
|
|
289
|
+
*/
|
|
290
|
+
async connect() {
|
|
291
|
+
if (this._connectPromise) {
|
|
292
|
+
return await this._connectPromise;
|
|
293
|
+
}
|
|
294
|
+
// Detect devtools
|
|
295
|
+
if (window.parent && window.self !== window.parent) {
|
|
296
|
+
await new Promise((resolve) => {
|
|
297
|
+
/* // Wait up to 500ms for parent to respond
|
|
298
|
+
const timeout = setTimeout(() => {
|
|
299
|
+
window.removeEventListener("message", onMessage);
|
|
300
|
+
resolve();
|
|
301
|
+
}, 500); */
|
|
302
|
+
// Listen for parent response
|
|
303
|
+
const onMessage = (e) => {
|
|
304
|
+
// clearTimeout(timeout);
|
|
305
|
+
window.removeEventListener("message", onMessage);
|
|
306
|
+
if (e.data.clientKey) {
|
|
307
|
+
this._clientTokenKey = `KM_TOKEN/${e.data.clientKey}`;
|
|
308
|
+
}
|
|
309
|
+
resolve();
|
|
310
|
+
};
|
|
311
|
+
window.addEventListener("message", onMessage);
|
|
312
|
+
window.parent.postMessage({ appId: this.appId }, "*");
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
// Set up the WebSocket connection
|
|
316
|
+
this._ws = new WebSocket(`${this._wsUrl}/apps/${this.appId}?clientVersion=${KOKIMOKI_APP_VERSION}`);
|
|
317
|
+
this._ws.binaryType = "arraybuffer";
|
|
318
|
+
// Close previous connection in hot-reload scenarios
|
|
319
|
+
if (window) {
|
|
320
|
+
if (!window.__KOKIMOKI_WS__) {
|
|
321
|
+
window.__KOKIMOKI_WS__ = {};
|
|
322
|
+
}
|
|
323
|
+
if (this.appId in window.__KOKIMOKI_WS__) {
|
|
324
|
+
console.log(`[Kokimoki] Closing previous connection for ${this.appId}`);
|
|
325
|
+
window.__KOKIMOKI_WS__[this.appId].close();
|
|
326
|
+
}
|
|
327
|
+
window.__KOKIMOKI_WS__[this.appId] = this;
|
|
328
|
+
}
|
|
329
|
+
// Wait for connection
|
|
330
|
+
this._connectPromise = new Promise((onInit) => {
|
|
331
|
+
// Fetch the auth token
|
|
332
|
+
const clientToken = localStorage.getItem(this._clientTokenKey);
|
|
333
|
+
// Send the app token on first connect
|
|
334
|
+
this.ws.onopen = () => {
|
|
335
|
+
this.ws.send(JSON.stringify({ type: "auth", code: this.code, token: clientToken }));
|
|
336
|
+
};
|
|
337
|
+
this.ws.onclose = () => {
|
|
338
|
+
this._connected = false;
|
|
339
|
+
this._connectPromise = undefined;
|
|
340
|
+
this._ws.onmessage = null;
|
|
341
|
+
this._ws = undefined;
|
|
342
|
+
if (window && window.__KOKIMOKI_WS__) {
|
|
343
|
+
delete window.__KOKIMOKI_WS__[this.appId];
|
|
344
|
+
}
|
|
345
|
+
// Clean up
|
|
346
|
+
this._subscribeReqPromises.clear();
|
|
347
|
+
this._transactionPromises.clear();
|
|
348
|
+
// Attempt to reconnect
|
|
349
|
+
if (!this._autoReconnect) {
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
console.log(`[Kokimoki] Connection lost, attempting to reconnect in ${this._reconnectTimeout} seconds...`);
|
|
353
|
+
setTimeout(async () => await this.connect(), this._reconnectTimeout * 1000);
|
|
354
|
+
this._reconnectTimeout = Math.min(3, this._reconnectTimeout + 1);
|
|
355
|
+
// Emit disconnected event
|
|
356
|
+
this.emit("disconnected");
|
|
357
|
+
};
|
|
358
|
+
this.ws.onmessage = (e) => {
|
|
359
|
+
// console.log(`Received WS message: ${e.data}`);
|
|
360
|
+
// Handle JSON messages
|
|
361
|
+
if (typeof e.data === "string") {
|
|
362
|
+
const message = JSON.parse(e.data);
|
|
363
|
+
switch (message.type) {
|
|
364
|
+
case "init":
|
|
365
|
+
this.handleInitMessage(message);
|
|
366
|
+
onInit();
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
// Handle binary messages
|
|
372
|
+
this.handleBinaryMessage(e.data);
|
|
373
|
+
};
|
|
374
|
+
});
|
|
375
|
+
await this._connectPromise;
|
|
376
|
+
this._connected = true;
|
|
377
|
+
// Connection established
|
|
378
|
+
console.log(`[Kokimoki] Client id: ${this.id}`);
|
|
379
|
+
console.log(`[Kokimoki] Client context:`, this.clientContext);
|
|
380
|
+
// Restore subscriptions if reconnected
|
|
381
|
+
const roomNames = Array.from(this._subscriptionsByName.keys()).map((name) => `"${name}"`);
|
|
382
|
+
if (roomNames.length) {
|
|
383
|
+
console.log(`[Kokimoki] Restoring subscriptions: ${roomNames}`);
|
|
384
|
+
}
|
|
385
|
+
for (const subscription of this._subscriptionsByName.values()) {
|
|
386
|
+
try {
|
|
387
|
+
await this.join(subscription.store);
|
|
388
|
+
}
|
|
389
|
+
catch (err) {
|
|
390
|
+
console.error(`[Kokimoki] Failed to restore subscription for "${subscription.roomName}":`, err);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
// Emit connected event
|
|
394
|
+
this._reconnectTimeout = 0;
|
|
395
|
+
this.emit("connected");
|
|
396
|
+
}
|
|
397
|
+
handleInitMessage(message) {
|
|
398
|
+
localStorage.setItem(this._clientTokenKey, message.clientToken);
|
|
399
|
+
this._id = message.clientId;
|
|
400
|
+
this._connectionId = message.id;
|
|
401
|
+
this._token = message.appToken;
|
|
402
|
+
this._clientContext = message.clientContext;
|
|
403
|
+
// Set up the auth headers
|
|
404
|
+
this._apiHeaders = new Headers({
|
|
405
|
+
Authorization: `Bearer ${this.token}`,
|
|
406
|
+
"Content-Type": "application/json",
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
handleBinaryMessage(data) {
|
|
410
|
+
const reader = new WsMessageReader(data);
|
|
411
|
+
const type = reader.readInt32();
|
|
412
|
+
switch (type) {
|
|
413
|
+
case WsMessageType.SubscribeRes:
|
|
414
|
+
this.handleSubscribeResMessage(reader);
|
|
415
|
+
break;
|
|
416
|
+
case WsMessageType.UnsubscribeRes:
|
|
417
|
+
this.handleUnsubscribeResMessage(reader);
|
|
418
|
+
break;
|
|
419
|
+
case WsMessageType.RoomUpdate:
|
|
420
|
+
this.handleRoomUpdateMessage(reader);
|
|
421
|
+
break;
|
|
422
|
+
case WsMessageType.Error:
|
|
423
|
+
this.handleErrorMessage(reader);
|
|
424
|
+
break;
|
|
425
|
+
case WsMessageType.Pong: {
|
|
426
|
+
const s = reader.readInt32();
|
|
427
|
+
const ms = reader.readInt32();
|
|
428
|
+
this._serverTimeOffset = Date.now() - s * 1000 - ms;
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
handleErrorMessage(msg) {
|
|
434
|
+
const reqId = msg.readInt32();
|
|
435
|
+
const error = msg.readString();
|
|
436
|
+
const subscribeReqPromise = this._subscribeReqPromises.get(reqId);
|
|
437
|
+
const transactionPromise = this._transactionPromises.get(reqId);
|
|
438
|
+
if (subscribeReqPromise) {
|
|
439
|
+
this._subscribeReqPromises.delete(reqId);
|
|
440
|
+
subscribeReqPromise.reject(error);
|
|
441
|
+
}
|
|
442
|
+
else if (transactionPromise) {
|
|
443
|
+
this._transactionPromises.delete(reqId);
|
|
444
|
+
transactionPromise.reject(error);
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
console.warn(`Received error for unknown request ${reqId}: ${error}`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
handleSubscribeResMessage(msg) {
|
|
451
|
+
const reqId = msg.readInt32();
|
|
452
|
+
const roomHash = msg.readUint32();
|
|
453
|
+
const promise = this._subscribeReqPromises.get(reqId);
|
|
454
|
+
if (promise) {
|
|
455
|
+
this._subscribeReqPromises.delete(reqId);
|
|
456
|
+
// In Write mode, no initial state is sent
|
|
457
|
+
if (!msg.end) {
|
|
458
|
+
promise.resolve(roomHash, msg.readUint8Array());
|
|
459
|
+
}
|
|
460
|
+
else {
|
|
461
|
+
promise.resolve(roomHash);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
handleUnsubscribeResMessage(msg) {
|
|
466
|
+
const reqId = msg.readInt32();
|
|
467
|
+
const promise = this._unsubscribeReqPromises.get(reqId);
|
|
468
|
+
if (promise) {
|
|
469
|
+
this._unsubscribeReqPromises.delete(reqId);
|
|
470
|
+
promise.resolve();
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
handleRoomUpdateMessage(msg) {
|
|
474
|
+
const appliedId = msg.readInt32();
|
|
475
|
+
const roomHash = msg.readUint32();
|
|
476
|
+
// Apply update if not in Write mode
|
|
477
|
+
if (!msg.end) {
|
|
478
|
+
const subscription = this._subscriptionsByHash.get(roomHash);
|
|
479
|
+
if (subscription) {
|
|
480
|
+
Y.applyUpdate(subscription.store.doc, msg.readUint8Array(), this);
|
|
481
|
+
}
|
|
482
|
+
else {
|
|
483
|
+
console.warn(`Received update for unknown room ${roomHash}`);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
// Check transaction resolves
|
|
487
|
+
for (const [transactionId, { resolve },] of this._transactionPromises.entries()) {
|
|
488
|
+
if (appliedId >= transactionId) {
|
|
489
|
+
this._transactionPromises.delete(transactionId);
|
|
490
|
+
resolve();
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Gets the current server timestamp, accounting for client-server time offset.
|
|
496
|
+
*
|
|
497
|
+
* @returns The current server timestamp in milliseconds.
|
|
498
|
+
*/
|
|
499
|
+
serverTimestamp() {
|
|
500
|
+
return Date.now() - this._serverTimeOffset;
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Sends a Y.js update to a specific room.
|
|
504
|
+
*
|
|
505
|
+
* @param room - The name of the room to update.
|
|
506
|
+
* @param update - The Y.js update as a Uint8Array.
|
|
507
|
+
* @returns A promise that resolves with the server response.
|
|
508
|
+
*/
|
|
509
|
+
async patchRoomState(room, update) {
|
|
510
|
+
const res = await fetch(`${this._apiUrl}/rooms/${room}`, {
|
|
511
|
+
method: "PATCH",
|
|
512
|
+
headers: this.apiHeaders,
|
|
513
|
+
body: update,
|
|
514
|
+
});
|
|
515
|
+
return await res.json();
|
|
516
|
+
}
|
|
517
|
+
async sendSubscribeReq(roomName, mode) {
|
|
518
|
+
// Set up sync resolver
|
|
519
|
+
const reqId = ++this._messageId;
|
|
520
|
+
return await new Promise((resolve, reject) => {
|
|
521
|
+
this._subscribeReqPromises.set(reqId, {
|
|
522
|
+
resolve: (roomHash, initialUpdate) => {
|
|
523
|
+
resolve({ roomHash, initialUpdate });
|
|
524
|
+
},
|
|
525
|
+
reject,
|
|
526
|
+
});
|
|
527
|
+
// Send subscription request
|
|
528
|
+
const msg = new WsMessageWriter();
|
|
529
|
+
msg.writeInt32(WsMessageType.SubscribeReq);
|
|
530
|
+
msg.writeInt32(reqId);
|
|
531
|
+
msg.writeString(roomName);
|
|
532
|
+
msg.writeChar(mode);
|
|
533
|
+
this.ws.send(msg.getBuffer());
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
async sendUnsubscribeReq(roomHash) {
|
|
537
|
+
const reqId = ++this._messageId;
|
|
538
|
+
return await new Promise((resolve, reject) => {
|
|
539
|
+
this._unsubscribeReqPromises.set(reqId, {
|
|
540
|
+
resolve,
|
|
541
|
+
reject,
|
|
542
|
+
});
|
|
543
|
+
// Send unsubscribe request
|
|
544
|
+
const msg = new WsMessageWriter();
|
|
545
|
+
msg.writeInt32(WsMessageType.UnsubscribeReq);
|
|
546
|
+
msg.writeInt32(reqId);
|
|
547
|
+
msg.writeUint32(roomHash);
|
|
548
|
+
this.ws.send(msg.getBuffer());
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Joins a store by subscribing to its corresponding room.
|
|
553
|
+
*
|
|
554
|
+
* If already joined, this method does nothing. For local stores, initializes
|
|
555
|
+
* the store locally. For remote stores, sends a subscription request to the server.
|
|
556
|
+
*
|
|
557
|
+
* @param store - The KokimokiStore to join.
|
|
558
|
+
* @template T - The type of the store's state object.
|
|
559
|
+
*/
|
|
560
|
+
async join(store) {
|
|
561
|
+
let subscription = this._subscriptionsByName.get(store.roomName);
|
|
562
|
+
if (!subscription) {
|
|
563
|
+
subscription = new RoomSubscription(this, store);
|
|
564
|
+
this._subscriptionsByName.set(store.roomName, subscription);
|
|
565
|
+
}
|
|
566
|
+
// Send subscription request if connected to server
|
|
567
|
+
if (!subscription.joined) {
|
|
568
|
+
let res;
|
|
569
|
+
if (store instanceof KokimokiLocalStore) {
|
|
570
|
+
res = store.getInitialUpdate(this.appId, this.id);
|
|
571
|
+
}
|
|
572
|
+
else {
|
|
573
|
+
res = await this.sendSubscribeReq(store.roomName, store.mode);
|
|
574
|
+
}
|
|
575
|
+
this._subscriptionsByHash.set(res.roomHash, subscription);
|
|
576
|
+
await subscription.applyInitialResponse(res.roomHash, res.initialUpdate);
|
|
577
|
+
// Trigger onJoin event
|
|
578
|
+
await store.onJoin(this);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Leaves a store by unsubscribing from its corresponding room.
|
|
583
|
+
*
|
|
584
|
+
* Triggers the store's `onBeforeLeave` and `onLeave` lifecycle hooks.
|
|
585
|
+
*
|
|
586
|
+
* @param store - The KokimokiStore to leave.
|
|
587
|
+
* @template T - The type of the store's state object.
|
|
588
|
+
*/
|
|
589
|
+
async leave(store) {
|
|
590
|
+
const subscription = this._subscriptionsByName.get(store.roomName);
|
|
591
|
+
if (subscription) {
|
|
592
|
+
await store.onBeforeLeave(this);
|
|
593
|
+
await this.sendUnsubscribeReq(subscription.roomHash);
|
|
594
|
+
this._subscriptionsByName.delete(store.roomName);
|
|
595
|
+
this._subscriptionsByHash.delete(subscription.roomHash);
|
|
596
|
+
subscription.close();
|
|
597
|
+
// Trigger onLeave event
|
|
598
|
+
store.onLeave(this);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Executes a transaction across one or more stores.
|
|
603
|
+
*
|
|
604
|
+
* Provides proxies to the stores that track changes. All changes are batched
|
|
605
|
+
* and sent to the server atomically. The transaction ensures consistency across
|
|
606
|
+
* multiple stores.
|
|
607
|
+
*
|
|
608
|
+
* @param stores - Array of stores to include in the transaction.
|
|
609
|
+
* @param handler - Function that receives store proxies and performs modifications.
|
|
610
|
+
* @returns A promise that resolves with the return value of the handler.
|
|
611
|
+
* @template TStores - Tuple type of the stores array.
|
|
612
|
+
* @template ReturnT - The return type of the handler function.
|
|
613
|
+
*
|
|
614
|
+
* @example
|
|
615
|
+
* ```ts
|
|
616
|
+
* await client.transact([playerStore, gameStore], ([player, game]) => {
|
|
617
|
+
* player.score += 10;
|
|
618
|
+
* game.lastUpdate = Date.now();
|
|
619
|
+
* });
|
|
620
|
+
* ```
|
|
621
|
+
*/
|
|
622
|
+
async transact(stores, handler) {
|
|
623
|
+
// if (!this._connected) {
|
|
624
|
+
// throw new Error("Client not connected");
|
|
625
|
+
// }
|
|
626
|
+
const transaction = new KokimokiTransaction(stores);
|
|
627
|
+
// @ts-ignore
|
|
628
|
+
const returnValue = await handler(transaction.getProxies());
|
|
629
|
+
const { updates, consumedMessages } = await transaction.getUpdates();
|
|
630
|
+
if (!updates.length) {
|
|
631
|
+
return returnValue;
|
|
632
|
+
}
|
|
633
|
+
// Construct buffers
|
|
634
|
+
const remoteUpdateWriter = new WsMessageWriter();
|
|
635
|
+
const localUpdateWriter = new WsMessageWriter();
|
|
636
|
+
// Write message type
|
|
637
|
+
remoteUpdateWriter.writeInt32(WsMessageType.Transaction);
|
|
638
|
+
// Update and write transaction ID
|
|
639
|
+
const transactionId = ++this._messageId;
|
|
640
|
+
remoteUpdateWriter.writeInt32(transactionId);
|
|
641
|
+
localUpdateWriter.writeInt32(transactionId);
|
|
642
|
+
// Write room hashes where messages were consumed (remote only)
|
|
643
|
+
remoteUpdateWriter.writeInt32(consumedMessages.size);
|
|
644
|
+
for (const roomName of consumedMessages) {
|
|
645
|
+
const subscription = this._subscriptionsByName.get(roomName);
|
|
646
|
+
if (!subscription) {
|
|
647
|
+
throw new Error(`Cannot consume message in "${roomName}" because it hasn't been joined`);
|
|
648
|
+
}
|
|
649
|
+
remoteUpdateWriter.writeUint32(subscription.roomHash);
|
|
650
|
+
}
|
|
651
|
+
// Write updates
|
|
652
|
+
let localUpdates = 0, remoteUpdates = 0;
|
|
653
|
+
for (const { roomName, update } of updates) {
|
|
654
|
+
const subscription = this._subscriptionsByName.get(roomName);
|
|
655
|
+
if (!subscription) {
|
|
656
|
+
throw new Error(`Cannot send update to "${roomName}" because it hasn't been joined`);
|
|
657
|
+
}
|
|
658
|
+
if (subscription.store instanceof KokimokiLocalStore) {
|
|
659
|
+
localUpdates++;
|
|
660
|
+
localUpdateWriter.writeUint32(subscription.roomHash);
|
|
661
|
+
localUpdateWriter.writeUint8Array(update);
|
|
662
|
+
}
|
|
663
|
+
else {
|
|
664
|
+
remoteUpdates++;
|
|
665
|
+
remoteUpdateWriter.writeUint32(subscription.roomHash);
|
|
666
|
+
remoteUpdateWriter.writeUint8Array(update);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
// Wait for server to apply transaction
|
|
670
|
+
if (remoteUpdates) {
|
|
671
|
+
const remoteBuffer = remoteUpdateWriter.getBuffer();
|
|
672
|
+
await new Promise((resolve, reject) => {
|
|
673
|
+
this._transactionPromises.set(transactionId, { resolve, reject });
|
|
674
|
+
// Send update to server
|
|
675
|
+
try {
|
|
676
|
+
this.ws.send(remoteBuffer);
|
|
677
|
+
}
|
|
678
|
+
catch (e) {
|
|
679
|
+
// Not connected
|
|
680
|
+
console.log("Failed to send update to server:", e);
|
|
681
|
+
// Delete transaction promise
|
|
682
|
+
this._transactionPromises.delete(transactionId);
|
|
683
|
+
// TODO: merge updates or something
|
|
684
|
+
reject(e);
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
// Apply local updates
|
|
689
|
+
if (localUpdates) {
|
|
690
|
+
const localBuffer = localUpdateWriter.getBuffer();
|
|
691
|
+
const reader = new WsMessageReader(localBuffer);
|
|
692
|
+
this.handleRoomUpdateMessage(reader);
|
|
693
|
+
}
|
|
694
|
+
return returnValue;
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* Closes the client connection and cleans up resources.
|
|
698
|
+
*
|
|
699
|
+
* Disables automatic reconnection, closes the WebSocket, and clears all intervals.
|
|
700
|
+
*/
|
|
701
|
+
async close() {
|
|
702
|
+
this._autoReconnect = false;
|
|
703
|
+
if (this._ws) {
|
|
704
|
+
this._ws.close();
|
|
705
|
+
}
|
|
706
|
+
if (this._pingInterval) {
|
|
707
|
+
clearInterval(this._pingInterval);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Gets the internal room hash identifier for a store.
|
|
712
|
+
*
|
|
713
|
+
* @param store - The store to get the room hash for.
|
|
714
|
+
* @returns The room hash as a number.
|
|
715
|
+
* @throws Error if the store hasn't been joined.
|
|
716
|
+
* @template T - The type of the store's state object.
|
|
717
|
+
*/
|
|
718
|
+
getRoomHash(store) {
|
|
719
|
+
const subscription = this._subscriptionsByName.get(store.roomName);
|
|
720
|
+
if (!subscription) {
|
|
721
|
+
throw new Error(`Store "${store.roomName}" not joined`);
|
|
722
|
+
}
|
|
723
|
+
return subscription.roomHash;
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Creates a new remote store synchronized with the server.
|
|
727
|
+
*
|
|
728
|
+
* @param name - The name of the room/store.
|
|
729
|
+
* @param defaultState - The initial state of the store.
|
|
730
|
+
* @param autoJoin - Whether to automatically join the store (default: true).
|
|
731
|
+
* @returns A new KokimokiStore instance.
|
|
732
|
+
* @template T - The type of the store's state object.
|
|
733
|
+
*
|
|
734
|
+
* @example
|
|
735
|
+
* ```ts
|
|
736
|
+
* const gameStore = client.store('game', { players: [], score: 0 });
|
|
737
|
+
* ```
|
|
738
|
+
*/
|
|
739
|
+
store(name, defaultState, autoJoin = true) {
|
|
740
|
+
const store = new KokimokiStore(name, defaultState);
|
|
741
|
+
if (autoJoin) {
|
|
742
|
+
this.join(store)
|
|
743
|
+
.then(() => { })
|
|
744
|
+
.catch(() => { });
|
|
745
|
+
}
|
|
746
|
+
return store;
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* Creates a new local store that persists only in the client's browser.
|
|
750
|
+
*
|
|
751
|
+
* Local stores are automatically joined and are not synchronized with the server.
|
|
752
|
+
* Data is stored locally per client and app.
|
|
753
|
+
*
|
|
754
|
+
* @param name - The name of the local store.
|
|
755
|
+
* @param defaultState - The initial state of the store.
|
|
756
|
+
* @returns A new KokimokiLocalStore instance.
|
|
757
|
+
* @template T - The type of the store's state object.
|
|
758
|
+
*
|
|
759
|
+
* @example
|
|
760
|
+
* ```ts
|
|
761
|
+
* const settingsStore = client.localStore('settings', { volume: 0.5, theme: 'dark' });
|
|
762
|
+
* ```
|
|
763
|
+
*/
|
|
764
|
+
localStore(name, defaultState) {
|
|
765
|
+
const store = new KokimokiLocalStore(name, defaultState);
|
|
766
|
+
this.join(store)
|
|
767
|
+
.then(() => { })
|
|
768
|
+
.catch(() => { });
|
|
769
|
+
return store;
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Sends app data to the server via webhook for external processing.
|
|
773
|
+
*
|
|
774
|
+
* @param event - The name of the webhook event.
|
|
775
|
+
* @param data - The data to send with the webhook.
|
|
776
|
+
* @returns A promise that resolves with the job ID.
|
|
777
|
+
* @template T - The type of the data being sent.
|
|
778
|
+
*
|
|
779
|
+
* @example
|
|
780
|
+
* ```ts
|
|
781
|
+
* await client.sendWebhook('game-ended', { winner: 'player1', score: 100 });
|
|
782
|
+
* ```
|
|
783
|
+
*/
|
|
784
|
+
async sendWebhook(event, data) {
|
|
785
|
+
const res = await fetch(`${this._apiUrl}/webhooks`, {
|
|
786
|
+
method: "POST",
|
|
787
|
+
headers: this.apiHeaders,
|
|
788
|
+
body: JSON.stringify({ event, data }),
|
|
789
|
+
});
|
|
790
|
+
return await res.json();
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* Access AI capabilities including text generation, structured JSON output, and image modification.
|
|
794
|
+
*/
|
|
795
|
+
get ai() {
|
|
796
|
+
if (!this._ai) {
|
|
797
|
+
throw new Error("AI client not initialized");
|
|
798
|
+
}
|
|
799
|
+
return this._ai;
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* Access file upload and management for media files, images, and user-generated content.
|
|
803
|
+
*/
|
|
804
|
+
get storage() {
|
|
805
|
+
if (!this._storage) {
|
|
806
|
+
throw new Error("Storage client not initialized");
|
|
807
|
+
}
|
|
808
|
+
return this._storage;
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* Access player ranking and score tracking with efficient queries and pagination.
|
|
812
|
+
*/
|
|
813
|
+
get leaderboard() {
|
|
814
|
+
if (!this._leaderboard) {
|
|
815
|
+
throw new Error("Leaderboard client not initialized");
|
|
816
|
+
}
|
|
817
|
+
return this._leaderboard;
|
|
818
|
+
}
|
|
819
|
+
}
|