@kokimoki/app 0.6.9 → 1.0.1

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.
Files changed (48) hide show
  1. package/dist/index.d.ts +5 -1
  2. package/dist/index.js +5 -1
  3. package/dist/kokimoki-client copy.d.ts +57 -0
  4. package/dist/kokimoki-client copy.js +259 -0
  5. package/dist/kokimoki-client-refactored.d.ts +67 -0
  6. package/dist/kokimoki-client-refactored.js +422 -0
  7. package/dist/kokimoki-client.d.ts +25 -14
  8. package/dist/kokimoki-client.js +286 -103
  9. package/dist/kokimoki-queue.d.ts +34 -0
  10. package/dist/kokimoki-queue.js +30 -0
  11. package/dist/kokimoki-schema.d.ts +58 -0
  12. package/dist/kokimoki-schema.js +106 -0
  13. package/dist/kokimoki-store.d.ts +13 -0
  14. package/dist/kokimoki-store.js +48 -0
  15. package/dist/kokimoki-transaction.d.ts +25 -0
  16. package/dist/kokimoki-transaction.js +99 -0
  17. package/dist/message-queue.d.ts +8 -0
  18. package/dist/message-queue.js +19 -0
  19. package/dist/room-subscription-mode.d.ts +5 -0
  20. package/dist/room-subscription-mode.js +6 -0
  21. package/dist/room-subscription.d.ts +15 -0
  22. package/dist/room-subscription.js +49 -0
  23. package/dist/synced-schema.d.ts +52 -0
  24. package/dist/synced-schema.js +92 -0
  25. package/dist/synced-store copy.d.ts +7 -0
  26. package/dist/synced-store copy.js +9 -0
  27. package/dist/synced-types.d.ts +45 -0
  28. package/dist/synced-types.js +72 -0
  29. package/dist/types/events.d.ts +1 -2
  30. package/dist/version.d.ts +1 -1
  31. package/dist/version.js +1 -1
  32. package/dist/ws-message/index.d.ts +3 -0
  33. package/dist/ws-message/index.js +3 -0
  34. package/dist/ws-message/ws-message-reader.d.ts +9 -0
  35. package/dist/ws-message/ws-message-reader.js +17 -0
  36. package/dist/ws-message/ws-message-type.d.ts +4 -0
  37. package/dist/ws-message/ws-message-type.js +5 -0
  38. package/dist/ws-message/ws-message-writer.d.ts +12 -0
  39. package/dist/ws-message/ws-message-writer.js +30 -0
  40. package/dist/ws-message-reader.d.ts +11 -0
  41. package/dist/ws-message-reader.js +36 -0
  42. package/dist/ws-message-type.d.ts +9 -0
  43. package/dist/ws-message-type.js +10 -0
  44. package/dist/ws-message-writer.d.ts +9 -0
  45. package/dist/ws-message-writer.js +45 -0
  46. package/dist/ws-message.d.ts +14 -0
  47. package/dist/ws-message.js +34 -0
  48. package/package.json +4 -4
@@ -0,0 +1,422 @@
1
+ import EventEmitter from "events";
2
+ import { KOKIMOKI_APP_VERSION } from "./version";
3
+ import * as Y from "yjs";
4
+ import { KokimokiTransaction } from "./kokimoki-transaction";
5
+ import { WsMessageType } from "./ws-message-type";
6
+ import { WsMessageWriter } from "./ws-message-writer";
7
+ import { WsMessageReader } from "./ws-message-reader";
8
+ import { RoomSubscriptionMode } from "./room-subscription-mode";
9
+ import { RoomSubscription } from "./room-subscription";
10
+ export class KokimokiClient extends EventEmitter {
11
+ host;
12
+ appId;
13
+ code;
14
+ _wsUrl;
15
+ _apiUrl;
16
+ _id;
17
+ _token;
18
+ _apiHeaders;
19
+ _serverTimeOffset = 0;
20
+ _clientContext;
21
+ _ws;
22
+ _subscriptionsByName = new Map();
23
+ _subscriptionsByHash = new Map();
24
+ _subscribeReqPromises = new Map();
25
+ _transactionPromises = new Map();
26
+ _connected = false;
27
+ _connectPromise;
28
+ _messageId = 0;
29
+ _reconnectTimeout = 0;
30
+ constructor(host, appId, code = "") {
31
+ super();
32
+ this.host = host;
33
+ this.appId = appId;
34
+ this.code = code;
35
+ // Set up the URLs
36
+ const secure = this.host.indexOf(":") === -1;
37
+ this._wsUrl = `ws${secure ? "s" : ""}://${this.host}`;
38
+ this._apiUrl = `http${secure ? "s" : ""}://${this.host}`;
39
+ }
40
+ get id() {
41
+ if (!this._id) {
42
+ throw new Error("Client not connected");
43
+ }
44
+ return this._id;
45
+ }
46
+ get token() {
47
+ if (!this._token) {
48
+ throw new Error("Client not connected");
49
+ }
50
+ return this._token;
51
+ }
52
+ get apiUrl() {
53
+ if (!this._apiUrl) {
54
+ throw new Error("Client not connected");
55
+ }
56
+ return this._apiUrl;
57
+ }
58
+ get apiHeaders() {
59
+ if (!this._apiHeaders) {
60
+ throw new Error("Client not connected");
61
+ }
62
+ return this._apiHeaders;
63
+ }
64
+ get clientContext() {
65
+ if (this._clientContext === undefined) {
66
+ throw new Error("Client not connected");
67
+ }
68
+ return this._clientContext;
69
+ }
70
+ get connected() {
71
+ return this._connected;
72
+ }
73
+ get ws() {
74
+ if (!this._ws) {
75
+ throw new Error("Not connected");
76
+ }
77
+ return this._ws;
78
+ }
79
+ async connect() {
80
+ if (this._connectPromise) {
81
+ return await this._connectPromise;
82
+ }
83
+ this._ws = new WebSocket(`${this._wsUrl}/apps/${this.appId}?clientVersion=${KOKIMOKI_APP_VERSION}`);
84
+ this._ws.binaryType = "arraybuffer";
85
+ // Close previous connection in hot-reload scenarios
86
+ if (window) {
87
+ if (!window.__KOKIMOKI_WS__) {
88
+ window.__KOKIMOKI_WS__ = {};
89
+ }
90
+ if (this.appId in window.__KOKIMOKI_WS__) {
91
+ window.__KOKIMOKI_WS__[this.appId].close();
92
+ }
93
+ window.__KOKIMOKI_WS__[this.appId] = this._ws;
94
+ }
95
+ // Wait for connection
96
+ this._connectPromise = new Promise((onInit) => {
97
+ // Fetch the auth token
98
+ let clientToken = localStorage.getItem("KM_TOKEN");
99
+ // Send the app token on first connect
100
+ this.ws.onopen = () => {
101
+ this.ws.send(JSON.stringify({ type: "auth", code: this.code, token: clientToken }));
102
+ };
103
+ this.ws.onclose = () => {
104
+ this._connected = false;
105
+ this._connectPromise = undefined;
106
+ this._ws = undefined;
107
+ if (window && window.__KOKIMOKI_WS__) {
108
+ delete window.__KOKIMOKI_WS__[this.appId];
109
+ }
110
+ // Clean up
111
+ this._subscribeReqPromises.clear();
112
+ this._transactionPromises.clear();
113
+ // Attempt to reconnect
114
+ console.log(`[Kokimoki] Connection lost, attempting to reconnect in ${this._reconnectTimeout} seconds...`);
115
+ setTimeout(async () => await this.connect(), this._reconnectTimeout * 1000);
116
+ this._reconnectTimeout = Math.min(3, this._reconnectTimeout + 1);
117
+ // Emit disconnected event
118
+ this.emit("disconnected");
119
+ };
120
+ this.ws.onmessage = (e) => {
121
+ console.log(`Received WS message: ${e.data}`);
122
+ // Handle JSON messages
123
+ if (typeof e.data === "string") {
124
+ const message = JSON.parse(e.data);
125
+ switch (message.type) {
126
+ case "serverTime": {
127
+ this._serverTimeOffset = Date.now() - message.serverTime;
128
+ console.log(`Server time offset: ${this._serverTimeOffset}`);
129
+ break;
130
+ }
131
+ case "init":
132
+ this.handleInitMessage(message);
133
+ onInit();
134
+ break;
135
+ }
136
+ return;
137
+ }
138
+ // Handle binary messages
139
+ this.handleBinaryMessage(e.data);
140
+ };
141
+ });
142
+ await this._connectPromise;
143
+ this._connected = true;
144
+ // Connection established
145
+ console.log(`[Kokimoki] Client id: ${this.id}`);
146
+ console.log(`[Kokimoki] Client context:`, this.clientContext);
147
+ // Restore subscriptions if reconnected
148
+ const roomNames = Array.from(this._subscriptionsByName.keys()).map((name) => `"${name}"`);
149
+ if (roomNames.length) {
150
+ console.log(`[Kokimoki] Restoring subscriptions: ${roomNames}`);
151
+ }
152
+ for (const subscription of this._subscriptionsByName.values()) {
153
+ try {
154
+ await this.join(subscription.store);
155
+ }
156
+ catch (err) {
157
+ console.error(`[Kokimoki] Failed to restore subscription for "${subscription.roomName}":`, err);
158
+ }
159
+ }
160
+ // Emit connected event
161
+ this._reconnectTimeout = 0;
162
+ this.emit("connected");
163
+ }
164
+ handleInitMessage(message) {
165
+ localStorage.setItem("KM_TOKEN", message.clientToken);
166
+ this._id = message.clientId;
167
+ this._token = message.appToken;
168
+ this._clientContext = message.clientContext;
169
+ // Set up the auth headers
170
+ this._apiHeaders = new Headers({
171
+ Authorization: `Bearer ${this.token}`,
172
+ "Content-Type": "application/json",
173
+ });
174
+ }
175
+ handleBinaryMessage(data) {
176
+ const reader = new WsMessageReader(data);
177
+ const type = reader.readInt32();
178
+ switch (type) {
179
+ case WsMessageType.SubscribeRes:
180
+ this.handleSubscribeResMessage(reader);
181
+ break;
182
+ case WsMessageType.RoomUpdate:
183
+ this.handleRoomUpdateMessage(reader);
184
+ break;
185
+ case WsMessageType.Error:
186
+ this.handleErrorMessage(reader);
187
+ break;
188
+ }
189
+ }
190
+ handleErrorMessage(msg) {
191
+ const reqId = msg.readInt32();
192
+ const error = msg.readString();
193
+ const subscribeReqPromise = this._subscribeReqPromises.get(reqId);
194
+ const transactionPromise = this._transactionPromises.get(reqId);
195
+ if (subscribeReqPromise) {
196
+ this._subscribeReqPromises.delete(reqId);
197
+ subscribeReqPromise.reject(error);
198
+ }
199
+ else if (transactionPromise) {
200
+ this._transactionPromises.delete(reqId);
201
+ transactionPromise.reject(error);
202
+ }
203
+ else {
204
+ console.warn(`Received error for unknown request ${reqId}: ${error}`);
205
+ }
206
+ }
207
+ handleSubscribeResMessage(msg) {
208
+ const reqId = msg.readInt32();
209
+ const roomHash = msg.readUint32();
210
+ const promise = this._subscribeReqPromises.get(reqId);
211
+ if (promise) {
212
+ this._subscribeReqPromises.delete(reqId);
213
+ // In Write mode, no initial state is sent
214
+ if (!msg.end) {
215
+ promise.resolve(roomHash, msg.readUint8Array());
216
+ }
217
+ else {
218
+ promise.resolve(roomHash);
219
+ }
220
+ }
221
+ }
222
+ handleRoomUpdateMessage(msg) {
223
+ const appliedId = msg.readInt32();
224
+ const roomHash = msg.readUint32();
225
+ // Apply update if not in Write mode
226
+ if (!msg.end) {
227
+ const subscription = this._subscriptionsByHash.get(roomHash);
228
+ if (subscription) {
229
+ Y.applyUpdate(subscription.store.doc, msg.readUint8Array(), this);
230
+ }
231
+ else {
232
+ console.warn(`Received update for unknown room ${roomHash}`);
233
+ }
234
+ }
235
+ // Check transaction resolves
236
+ for (const [transactionId, { resolve },] of this._transactionPromises.entries()) {
237
+ if (appliedId >= transactionId) {
238
+ this._transactionPromises.delete(transactionId);
239
+ resolve();
240
+ }
241
+ }
242
+ }
243
+ serverTimestamp() {
244
+ return Date.now() - this._serverTimeOffset;
245
+ }
246
+ // Send Y update to room
247
+ async patchRoomState(room, update) {
248
+ const res = await fetch(`${this._apiUrl}/rooms/${room}`, {
249
+ method: "PATCH",
250
+ headers: this.apiHeaders,
251
+ body: update,
252
+ });
253
+ return await res.json();
254
+ }
255
+ // Storage
256
+ async createUpload(name, blob, tags) {
257
+ const res = await fetch(`${this._apiUrl}/uploads`, {
258
+ method: "POST",
259
+ headers: this.apiHeaders,
260
+ body: JSON.stringify({
261
+ name,
262
+ size: blob.size,
263
+ mimeType: blob.type,
264
+ tags,
265
+ }),
266
+ });
267
+ return await res.json();
268
+ }
269
+ async uploadChunks(blob, chunkSize, signedUrls) {
270
+ return await Promise.all(signedUrls.map(async (url, index) => {
271
+ const start = index * chunkSize;
272
+ const end = Math.min(start + chunkSize, blob.size);
273
+ const chunk = blob.slice(start, end);
274
+ const res = await fetch(url, { method: "PUT", body: chunk });
275
+ return JSON.parse(res.headers.get("ETag") || '""');
276
+ }));
277
+ }
278
+ async completeUpload(id, etags) {
279
+ const res = await fetch(`${this._apiUrl}/uploads/${id}`, {
280
+ method: "PUT",
281
+ headers: this.apiHeaders,
282
+ body: JSON.stringify({ etags }),
283
+ });
284
+ return await res.json();
285
+ }
286
+ async upload(name, blob, tags = []) {
287
+ const { id, chunkSize, urls } = await this.createUpload(name, blob, tags);
288
+ const etags = await this.uploadChunks(blob, chunkSize, urls);
289
+ return await this.completeUpload(id, etags);
290
+ }
291
+ async updateUpload(id, update) {
292
+ const res = await fetch(`${this._apiUrl}/uploads/${id}`, {
293
+ method: "PUT",
294
+ headers: this.apiHeaders,
295
+ body: JSON.stringify(update),
296
+ });
297
+ return await res.json();
298
+ }
299
+ async listUploads(filter = {}, skip = 0, limit = 100) {
300
+ const url = new URL("/uploads", this._apiUrl);
301
+ url.searchParams.set("skip", skip.toString());
302
+ url.searchParams.set("limit", limit.toString());
303
+ if (filter.clientId) {
304
+ url.searchParams.set("clientId", filter.clientId);
305
+ }
306
+ if (filter.mimeTypes) {
307
+ url.searchParams.set("mimeTypes", filter.mimeTypes.join());
308
+ }
309
+ if (filter.tags) {
310
+ url.searchParams.set("tags", filter.tags.join());
311
+ }
312
+ const res = await fetch(url.href, {
313
+ headers: this.apiHeaders,
314
+ });
315
+ return await res.json();
316
+ }
317
+ async deleteUpload(id) {
318
+ const res = await fetch(`${this._apiUrl}/uploads/${id}`, {
319
+ method: "DELETE",
320
+ headers: this.apiHeaders,
321
+ });
322
+ return await res.json();
323
+ }
324
+ async exposeScriptingContext(context) {
325
+ // @ts-ignore
326
+ window.KM_SCRIPTING_CONTEXT = context;
327
+ // @ts-ignore
328
+ window.dispatchEvent(new CustomEvent("km:scriptingContextExposed"));
329
+ }
330
+ async sendSubscriptionReq(roomName, mode) {
331
+ // Set up sync resolver
332
+ const reqId = ++this._messageId;
333
+ return await new Promise((resolve, reject) => {
334
+ this._subscribeReqPromises.set(reqId, {
335
+ resolve: (roomHash, initialUpdate) => {
336
+ resolve({ roomHash, initialUpdate });
337
+ },
338
+ reject,
339
+ });
340
+ // Send subscription request
341
+ const msg = new WsMessageWriter();
342
+ msg.writeInt32(WsMessageType.SubscribeReq);
343
+ msg.writeInt32(reqId);
344
+ msg.writeString(roomName);
345
+ msg.writeChar(mode);
346
+ this.ws.send(msg.getBuffer());
347
+ });
348
+ }
349
+ async join(store, mode = RoomSubscriptionMode.ReadWrite) {
350
+ let subscription = this._subscriptionsByName.get(store.roomName);
351
+ if (!subscription) {
352
+ subscription = new RoomSubscription(this, store, mode);
353
+ this._subscriptionsByName.set(store.roomName, subscription);
354
+ }
355
+ // Send subscription request if connected to server
356
+ if (!subscription.joined) {
357
+ const res = await this.sendSubscriptionReq(store.roomName, mode);
358
+ this._subscriptionsByHash.set(res.roomHash, subscription);
359
+ await subscription.applyInitialResponse(res.roomHash, res.initialUpdate);
360
+ }
361
+ }
362
+ async transact(handler) {
363
+ if (!this._connected) {
364
+ throw new Error("Client not connected");
365
+ }
366
+ const transaction = new KokimokiTransaction(this);
367
+ handler(transaction);
368
+ const { updates, consumedMessages } = await transaction.getUpdates();
369
+ if (!updates.length) {
370
+ return;
371
+ }
372
+ // Construct buffer
373
+ const writer = new WsMessageWriter();
374
+ // Write message type
375
+ writer.writeInt32(WsMessageType.Transaction);
376
+ // Update and write transaction ID
377
+ const transactionId = ++this._messageId;
378
+ writer.writeInt32(transactionId);
379
+ // Write room hashes where messages were consumed
380
+ writer.writeInt32(consumedMessages.size);
381
+ for (const roomName of consumedMessages) {
382
+ const subscription = this._subscriptionsByName.get(roomName);
383
+ if (!subscription) {
384
+ throw new Error(`Cannot consume message in "${roomName}" because it hasn't been joined`);
385
+ }
386
+ writer.writeUint32(subscription.roomHash);
387
+ }
388
+ // Write updates
389
+ for (const { roomName, update } of updates) {
390
+ const subscription = this._subscriptionsByName.get(roomName);
391
+ if (!subscription) {
392
+ throw new Error(`Cannot send update to "${roomName}" because it hasn't been joined`);
393
+ }
394
+ writer.writeUint32(subscription.roomHash);
395
+ writer.writeUint8Array(update);
396
+ }
397
+ const buffer = writer.getBuffer();
398
+ // Wait for server to apply transaction
399
+ await new Promise((resolve, reject) => {
400
+ this._transactionPromises.set(transactionId, { resolve, reject });
401
+ // Send update to server
402
+ try {
403
+ this.ws.send(buffer);
404
+ }
405
+ catch (e) {
406
+ // Not connected
407
+ console.log("Failed to send update to server:", e);
408
+ // TODO: merge updates or something
409
+ throw e;
410
+ }
411
+ });
412
+ /* // Reset doc in Write-only mode
413
+ for (const { roomName, update } of updates) {
414
+ const mode = this._subscriptions.get(roomName);
415
+
416
+ if (mode === RoomSubscriptionMode.Write) {
417
+ // @ts-ignore
418
+ // this._stores.get(this._roomHashes.get(roomName)!)!.doc = new Y.Doc();
419
+ }
420
+ } */
421
+ }
422
+ }
@@ -1,12 +1,12 @@
1
- import { HocuspocusProvider } from "@hocuspocus/provider";
2
1
  import type TypedEmitter from "typed-emitter";
3
- import type { SyncedStore } from "./synced-store";
4
- import type { DocTypeDescription } from "@syncedstore/core/types/doc";
5
2
  import type { KokimokiClientEvents } from "./types/events";
6
3
  import type { Upload } from "./types/upload";
7
4
  import type { Paginated } from "./types/common";
8
- declare const KokimokiClient_base: new <T>() => TypedEmitter<KokimokiClientEvents<T>>;
9
- export declare class KokimokiClient<StatelessDataT = any, ClientContextT = any> extends KokimokiClient_base<StatelessDataT> {
5
+ import { KokimokiTransaction } from "./kokimoki-transaction";
6
+ import type { KokimokiStore } from "./kokimoki-store";
7
+ import type { KokimokiSchema as S } from "./kokimoki-schema";
8
+ declare const KokimokiClient_base: new () => TypedEmitter<KokimokiClientEvents>;
9
+ export declare class KokimokiClient<ClientContextT = any> extends KokimokiClient_base {
10
10
  readonly host: string;
11
11
  readonly appId: string;
12
12
  readonly code: string;
@@ -15,26 +15,34 @@ export declare class KokimokiClient<StatelessDataT = any, ClientContextT = any>
15
15
  private _id?;
16
16
  private _token?;
17
17
  private _apiHeaders?;
18
- private _providers;
19
18
  private _serverTimeOffset;
20
19
  private _clientContext?;
20
+ private _ws?;
21
+ private _subscriptionsByName;
22
+ private _subscriptionsByHash;
23
+ private _subscribeReqPromises;
24
+ private _transactionPromises;
21
25
  private _connected;
22
- private _lastPongAt;
26
+ private _connectPromise?;
27
+ private _messageId;
28
+ private _reconnectTimeout;
29
+ private _pingInterval;
23
30
  constructor(host: string, appId: string, code?: string);
24
31
  get id(): string;
25
32
  get token(): string;
26
33
  get apiUrl(): string;
27
34
  get apiHeaders(): Headers;
28
35
  get clientContext(): ClientContextT & ({} | null);
36
+ get connected(): boolean;
37
+ get ws(): WebSocket;
29
38
  connect(): Promise<void>;
39
+ private handleInitMessage;
40
+ private handleBinaryMessage;
41
+ private handleErrorMessage;
42
+ private handleSubscribeResMessage;
43
+ private handleRoomUpdateMessage;
30
44
  serverTimestamp(): number;
31
- private receivePong;
32
- private checkConnectionState;
33
- setProvider<T extends DocTypeDescription>(name: string, store: SyncedStore<T>): Promise<void>;
34
- removeProvider(name: string): void;
35
- getProvider(name: string): HocuspocusProvider | undefined;
36
- sendStatelessToClient(room: string, clientId: string, data: StatelessDataT): void;
37
- sendStatelessToRoom(room: string, data: StatelessDataT, self?: boolean): void;
45
+ patchRoomState(room: string, update: Uint8Array): Promise<any>;
38
46
  private createUpload;
39
47
  private uploadChunks;
40
48
  private completeUpload;
@@ -52,5 +60,8 @@ export declare class KokimokiClient<StatelessDataT = any, ClientContextT = any>
52
60
  deletedCount: number;
53
61
  }>;
54
62
  exposeScriptingContext(context: any): Promise<void>;
63
+ private sendSubscriptionReq;
64
+ join<T extends S.Generic<unknown>>(store: KokimokiStore<T>): Promise<void>;
65
+ transact(handler: (t: KokimokiTransaction) => void): Promise<void>;
55
66
  }
56
67
  export {};