• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

WolferyScripting / resclient-ts / #44

28 Aug 2025 08:26PM UTC coverage: 48.379% (-0.06%) from 48.441%
#44

push

DonovanDMC
1.1.9

230 of 292 branches covered (78.77%)

Branch coverage included in aggregate %.

1665 of 3625 relevant lines covered (45.93%)

9.87 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

33.17
/lib/models/ResClient.ts
1
import CacheItem from "./CacheItem.js";
1✔
2
import TypeList, { type ItemFactory } from "./TypeList.js";
1✔
3
import ResCollection from "./ResCollection.js";
1✔
4
import ResError from "./ResError.js";
1✔
5
import ResModel from "./ResModel.js";
1✔
6
import ResRef from "./ResRef.js";
1✔
7
import eventBus, { type EventBus } from "../includes/eventbus/index.js";
1✔
8
import {
1✔
9
    ACTION_DELETE,
1✔
10
    COLLECTION_TYPE,
1✔
11
    ERROR_TYPE,
1✔
12
    MODEL_TYPE,
1✔
13
    States,
1✔
14
    RESOURCE_TYPES,
1✔
15
    RECONNECT_DELAY,
1✔
16
    SUBSCRIBE_STALE_DELAY,
1✔
17
    ErrorCodes
1✔
18
} from "../Constants.js";
1✔
19
import type {
1✔
20
    AddEventData,
1✔
21
    RemoveEventData,
1✔
22
    ChangeEventData,
1✔
23
    ErrorData,
1✔
24
    Ref,
1✔
25
    Shared,
1✔
26
    RIDRef,
1✔
27
    Refs,
1✔
28
    AnyFunction,
1✔
29
    AnyObject,
1✔
30
    AnyRes,
1✔
31
    AnyClass
1✔
32
} from "../util/types.js";
1✔
33
import { Debug } from "../util/Debug.js";
1✔
34
import ensurePromiseReturn from "../util/ensurePromiseReturn.js";
1✔
35
import Properties from "../util/Properties.js";
1✔
36
import { lcsDiffAsync } from "../util/util.js";
1✔
37
import ProtocolHelper from "../util/ProtocolHelper.js";
1✔
38
import WebSocket, { type MessageEvent } from "ws";
1✔
39
import assert from "node:assert";
1✔
40

1✔
41
export type OnConnectFunction = (api: ResClient) => unknown;
1✔
42
export type OnConnectErrorFunction = (api: ResClient, err: unknown) => unknown;
1✔
43
export interface ClientOptions {
1✔
44
    defaultCollectionFactory?: ItemFactory<ResCollection>;
1✔
45
    defaultErrorFactory?: ItemFactory<ResError>;
1✔
46
    defaultModelFactory?: ItemFactory<ResModel>;
1✔
47
    eventBus?: EventBus;
1✔
48
    namespace?: string;
1✔
49
    onConnect?: OnConnectFunction;
1✔
50
    onConnectError?: OnConnectErrorFunction;
1✔
51
    protocol?: string;
1✔
52
    retryOnTooActive?: boolean;
1✔
53
}
1✔
54

1✔
55
export interface Request {
1✔
56
    method: string;
1✔
57
    params: unknown;
1✔
58
    reject(err: Error | ResError): void;
1✔
59
    resolve(value: unknown): void;
1✔
60
}
1✔
61

1✔
62
export function getRID(v: unknown): string | null {
1✔
63
    if (typeof v === "object" && v !== null && "getResourceID" in v && typeof v.getResourceID === "function") {
×
64
        return v.getResourceID() as string;
×
65
    }
×
66
    // checking for the rid property causes ResRef to be processed as a regular cacheItem in change events
×
67
    /* if ("rid" in v && typeof v.rid === "string") {
×
68
            return v.rid;
×
69
        } */
×
70

×
71
    return null;
×
72
}
×
73

1✔
74
export interface ResType<K extends string, T extends AnyRes = AnyRes, D = unknown> {
1✔
75
    id: K;
1✔
76
    list: TypeList<T>;
1✔
77
    getFactory(rid: string): ItemFactory<T>;
1✔
78
    prepareData(data: unknown): D;
1✔
79
    synchronize(cacheItem: CacheItem<T>, data: unknown): unknown;
1✔
80
}
1✔
81

