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