1✔
82
export type AnyResType = ResClient["types"][keyof ResClient["types"]];
1✔
83
export default class ResClient {
1✔
84
    private onClose = this._onClose.bind(this);
17✔
85
    private onError = this._onError.bind(this);
17✔
86
    private onMessage = this._onMessage.bind(this);
17✔
87
    private onOpen = this._onOpen.bind(this);
17✔
88
    private unsubscribe = this._unsubscribe.bind(this);
17✔
89
    cache: Record<string, CacheItem> = {};
17✔
90
    connectCallback: { reject(err: ErrorData): void; resolve(): void; } | null = null;
17✔
91
    connectPromise: Promise<void> | null = null;
17✔
92
    connected = false;
17✔
93
    defaultCollectionFactory!: ItemFactory<ResCollection>;
17✔
94
    defaultErrorFactory!: ItemFactory<ResError>;
17✔
95
    defaultModelFactory!: ItemFactory<ResModel>;
17✔
96
    eventBus = eventBus;
17✔
97
    namespace = "resclient";
17✔
98
    onConnect: OnConnectFunction | null = null;
17✔
99
    onConnectError: OnConnectErrorFunction | null = null;
17✔
100
    protocol!: ProtocolHelper;
17✔
101
    requestID = 1;
17✔
102
    requests: Record<number, Request> = {};
17✔
103
    retryOnTooActive = false;
17✔
104
    stale: Record<string, boolean> | null = null;
17✔
105
    tryConnect = false;
17✔
106
    types = {
17✔
107
        collection: {
17✔
108
            id:          COLLECTION_TYPE,
17✔
109
            list:        new TypeList((api, rid) => this.defaultCollectionFactory(api, rid)),
17✔
110
            prepareData: (data: Array<unknown>): Array<unknown> => data.map(item => this._prepareValue(item as never, true)),
17✔
111
            getFactory(rid: string): ItemFactory<ResCollection> {
17✔
112
                return this.list.getFactory(rid);
×
113
            },
17✔
114
            synchronize: this._syncCollection.bind(this)
17✔
115
        } satisfies ResType<typeof COLLECTION_TYPE, ResCollection, Array<unknown>> as ResType<typeof COLLECTION_TYPE, ResCollection, Array<unknown>>,
17✔
116
        error: {
17✔
117
            id:          ERROR_TYPE,
17✔
118
            list:        new TypeList((api, rid) => this.defaultErrorFactory(api, rid)),
17✔
119
            prepareData: (data: unknown): unknown => data,
17✔
120
            getFactory(rid: string): ItemFactory<ResError> {
17✔
121
                return this.list.getFactory(rid);
×
122
            },
17✔
123
            // eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars
17✔
124
            synchronize(cacheItem: CacheItem<ResError>, data: Array<RIDRef>): void {}
17✔
125
        } satisfies ResType<typeof ERROR_TYPE, ResError, unknown> as ResType<typeof ERROR_TYPE, ResError, unknown>,
17✔
126
        model: {
17✔
127
            id:          MODEL_TYPE,
17✔
128
            list:        new TypeList((api, rid) => this.defaultModelFactory(api, rid)),
17✔
129
            prepareData: (data: AnyObject): AnyObject => {
17✔
130
                const obj = {} as AnyObject;
×
131
                // eslint-disable-next-line guard-for-in
×
132
                for (const key of Object.keys(data)) {
×
133
                    obj[key] = this._prepareValue(data[key] as never, true);
×
134
                }
×
135
                return obj;
×
136
            },
17✔
137
            getFactory(rid: string): ItemFactory<ResModel> {
17✔
138
                return this.list.getFactory(rid);
×
139
            },
17✔
140
            synchronize: this._syncModel.bind(this)
17✔
141
        } satisfies ResType<typeof MODEL_TYPE, ResModel, AnyObject> as ResType<typeof MODEL_TYPE, ResModel, AnyObject>
17✔
142
    };
17✔
143
    ws: WebSocket | null = null;
17✔
144
    wsFactory!: (() => WebSocket);
17✔
145
    constructor(hostUrlOrFactory: string | (() => WebSocket), options: ClientOptions = {}) {
17✔
146
        this.eventBus = options.eventBus || this.eventBus;
17✔
147
        if (options.eventBus !== undefined) {
17!
148
            this.eventBus = options.eventBus;
×
149
        }
×
150

17✔
151
        if (options.namespace !== undefined) {
17!
152
            this.namespace = options.namespace;
×
153
        }
×
154

17✔
155
        if (options.onConnect !== undefined) {
17!
156
            this.onConnect = options.onConnect;
×
157
        }
×
158

17✔
159
        if (options.onConnectError !== undefined) {
17!
160
            this.onConnectError = options.onConnectError;
×
161
        }
×
162

17✔
163
        if (options.retryOnTooActive !== undefined) {
17!
164
            this.retryOnTooActive = options.retryOnTooActive;
×
165
        }
×
166

17✔
167
        this.defaultCollectionFactory = options.defaultCollectionFactory ?? ((api: ResClient, rid: string): ResCollection => new ResCollection(api, rid));
17✔
168
        this.defaultErrorFactory = options.defaultErrorFactory ?? ((api: ResClient, rid: string): ResError => new ResError(api, rid));
17✔
169
        this.defaultModelFactory = options.defaultModelFactory ?? ((api: ResClient, rid: string): ResModel => new ResModel(api, rid));
17✔
170
        this.wsFactory = typeof hostUrlOrFactory === "string" ? (): WebSocket => new WebSocket(hostUrlOrFactory) : hostUrlOrFactory;
17!
171
        this.protocol = new ProtocolHelper();
17✔
172
        if (options.protocol) this.protocol.setClient(options.protocol);
17!
173

17✔
174
        if (!this.protocol.clientSupported) {
17!
175
            throw new Error(`Unsupported client protocol version: ${this.protocol.client}`);
×
176
        }
×
177

17✔
178
        Properties.of(this)
17✔
179
            .readOnlyBulk("cache", "eventBus", "onClose", "onError", "onMessage", "onOpen", "requests", "types", "unsubscribe", "wsFactory", "protocol")
17✔
180
            .writableBulk("connectCallback", "connected", "connectPromise", "namespace", "onConnect", "onConnectError", "requestID", "stale", "tryConnect", "ws");
17✔
181
    }
17✔
182

17✔
183
    private _addStale(rid: string): void {
17✔
184
        if (!this.stale) {
×
185
            this.stale = {};
×
186
        }
×
187
        this.stale[rid] = true;
×
188
    }
×
189

17✔
190
    private async _cacheResources(r: Shared): Promise<void> {
17✔
191
        if (!r || !(r.models || r.collections || r.errors)) {
×
192
            return;
×
193
        }
×
194

×
195
        const sync = {} as Record<typeof RESOURCE_TYPES[number], Record<string, Ref>>;
×
196
        const rr =  (t: typeof RESOURCE_TYPES[number]): Refs => ({ collection: r.collections, model: r.models, error: r.errors }[t]!);
×
197
        // eslint-disable-next-line unicorn/no-array-for-each
×
198
        RESOURCE_TYPES.forEach(t => (sync[t] = this._createItems(rr(t), this.types[t])! as never));
×
199
        // must be initialized in specific order
×
200
        for (const type of RESOURCE_TYPES) await this._initItems(rr(type), this.types[type]);
×
201
        for (const type of RESOURCE_TYPES) await this._listenItems(rr(type));
×
202
        for (const type of RESOURCE_TYPES) await this._syncItems(sync[type], this.types[type]);
×
203

×
204
    }
×
205

17✔
206
    private async _call<T = unknown>(type: string, rid: string, method?: string, params?: unknown): Promise<T> {
17✔
207
        return this._send<{ payload: T; rid?: string; }>(type, rid, method || "", params)
×
208
            .then(async result => {
×
209
                if (result.rid) {
×
210
                    await this._cacheResources(result as never);
×
211
                    const ci = this.cache[result.rid];
×
212
                    assert(ci, `Missing CacheItem (rid: ${result.rid})`);
×
213
                    ci.addSubscribed(1);
×
214
                    return ci.item as T;
×
215
                }
×
216
                return result.payload;
×
217
            });
×
218
    }
×
219

17✔
220
    private _connectReject(e: ErrorData & { data?: unknown; }): void {
17✔
221
        this.connectPromise = null;
×
222
        this.ws = null;
×
223

×
224
        if (this.connectCallback) {
×
225
            this.connectCallback.reject(e);
×
226
            this.connectCallback = null;
×
227
        }
×
228
    }
×
229

17✔
230
    private _connectResolve(): void {
17✔
231
        if (this.connectCallback) {
×
232
            this.connectCallback.resolve();
×
233
            this.connectCallback = null;
×
234
        }
×
235
    }
×
236

17✔
237
    private _createItems(refs: Refs, type: AnyResType): AnyObject | undefined {
17✔
238
        if (!refs) {
×
239
            return;
×
240
        }
×
241

×
242
        let sync: AnyObject | undefined;
×
243
        for (const rid of Object.keys(refs)) {
×
244
            let ci = this.cache[rid];
×
245
            if (ci) {
×
246
                // Remove item as stale if needed
×
247
                this._removeStale(rid);
×
248
            } else {
×
249
                ci = this.cache[rid] = CacheItem.createDefault(rid, this);
×
250
            }
×
251
            // If an item is already set,
×
252
            // it has gone stale and needs to be synchronized.
×
253
            if (ci.item) {
×
254
                if (ci.type === type.id) {
×
255
                    sync = sync || {};
×
256
                    sync[rid] = refs[rid];
×
257
                } else {
×
258
                    Debug("warn", "Resource type inconsistency", rid, ci.type, type.id);
×
259
                }
×
260
                delete refs[rid];
×
261
            } else {
×
262
                const f = type.getFactory(rid);
×
263
                ci.setItem(f(this, rid), type.id);
×
264
            }
×
265
        }
×
266

×
267
        return sync;
×
268
    }
×
269

17✔
270
    private _deleteRef(ci: CacheItem<AnyRes>): void {
17✔
271
        const item = ci.item;
×
272
        let ri: CacheItem | null = null;
×
273
        switch (ci.type) {
×
274
            case COLLECTION_TYPE: {
×
275
                for (const v of item as ResCollection) {
×
276
                    ri = this._getRefItem(v);
×
277
                    if (ri) {
×
278
                        ri.addIndirect(-1);
×
279
                    }
×
280
                }
×
281
                break;
×
282
            }
×
283
            case MODEL_TYPE: {
×
284
                for (const k in item) {
×
285
                    if (Object.hasOwn(item, k)) {
×
286
                        ri = this._getRefItem(item[k as never]);
×
287
                        if (ri) {
×
288
                            ri.addIndirect(-1);
×
289
                        }
×
290
                    }
×
291
                }
×
292
                break;
×
293
            }
×
294
        }
×
295

×
296
        delete this.cache[ci.rid];
×
297
        this._removeStale(ci.rid);
×
298
    }
×
299

17✔
300
    private _emit(event: string, data: unknown): void {
17✔
301
        this.eventBus.emit(this, event, data, this.namespace);
×
302
    }
×
303

17✔
304
    private _getRefItem(v: unknown): CacheItem | null {
17✔
305
        const rid = getRID(v);
×
306
        if (!rid) {
×
307
            return null;
×
308
        }
×
309
        const refItem = this.cache[rid];
×
310
        // refItem not in cache means
×
311
        // item has been deleted as part of
×
312
        // a refState object.
×
313
        if (!refItem) {
×
314
            return null;
×
315
        }
×
316
        return refItem;
×
317
    }
×
318

17✔
319
    private _getRefState(ci: CacheItem): AnyObject<Ref> {
17✔
320
        const refs = {} as AnyObject<Ref>;
17✔
321
        // Quick exit
17✔
322
        if (ci.subscribed) {
17✔
323
            return refs;
1✔
324
        }
1✔
325
        refs[ci.rid] = { ci, rc: ci.indirect, st: States.NONE };
16✔
326
        this._traverse(ci, this._seekRefs.bind(this, refs), 0, true);
16✔
327
        this._traverse(ci, this._markDelete.bind(this, refs) as never, States.DELETE);
16✔
328
        return refs;
16✔
329
    }
16✔
330

17✔
331
    private async _handleAddEvent(ci: CacheItem<ResCollection>, event: string, data: AddEventData): Promise<boolean> {
17✔
332
        if (ci.type !== COLLECTION_TYPE) {
×
333
            return false;
×
334
        }
×
335

×
336
        await this._cacheResources(data);
×
337
        const v = this._prepareValue(data.value, true);
×
338
        const idx = data.idx;
×
339

×
340
        ci.item.add(v, idx);
×
341
        this.eventBus.emit(ci.item, `${this.namespace}.resource.${ci.rid}.${event}`, { item: v, idx });
×
342
        return true;
×
343
    }
×
344

17✔
345
    private async _handleChangeEvent(cacheItem: CacheItem<ResModel>, event: string, data: ChangeEventData, reset: boolean): Promise<boolean> {
17✔
346
        if (cacheItem.type !== MODEL_TYPE) {
×
347
            return false;
×
348
        }
×
349

×
350
        await this._cacheResources(data);
×
351

×
352
        const item = cacheItem.item;
×
353
        let rid;
×
354
        const vals = data.values;
×
355
        for (const key of Object.keys(vals)) {
×
356
            vals[key] = this._prepareValue(vals[key]!) as string;
×
357
        }
×
358

×
359
        // Update the model with new values
×
360
        const changed = item.update(vals, reset);
×
361
        if (!changed) {
×
362
            return false;
×
363
        }
×
364

×
365
        // Used changed object to determine which resource references has been
×
366
        // added or removed.
×
367
        const ind: Record<string, number> = {};
×
368
        for (const key of Object.keys(changed)) {
×
369
            if ((rid = getRID(changed[key]))) {
×
370
                ind[rid] = (ind[rid] || 0) - 1;
×
371
            }
×
372
            if ((rid = getRID(vals[key]))) {
×
373
                ind[rid] = (ind[rid] || 0) + 1;
×
374
            }
×
375
        }
×
376

×
377
        // Remove indirect reference to resources no longer referenced in the model
×
378
        for (const [key, value] of Object.entries(ind)) {
×
379
            const ci = this.cache[key];
×
380
            assert(ci, `Missing CacheItem (rid: ${key})`);
×
381
            ci.addIndirect(value);
×
382
            if (value > 0) {
×
383
                this._tryDelete(ci);
×
384
            }
×
385
        }
×
386

×
387
        this.eventBus.emit(cacheItem.item, `${this.namespace}.resource.${cacheItem.rid}.${event}`, changed);
×
388
        return true;
×
389
    }
×
390

17✔
391
    private async _handleErrorResponse(req: Request, data: unknown): Promise<void> {
17✔
392
        const m = req.method;
×
393
        // Extract the rid if possible
×
394
        let rid = "";
×
395
        let i = m.indexOf(".");
×
396
        if (i >= 0) {
×
397
            rid = m.slice(i + 1);
×
398
            const a = m.slice(0, Math.max(0, i));
×
399
            if (a === "call" || a === "auth") {
×
400
                i = rid.lastIndexOf(".");
×
401
                if (i >= 0) {
×
402
                    rid = rid.slice(0, Math.max(0, i));
×
403
                }
×
404
            }
×
405
        }
×
406
        const errorData = (data as Record<"error", ErrorData & { data?: unknown; }>).error;
×
407
        if (this.retryOnTooActive && "code" in errorData && errorData.code === ErrorCodes.TOO_ACTIVE) {
×
408
            const seconds = (Number((errorData.data as { seconds: number; } | undefined)?.seconds) || 0) + 1;
×
409
            Debug("tooActive", `Got ${ErrorCodes.TOO_ACTIVE}, waiting ${seconds} second${seconds === 1 ? "" : "s"} to retry ${req.method}`);
×
410
            await new Promise(resolve => setTimeout(resolve, seconds * 1000)); // can't import timers/promises due to other non-async usages
×
411
            const r = await this._sendNow(req.method, req.params);
×
412
            req.resolve(r);
×
413
            return;
×
414
        } else {
×
415
            const err = await (new ResError(this, rid.trim(), m, req.params)).init(errorData);
×
416
            try {
×
417
                this._emit("error", err);
×
418
            } catch {}
×
419

×
420
            // Execute error callback bound to calling object
×
421
            req.reject(err);
×
422
        }
×
423
    }
×
424

17✔
425
    private async _handleEvent(data: { data: unknown; event: string; }): Promise<void> {
17✔
426
        // Event
×
427
        const index = data.event.lastIndexOf(".");
×
428
        if (index === -1 || index === data.event.length - 1) {
×
429
            throw new Error(`Malformed event name: ${data.event}`);
×
430
        }
×
431

×
432
        const rid = data.event.slice(0, Math.max(0, index));
×
433

×
434
        const cacheItem = this.cache[rid];
×
435
        if (!cacheItem?.item) {
×
436
            throw new Error("Resource not found in cache");
×
437
        }
×
438

×
439
        const event = data.event.slice(index + 1);
×
440
        let handled = false;
×
441
        switch (event) {
×
442
            case "change": {
×
443
                handled = await this._handleChangeEvent(cacheItem as CacheItem<ResModel>, event, data.data as ChangeEventData, false);
×
444
                break;
×
445
            }
×
446

×
447
            case "add": {
×
448
                handled = await this._handleAddEvent(cacheItem as CacheItem<ResCollection>, event, data.data as AddEventData);
×
449
                break;
×
450
            }
×
451

×
452
            case "remove": {
×
453
                handled = await this._handleRemoveEvent(cacheItem as CacheItem<ResCollection>, event, data.data as RemoveEventData);
×
454
                break;
×
455
            }
×
456

×
457
            case "unsubscribe": {
×
458
                handled = await this._handleUnsubscribeEvent(cacheItem);
×
459
                break;
×
460
            }
×
461
        }
×
462

×
463
        if (!handled) {
×
464
            this.eventBus.emit(cacheItem.item, `${this.namespace}.resource.${rid}.${event}`, data.data);
×
465
        }
×
466
    }
×
467

17✔
468
    private _handleFailedSubscribe(ci: CacheItem): void {
17✔
469
        ci.addSubscribed(-1);
×
470
        this._tryDelete(ci);
×
471
    }
×
472

17✔
473
    private async _handleRemoveEvent(ci: CacheItem<ResCollection>, event: string, data: RemoveEventData): Promise<boolean> {
17✔
474
        if (ci.type !== COLLECTION_TYPE) {
×
475
            return false;
×
476
        }
×
477

×
478
        const idx = data.idx;
×
479
        const item = ci.item.remove(idx);
×
480
        this.eventBus.emit(ci.item, `${this.namespace}.resource.${ci.rid}.${event}`, { item, idx });
×
481

×
482
        const rid = getRID(item);
×
483
        if (rid) {
×
484
            const refItem = this.cache[rid];
×
485
            if (!refItem) {
×
486
                throw new Error("Removed model is not in cache");
×
487
            }
×
488

×
489
            refItem.addIndirect(-1);
×
490
            this._tryDelete(refItem);
×
491
        }
×
492
        return true;
×
493
    }
×
494

17✔
495
    private async _handleSuccessResponse(req: Request, data: unknown): Promise<void> {
17✔
496
        req.resolve((data as Record<"result", unknown>).result);
×
497
    }
×
498

17✔
499
    private async _handleUnsubscribeEvent(ci: CacheItem): Promise<boolean> {
17✔
500
        await ci.item.dispose();
×
501
        ci.addSubscribed(0);
×
502
        this._tryDelete(ci);
×
503
        this.eventBus.emit(ci.item, `${this.namespace}.resource.${ci.rid}.unsubscribe`, { item: ci.item });
×
504
        return true;
×
505
    }
×
506

17✔
507
    private async _initItems(refs: Refs, type: AnyResType): Promise<void> {
17✔
508
        if (!refs) {
×
509
            return;
×
510
        }
×
511

×
512
        const promises: Array<Promise<AnyRes>> = [];
×
513
        for (const rid of Object.keys(refs)) {
×
514
            const cacheItem = this.cache[rid];
×
515
            assert(cacheItem, `Missing CacheItem (rid: ${rid})`);
×
516
            promises.push(cacheItem.item.init(type.prepareData(refs[rid] as never) as never));
×
517
        }
×
518
        await Promise.all(promises);
×
519
    }
×
520

17✔
521
    private async _listenItems(refs: Refs): Promise<void> {
17✔
522
        if (!refs) {
×
523
            return;
×
524
        }
×
525

×
526
        const promises: Array<Promise<void>> = [];
×
527
        for (const rid of Object.keys(refs)) {
×
528
            const cacheItem = this.cache[rid];
×
529
            assert(cacheItem, `Missing CacheItem (rid: ${rid})`);
×
530
            promises.push((cacheItem.item as unknown as { _listen(on: boolean): Promise<void>; })._listen(true));
×
531
        }
×
532
        await Promise.all(promises);
×
533
    }
×
534

17✔
535
    // @FIXME: this is a mess
17✔
536
    private _markDelete(refs: Record<string, Ref>, ci: CacheItem, state: unknown): unknown {
17✔
537
        // Quick exit if it is already subscribed
61✔
538
        if (ci.subscribed) {
61!
539
            return false;
×
540
        }
×
541

61✔
542
        const rid = ci.rid;
61✔
543
        const r = refs[rid]!;
61✔
544

61✔
545
        if (r.st === States.KEEP) {
61✔
546
            return false;
8✔
547
        }
8✔
548

53✔
549
        if (state === States.DELETE) {
61✔
550

38✔
551
            if (r.rc > 0) {
38✔
552
                r.st = States.KEEP;
7✔
553
                return rid;
7✔
554
            }
7✔
555

31✔
556
            if (r.st !== States.NONE) {
38✔
557
                return false;
2✔
558
            }
2✔
559

29✔
560
            if (r.ci.direct) {
38✔
561
                r.st = States.STALE;
5✔
562
                return rid;
5✔
563
            }
5✔
564

24✔
565
            r.st = States.DELETE;
24✔
566
            return States.DELETE;
24✔
567
        }
24✔
568

15✔
569
        // A stale item can never cover itself
15✔
570
        if (rid === state) {
61✔
571
            return false;
2✔
572
        }
2✔
573

13✔
574
        r.st = States.KEEP;
13✔
575
        return r.rc > 0 ? rid : state;
61!
576
    }
61✔
577

17✔
578
    private async _onClose(e: unknown): Promise<void> {
17✔
579
        if (typeof e === "object" && e !== null) {
×
580
            if ("message" in e) {
×
581
                Debug("ws", "ResClient close", ...[e.message, (e as { code?: string; }).code].filter(Boolean));
×
582
            } else if ("code" in e) {
×
583
                Debug("ws", "ResClient close", e.code);
×
584
            } else {
×
585
                Debug("ws", "ResClient close", e);
×
586
            }
×
587
        }
×
588
        this.connectPromise = null;
×
589
        this.ws = null;
×
590
        const wasConnected = this.connected;
×
591
        if (this.connected) {
×
592
            this.connected = false;
×
593

×
594
            // Set any subscribed item in cache to stale
×
595
            for (const rid of Object.keys(this.cache)) {
×
596
                const ci = this.cache[rid];
×
597
                assert(ci, `Missing CacheItem (rid: ${rid})`);
×
598
                if (ci.subscribed) {
×
599
                    ci.addSubscribed(0);
×
600
                    this._addStale(rid);
×
601
                    this._tryDelete(ci);
×
602
                }
×
603
            }
×
604

×
605
            this._emit("disconnect", e);
×
606
        }
×
607

×
608
        let hasStale = false;
×
609

×
610
        if (Object.keys(this.cache).length !== 0) {
×
611
            hasStale = true;
×
612
        }
×
613

×
614
        this.tryConnect = hasStale && this.tryConnect;
×
615

×
616
        if (this.tryConnect) {
×
617
            await this._reconnect(wasConnected);
×
618
        }
×
619
    }
×
620

17✔
621
    private async _onError(e: unknown): Promise<void> {
17✔
622
        Debug("ws", "ResClient error", e);
×
623
        this._connectReject({ code: ErrorCodes.CONNECTION_ERROR, message: "Connection error", data: e });
×
624
    }
×
625

17✔
626
    private async _onMessage(e: MessageEvent): Promise<void> {
17✔
627
        await this._receive((e as { data: string; }).data);
×
628
    }
×
629

17✔
630
    private async _onOpen(e: unknown): Promise<void> {
17✔
631
        Debug("ws", "ResClient open");
×
632
        let onConnectError: unknown = null;
×
633
        await this._sendNow<{ protocol: string; }>("version", { protocol: this.protocol.client })
×
634
            .then(ver => {
×
635
                if (ver.protocol) this.protocol.setServer(ver.protocol);
×
636
            })
×
637
            .then(async() => {
×
638
                if (!this.protocol.serverSupported) {
×
639
                    throw new Error(`Unsupported server protocol version: ${this.protocol.server}`);
×
640
                }
×
641
                if (this.onConnect) {
×
642
                    this.connected = true;
×
643
                    await ensurePromiseReturn(this.onConnect, null, this)
×
644
                        .catch(async(err: unknown) => {
×
645
                            if (this.onConnectError === null) {
×
646
                                onConnectError = err;
×
647
                            } else {
×
648
                                await ensurePromiseReturn(this.onConnectError, null, this, err)
×
649
                                    .then(() => {
×
650
                                        onConnectError = null;
×
651
                                    })
×
652
                                    .catch((onerr: unknown) => {
×
653
                                        onConnectError = onerr;
×
654
                                    });
×
655
                            }
×
656
                        });
×
657
                    this.connected = false;
×
658
                }
×
659
            })
×
660
            .then(async() => {
×
661
                this.connected = true;
×
662
                await this._subscribeToAllStale();
×
663
                this._emit("connect", e);
×
664
                this._connectResolve();
×
665
            })
×
666
            .catch(() => this.ws?.close())
×
667
            .then(() => {
×
668
                if (onConnectError !== null) {
×
669
                    throw onConnectError;
×
670
                }
×
671
            });
×
672
    }
×
673

17✔
674
    private async _patchDiff<T>(a: Array<T>, b: Array<T>, onKeep: (item: T, aIndex: number, bIndex: number, idx: number) => Promise<unknown>, onAdd: (item: T, aIndex: number, bIndex: number) => Promise<unknown>, onRemove: (item: T, aIndex: number, idx: number) => Promise<unknown>): Promise<void> {
17✔
675
        return lcsDiffAsync<T>(a, b, onKeep, onAdd, onRemove);
×
676
    }
×
677

17✔
678
    private _prepareValue(v: { action?: string; data?: unknown; rid?: string; soft?: boolean; } | string, addIndirect = false): unknown {
17✔
679
        let val: unknown = v;
×
680
        if (v !== null && typeof v === "object") {
×
681
            if (v.rid) {
×
682
                // Resource reference
×
683
                if (v.soft) {
×
684
                    // Soft reference
×
685
                    val = new ResRef(this, v.rid);
×
686
                } else {
×
687
                    // Non-soft reference
×
688
                    const ci = this.cache[v.rid];
×
689
                    assert(ci, `Missing CacheItem (rid: ${v.rid})`);
×
690
                    if (addIndirect) {
×
691
                        ci.addIndirect();
×
692
                    }
×
693
                    val = ci.item;
×
694
                }
×
695
            } else if (Object.hasOwn(v, "data")) {
×
696
                // Data value
×
697
                val = v.data;
×
698
            } else if (v.action === "delete") {
×
699
                val = undefined;
×
700
            } else {
×
701
                throw new Error("Invalid value: " + JSON.stringify(val));
×
702
            }
×
703
        }
×
704

×
705
        return val;
×
706
    }
×
707

17✔
708
    private async _receive(json: string): Promise<void> {
17✔
709
        const data = JSON.parse(json.trim()) as AnyObject;
×
710
        Debug("ws:receive", "<-", data);
×
711

×
712
        if (Object.hasOwn(data, "id")) {
×
713
            const id = data.id as number;
×
714

×
715
            // Find the stored request
×
716
            const req = this.requests[id];
×
717
            if (!req) {
×
718
                throw new Error("Server response without matching request");
×
719
            }
×
720

×
721
            delete this.requests[id];
×
722

×
723
            if (Object.hasOwn(data, "error")) {
×
724
                await this._handleErrorResponse(req, data);
×
725
            } else {
×
726
                await this._handleSuccessResponse(req, data);
×
727
            }
×
728
        } else if (Object.hasOwn(data, "event")) {
×
729
            await this._handleEvent(data as never);
×
730
        } else {
×
731
            throw new Error("Invalid message from server: " + json);
×
732
        }
×
733
    }
×
734

17✔
735
    private async _reconnect(noDelay = false): Promise<void> {
17✔
736
        if (noDelay) {
×
737
            await this.connect();
×
738
            return;
×
739
        }
×
740
        setTimeout(async() => {
×
741
            if (!this.tryConnect) {
×
742
                return;
×
743
            }
×
744

×
745
            await this.connect();
×
746
        }, RECONNECT_DELAY);
×
747
    }
×
748

17✔
749
    private _removeStale(rid: string): void {
17✔
750
        if (this.stale) {
×
751
            delete this.stale[rid];
×
752
            if (Object.keys(this.stale).length === 0) {
×
753
                this.stale = null;
×
754
            }
×
755
        }
×
756
    }
×
757

17✔
758
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
17✔
759
    private _seekRefs(refs: Record<string, Ref>, ci: CacheItem, state: States): boolean {
17✔
760
        // Quick exit if it is already subscribed
39✔
761
        if (ci.subscribed) {
39!
762
            return false;
×
763
        }
×
764

39✔
765
        const rid = ci.rid;
39✔
766
        const r = refs[rid];
39✔
767
        if (r) {
39✔
768
            r.rc--;
11✔
769
            return false;
11✔
770
        }
11✔
771

28✔
772
        refs[rid] = { ci, rc: ci.indirect - 1, st: States.NONE };
28✔
773
        return true;
28✔
774
    }
28✔
775

17✔
776
    private _send<T = unknown>(action: string, rid: string, method?: string, params?: unknown): Promise<T> {
17✔
777
        if (!rid) {
×
778
            throw new Error("Invalid resource ID");
×
779
        }
×
780

×
781
        if (method === "") {
×
782
            throw new Error("Invalid method");
×
783
        }
×
784

×
785
        const m = `${action}.${rid}${(method ? `.${method}` : "")}`;
×
786

×
787
        return this.connected
×
788
            ? this._sendNow<T>(m, params)
×
789
            : this.connect()
×
790
                .catch(async err => {
×
791
                    throw (await (new ResError(this, rid, m, params)).init(err as Error));
×
792
                })
×
793
                .then(() => this._sendNow<T>(m, params));
×
794
    }
×
795

17✔
796
    private _sendNow<T = unknown>(method: string, params?: unknown): Promise<T> {
17✔
797
        return new Promise<T>((resolve, reject) => {
×
798
            const req = { id: this.requestID++, method, params };
×
799

×
800
            this.requests[req.id] = {
×
801
                method,
×
802
                params: req.params,
×
803
                resolve,
×
804
                reject
×
805
            };
×
806

×
807
            const json = JSON.stringify(req);
×
808
            Debug("ws:send", "->", method, params);
×
809
            this.ws!.send(json);
×
810
        });
×
811
    }
×
812

17✔
813
    private _sendUnsubscribe(ci: CacheItem, count: number): void {
17✔
814
        this._send("unsubscribe", ci.rid, undefined, count > 1 ? { count } : null)
×
815
            .then(() => {
×
816
                ci.addSubscribed(-count);
×
817
                this._tryDelete(ci);
×
818
            })
×
819
            .catch(() => this._tryDelete(ci));
×
820
    }
×
821

17✔
822
    private _setStale(rid: string): void {
17✔
823
        this._addStale(rid);
×
824
        if (this.connected) {
×
825
            setTimeout(() => this._subscribeToStale(rid), SUBSCRIBE_STALE_DELAY);
×
826
        }
×
827
    }
×
828

17✔
829
    private async _subscribe(ci: CacheItem, throwError = false): Promise<void> {
17✔
830
        const rid = ci.rid;
×
831
        ci.addSubscribed(1);
×
832
        this._removeStale(rid);
×
833
        return this._send<Shared>("subscribe", rid)
×
834
            .then(response => this._cacheResources(response))
×
835
            .catch(async err => {
×
836
                if (throwError) {
×
837
                    this._handleFailedSubscribe(ci);
×
838
                    throw err;
×
839
                } else {
×
840
                    await this._handleUnsubscribeEvent(ci);
×
841
                }
×
842
            });
×
843
    }
×
844

17✔
845
    private async _subscribeReferred(ci: CacheItem): Promise<void> {
17✔
846
        const i = ci.subscribed;
×
847
        ci.subscribed = 0;
×
848
        const refs = this._getRefState(ci);
×
849
        ci.subscribed = i;
×
850

×
851
        for (const rid of Object.keys(refs)) {
×
852
            const r = refs[rid]!;
×
853
            if (r.st === States.STALE) {
×
854
                await this._subscribe(r.ci);
×
855
            }
×
856
        }
×
857
    }
×
858

17✔
859
    private async _subscribeToAllStale(): Promise<void> {
17✔
860
        if (!this.stale) {
×
861
            return;
×
862
        }
×
863

×
864
        for (const rid of Object.keys(this.stale)) {
×
865
            await this._subscribeToStale(rid);
×
866
        }
×
867
    }
×
868

17✔
869
    private async _subscribeToStale(rid: string): Promise<void> {
17✔
870
        if (!this.connected || !this.stale || !this.stale[rid]) {
×
871
            return;
×
872
        }
×
873

×
874
        const ci = this.cache[rid];
×
875
        assert(ci, `Missing CacheItem (rid: ${rid})`);
×
876
        return this._subscribe(ci);
×
877
    }
×
878

17✔
879
    private async _syncCollection(cacheItem: CacheItem<ResCollection>, data: Array<RIDRef>): Promise<void> {
17✔
880
        const collection = cacheItem.item;
×
881
        let i = collection.length;
×
882
        const a = Array.from({ length: i });
×
883
        while (i--) {
×
884
            a[i] = collection.at(i);
×
885
        }
×
886

×
887
        const b = data.map(v => this._prepareValue(v as never));
×
888
        await this._patchDiff<unknown>(a, b,
×
889
            async () => {},
×
890
            async (id: unknown, n: number, idx: number) => this._handleAddEvent(cacheItem, "add", { value: data[n]!, idx }),
×
891
            async (id: unknown, m: number, idx: number) => this._handleRemoveEvent(cacheItem, "remove", { idx })
×
892
        );
×
893
    }
×
894

17✔
895
    private async _syncItems(refs: AnyObject, type: AnyResType): Promise<void> {
17✔
896
        if (!refs) {
×
897
            return;
×
898
        }
×
899

×
900
        for (const rid of Object.keys((refs))) {
×
901
            const ci = this.cache[rid];
×
902
            await type.synchronize(ci as never, refs[rid] as never);
×
903
        }
×
904
    }
×
905

17✔
906
    private async _syncModel(ci: CacheItem<ResModel>, data: ChangeEventData["values"]): Promise<void> {
17✔
907
        await this._handleChangeEvent(ci, "change", { values: data }, true);
×
908
    }
×
909

17✔
910
    private _traverse(ci: CacheItem, cb: (ci: CacheItem, state: States) => States | boolean, state: States, skipFirst = false): void {
17✔
911
        // Call callback to get new state to pass to
116✔
912
        // children. If false, we should not traverse deeper
116✔
913
        if (!skipFirst) {
116✔
914
            const s = cb(ci, state);
100✔
915
            if (s === false) {
100✔
916
                return;
23✔
917
            } else {
100✔
918
                state = s as States;
77✔
919
            }
77✔
920
        }
100✔
921

93✔
922
        const item = ci.item;
93✔
923
        switch (ci.type) {
93✔
924
            case COLLECTION_TYPE: {
93✔
925
                for (const v of item as ResCollection) {
93✔
926
                    const cii = this._getRefItem(v);
84✔
927
                    if (cii) {
84✔
928
                        this._traverse(cii, cb, state);
84✔
929
                    }
84✔
930
                }
84✔
931
                break;
93✔
932
            }
93✔
933
            case MODEL_TYPE: {
116!
934
                for (const k in item) {
×
935
                    if (Object.hasOwn(item, k)) {
×
936
                        const cii = this._getRefItem(item[k as never]);
×
937
                        if (cii) {
×
938
                            this._traverse(cii, cb, state);
×
939
                        }
×
940
                    }
×
941
                }
×
942
                break;
×
943
            }
×
944
        }
116✔
945
    }
116✔
946

17✔
947
    private _tryDelete(ci: CacheItem): void {
17✔
948
        const refs = this._getRefState(ci);
×
949

×
950
        for (const rid of Object.keys(refs)) {
×
951
            const r = refs[rid]!;
×
952
            switch (r.st) {
×
953
                case States.STALE: {
×
954
                    this._setStale(rid);
×
955
                    break;
×
956
                }
×
957
                case States.DELETE: {
×
958
                    this._deleteRef(r.ci as never);
×
959
                    break;
×
960
                }
×
961
            }
×
962
        }
×
963
    }
×
964

17✔
965
    private async _unsubscribe(ci: CacheItem): Promise<void> {
17✔
966
        if (!ci.subscribed) {
×
967
            if (this.stale && this.stale[ci.rid]) {
×
968
                this._tryDelete(ci);
×
969
            }
×
970
            return;
×
971
        }
×
972

×
973
        await this._subscribeReferred(ci);
×
974

×
975
        let i = ci.subscribed;
×
976
        if (this.protocol.unsubscribeCountSupported) {
×
977
            this._sendUnsubscribe(ci, i);
×
978
        } else {
×
979
            while (i--) {
×
980
                this._sendUnsubscribe(ci, 1);
×
981
            }
×
982
        }
×
983
    }
×
984

17✔
985
    authenticate<T = unknown>(rid: string, method: string, params: unknown): Promise<T> {
17✔
986
        return this._call<T>("auth", rid, method, params);
×
987
    }
×
988

17✔
989
    call<T = unknown>(rid: string, method: string, params?: unknown): Promise<T> {
17✔
990
        return this._call<T>("call", rid, method, params);
×
991
    }
×
992

17✔
993
    connect(): Promise<void> {
17✔
994
        this.tryConnect = true;
×
995
        if (!this.connectPromise) {
×
996
            this.connectPromise = new Promise<void>((resolve, reject) => {
×
997
                this.connectCallback = { resolve, reject };
×
998
                this.ws = this.wsFactory();
×
999

×
1000
                /* eslint-disable unicorn/prefer-add-event-listener */
×
1001
                this.ws.onopen = this.onOpen;
×
1002
                this.ws.onerror = this.onError;
×
1003
                this.ws.onmessage = this.onMessage;
×
1004
                this.ws.onclose = this.onClose;
×
1005
                /* eslint-enable unicorn/prefer-add-event-listener */
×
1006
            });
×
1007
            this.connectPromise.catch(err => this._emit("connectError", err));
×
1008
        }
×
1009

×
1010
        return this.connectPromise;
×
1011
    }
×
1012

17✔
1013
    async create(rid: string, params: unknown): Promise<ResModel | ResError | ResCollection> {
17✔
1014
        return this._send<Shared>("new", rid, undefined, params)
×
1015
            .then(async result => {
×
1016
                await this._cacheResources(result);
×
1017
                const _rid = (result as { rid: string; }).rid;
×
1018
                const ci = this.cache[rid];
×
1019
                assert(ci, `Missing CacheItem (rid: ${_rid})`);
×
1020
                ci.addSubscribed(1);
×
1021
                return ci.item;
×
1022
            });
×
1023
    }
×
1024

17✔
1025
    async disconnect(): Promise<void> {
17✔
1026
        this.tryConnect = false;
×
1027
        const err = { code: ErrorCodes.DISCONNECT, message: "Disconnect called" };
×
1028
        const resErr = new ResError(this, "disconnect", undefined, err);
×
1029

×
1030
        const req = Object.values(this.requests);
×
1031
        if (req.length !== 0) {
×
1032
            for (const r of req) r.reject(resErr);
×
1033
        }
×
1034
        if (this.ws) {
×
1035
            const ws = this.ws;
×
1036
            ws.removeEventListener("close", this.onClose);
×
1037
            await this.onClose(resErr);
×
1038
            ws.close();
×
1039
            this._connectReject(err);
×
1040
        }
×
1041
    }
×
1042

17✔
1043
    async get<T extends AnyRes = AnyRes>(rid: string, forceKeep = false): Promise<T> {
17✔
1044
        Debug("client:get", `${rid}${forceKeep ? " (keep)" : ""}`);
×
1045
        return this.subscribe(rid, forceKeep).then(() => this.getCached<T>(rid)!);
×
1046
    }
×
1047

17✔
1048
    getCached<T extends AnyRes = AnyRes>(rid: string): T | null {
17✔
1049
        Debug("client:getCached", rid);
×
1050
        return this.cache[rid]?.item as T ?? null;
×
1051
    }
×
1052

17✔
1053
    async getPaginated<T extends ResModel = ResModel>(rid: string, offset: number, limit: number): Promise<Array<T>> {
17✔
1054
        rid = `${rid}?offset=${offset}&limit=${limit}`;
×
1055
        Debug("client:getPaginated", rid);
×
1056
        const ci = CacheItem.createDefault(rid, this);
×
1057
        this.cache[rid] = ci;
×
1058
        await ci.setPromise(this._subscribe(ci, true));
×
1059
        const item = ci.item as unknown as ResCollection | ResModel;
×
1060
        let items: Array<T>;
×
1061
        if (item instanceof ResModel) {
×
1062
            items = Object.values(item.props as Record<string, T>);
×
1063
        } else if (item instanceof ResCollection) {
×
1064
            items = item.list as Array<T>;
×
1065
        } else {
×
1066
            assert(false, `Invalid resource type for paginated request: ${(item as AnyClass).constructor.name}`);
×
1067
        }
×
1068
        ci.unsubscribe();
×
1069
        return items;
×
1070
    }
×
1071

17✔
1072
    keepCached(item: CacheItem, cb = false): void {
17✔
1073
        Debug("client:keepCached", item.rid);
×
1074
        if (item.forceKeep) return;
×
1075
        if (!cb) item.keep();
×
1076
        item.resetTimeout();
×
1077
    }
×
1078

17✔
1079
    off(handler: AnyFunction): this;
17✔
1080
    off(events: string | Array<string> | null, handler: AnyFunction): this;
17✔
1081
    off(...args: [string | Array<string> | null, AnyFunction] | [AnyFunction]): this {
17✔
1082
        this.eventBus.off(this, args.length === 1 ? null : args[0], args.at(-1) as AnyFunction, this.namespace);
×
1083
        return this;
×
1084
    }
×
1085

17✔
1086
    on(handler: AnyFunction): this;
17✔
1087
    on(events: string | Array<string> | null, handler: AnyFunction): this;
17✔
1088
    on(...args: [string | Array<string> | null, AnyFunction] | [AnyFunction]): this {
17✔
1089
        this.eventBus.on(this, args.length === 1 ? null : args[0], args.at(-1) as AnyFunction, this.namespace);
×
1090
        return this;
×
1091
    }
×
1092

17✔
1093
    registerCollectionType(pattern: string, factory: ItemFactory<ResCollection>): this {
17✔
1094
        this.types.collection.list.addFactory(pattern, factory);
×
1095
        return this;
×
1096
    }
×
1097

17✔
1098
    registerModelType(pattern: string, factory: ItemFactory<ResModel>): this {
17✔
1099
        this.types.model.list.addFactory(pattern, factory);
×
1100
        return this;
×
1101
    }
×
1102

17✔
1103
    resourceOff(rid: string, events: string | Array<string> | null, handler: AnyFunction): void {
17✔
1104
        Debug("client:resourceOff", `${rid} ${events === null ? "all" : (Array.isArray(events) ? events.join(", ") : events)}`);
×
1105
        const cacheItem = this.cache[rid];
×
1106
        if (!cacheItem?.item) {
×
1107
            throw new Error(`Resource ${rid} not found in cache`);
×
1108
        }
×
1109

×
1110
        cacheItem.removeDirect();
×
1111
        this.eventBus.off(cacheItem.item, events, handler, `${this.namespace}.resource.${rid}`);
×
1112
    }
×
1113

17✔
1114
    resourceOn(rid: string, events: string | Array<string> | null, handler: AnyFunction): void {
17✔
1115
        Debug("client:resourceOn", `${rid} ${events === null ? "all" : (Array.isArray(events) ? events.join(", ") : events)}`);
×
1116
        const cacheItem = this.cache[rid];
×
1117
        if (!cacheItem?.item) {
×
1118
            throw new Error(`Resource ${rid} not found in cache`);
×
1119
        }
×
1120

×
1121
        cacheItem.addDirect();
×
1122
        this.eventBus.on(cacheItem.item, events, handler, `${this.namespace}.resource.${rid}`);
×
1123
    }
×
1124

17✔
1125
    // TODO: needs better typing
17✔
1126
    setModel(modelId: string, props: AnyObject): Promise<unknown> {
17✔
1127
        props = { ...props };
×
1128
        // Replace undefined with actionDelete object
×
1129
        for (const k of Object.keys(props)) {
×
1130
            if (props[k] === undefined) {
×
1131
                props[k] = ACTION_DELETE;
×
1132
            }
×
1133
        }
×
1134

×
1135
        return this._send("call", modelId, "set", props);
×
1136
    }
×
1137

17✔
1138
    setOnConnect(onConnect: OnConnectFunction | null, onConnectError?: OnConnectErrorFunction | null): this {
17✔
1139
        this.onConnect = onConnect;
×
1140
        if (onConnectError !== undefined) {
×
1141
            this.onConnectError = onConnectError;
×
1142
        }
×
1143
        return this;
×
1144
    }
×
1145

17✔
1146
    async subscribe(rid: string, forceKeep = false): Promise<void> {
17✔
1147
        Debug("client:subscribe", `${rid}${forceKeep ? " (keep)" : ""}`);
×
1148
        let ci = this.cache[rid];
×
1149
        if (ci) {
×
1150
            if (ci.promise) await ci.promise;
×
1151
            ci.resetTimeout();
×
1152
            if (forceKeep) ci.keep();
×
1153
            return;
×
1154
        }
×
1155

×
1156
        ci = CacheItem.createDefault(rid, this);
×
1157
        this.cache[rid] = ci;
×
1158
        if (forceKeep) ci.keep();
×
1159
        return ci.setPromise(this._subscribe(ci, true));
×
1160
    }
×
1161

17✔
1162
    unkeepCached(item: CacheItem, cb = false): void {
17✔
1163
        Debug("client:unkeepCached", item.rid);
×
1164
        if (!item.forceKeep) return;
×
1165
        if (!cb) item.unkeep();
×
1166
        item.resetTimeout();
×
1167
    }
×
1168

17✔
1169
    unregisterCollectionType(pattern: string): this {
17✔
1170
        this.types.collection.list.removeFactory(pattern);
×
1171
        return this;
×
1172
    }
×
1173

17✔
1174
    unregisterModelType(pattern: string): this {
17✔
1175
        this.types.model.list.removeFactory(pattern);
×
1176
        return this;
×
1177
    }
×
1178
}
17✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc