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

WolferyScripting / resclient-ts / #59

05 Sep 2025 04:51AM UTC coverage: 48.432% (+0.1%) from 48.309%
#59

push

DonovanDMC
1.1.16

230 of 292 branches covered (78.77%)

Branch coverage included in aggregate %.

1670 of 3631 relevant lines covered (45.99%)

9.84 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
    /** If lists (`_list`) in collections should be enumerable. */
1✔
48
    enumerableLists?: boolean;
1✔
49
    eventBus?: EventBus;
1✔
50
    namespace?: string;
1✔
51
    onConnect?: OnConnectFunction;
1✔
52
    onConnectError?: OnConnectErrorFunction;
1✔
53
    protocol?: string;
1✔
54
    retryOnTooActive?: boolean;
1✔
55
}
1✔
56

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

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

×
73
    return null;
×
74
}
×
75

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

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

17✔
156
        this.defaultCollectionFactory = options.defaultCollectionFactory ?? ((api: ResClient, rid: string): ResCollection => new ResCollection(api, rid));
17✔
157
        this.defaultErrorFactory = options.defaultErrorFactory ?? ((api: ResClient, rid: string): ResError => new ResError(api, rid));
17✔
158
        this.defaultModelFactory = options.defaultModelFactory ?? ((api: ResClient, rid: string): ResModel => new ResModel(api, rid));
17✔
159
        this.wsFactory = typeof hostUrlOrFactory === "string" ? (): WebSocket => new WebSocket(hostUrlOrFactory) : hostUrlOrFactory;
17!
160
        this.protocol = new ProtocolHelper();
17✔
161
        if (options.protocol) this.protocol.setClient(options.protocol);
17!
162

17✔
163
        if (!this.protocol.clientSupported) {
17!
164
            throw new Error(`Unsupported client protocol version: ${this.protocol.client}`);
×
165
        }
×
166

17✔
167
        Properties.of(this)
17✔
168
            .readOnlyBulk("cache", "eventBus", "onClose", "onError", "onMessage", "onOpen", "requests", "types", "unsubscribe", "wsFactory", "protocol")
17✔
169
            .writableBulk("connectCallback", "connected", "connectPromise", "namespace", "onConnect", "onConnectError", "requestID", "stale", "tryConnect", "ws");
17✔
170
    }
17✔
171

17✔
172
    private _addStale(rid: string): void {
17✔
173
        if (!this.stale) {
×
174
            this.stale = {};
×
175
        }
×
176
        this.stale[rid] = true;
×
177
    }
×
178

17✔
179
    private async _cacheResources(r: Shared): Promise<void> {
17✔
180
        if (!r || !(r.models || r.collections || r.errors)) {
×
181
            return;
×
182
        }
×
183

×
184
        const sync = {} as Record<typeof RESOURCE_TYPES[number], Record<string, Ref>>;
×
185
        const rr =  (t: typeof RESOURCE_TYPES[number]): Refs => ({ collection: r.collections, model: r.models, error: r.errors }[t]!);
×
186
        // eslint-disable-next-line unicorn/no-array-for-each
×
187
        RESOURCE_TYPES.forEach(t => (sync[t] = this._createItems(rr(t), this.types[t])! as never));
×
188
        // must be initialized in specific order
×
189
        for (const type of RESOURCE_TYPES) await this._initItems(rr(type), this.types[type]);
×
190
        for (const type of RESOURCE_TYPES) await this._listenItems(rr(type));
×
191
        for (const type of RESOURCE_TYPES) await this._syncItems(sync[type], this.types[type]);
×
192

×
193
    }
×
194

17✔
195
    private async _call<T = unknown>(type: string, rid: string, method?: string, params?: unknown): Promise<T> {
17✔
196
        return this._send<{ payload: T; rid?: string; }>(type, rid, method || "", params)
×
197
            .then(async result => {
×
198
                if (result.rid) {
×
199
                    await this._cacheResources(result as never);
×
200
                    const ci = this.cache[result.rid];
×
201
                    assert(ci, `Missing CacheItem (rid: ${result.rid})`);
×
202
                    ci.addSubscribed(1);
×
203
                    return ci.item as T;
×
204
                }
×
205
                return result.payload;
×
206
            });
×
207
    }
×
208

17✔
209
    private _connectReject(e: ErrorData & { data?: unknown; }): void {
17✔
210
        this.connectPromise = null;
×
211
        this.ws = null;
×
212

×
213
        if (this.connectCallback) {
×
214
            this.connectCallback.reject(e);
×
215
            this.connectCallback = null;
×
216
        }
×
217
    }
×
218

17✔
219
    private _connectResolve(): void {
17✔
220
        if (this.connectCallback) {
×
221
            this.connectCallback.resolve();
×
222
            this.connectCallback = null;
×
223
        }
×
224
    }
×
225

17✔
226
    private _createItems(refs: Refs, type: AnyResType): AnyObject | undefined {
17✔
227
        if (!refs) {
×
228
            return;
×
229
        }
×
230

×
231
        let sync: AnyObject | undefined;
×
232
        for (const rid of Object.keys(refs)) {
×
233
            let ci = this.cache[rid];
×
234
            if (ci) {
×
235
                // Remove item as stale if needed
×
236
                this._removeStale(rid);
×
237
            } else {
×
238
                ci = this.cache[rid] = CacheItem.createDefault(rid, this);
×
239
            }
×
240
            // If an item is already set,
×
241
            // it has gone stale and needs to be synchronized.
×
242
            if (ci.item) {
×
243
                if (ci.type === type.id) {
×
244
                    sync = sync || {};
×
245
                    sync[rid] = refs[rid];
×
246
                } else {
×
247
                    Debug("warn", "Resource type inconsistency", rid, ci.type, type.id);
×
248
                }
×
249
                delete refs[rid];
×
250
            } else {
×
251
                const f = type.getFactory(rid);
×
252
                ci.setItem(f(this, rid, refs[rid] as never), type.id);
×
253
            }
×
254
        }
×
255

×
256
        return sync;
×
257
    }
×
258

17✔
259
    private _deleteRef(ci: CacheItem<AnyRes>): void {
17✔
260
        const item = ci.item;
×
261
        let ri: CacheItem | null = null;
×
262
        switch (ci.type) {
×
263
            case COLLECTION_TYPE: {
×
264
                for (const v of item as ResCollection) {
×
265
                    ri = this._getRefItem(v);
×
266
                    if (ri) {
×
267
                        ri.addIndirect(-1);
×
268
                    }
×
269
                }
×
270
                break;
×
271
            }
×
272
            case MODEL_TYPE: {
×
273
                for (const k in item) {
×
274
                    if (Object.hasOwn(item, k)) {
×
275
                        ri = this._getRefItem(item[k as never]);
×
276
                        if (ri) {
×
277
                            ri.addIndirect(-1);
×
278
                        }
×
279
                    }
×
280
                }
×
281
                break;
×
282
            }
×
283
        }
×
284

×
285
        delete this.cache[ci.rid];
×
286
        this._removeStale(ci.rid);
×
287
    }
×
288

17✔
289
    private _emit(event: string, data: unknown): void {
17✔
290
        this.eventBus.emit(this, event, data, this.namespace);
×
291
    }
×
292

17✔
293
    private _getRefItem(v: unknown): CacheItem | null {
17✔
294
        const rid = getRID(v);
×
295
        if (!rid) {
×
296
            return null;
×
297
        }
×
298
        const refItem = this.cache[rid];
×
299
        // refItem not in cache means
×
300
        // item has been deleted as part of
×
301
        // a refState object.
×
302
        if (!refItem) {
×
303
            return null;
×
304
        }
×
305
        return refItem;
×
306
    }
×
307

17✔
308
    private _getRefState(ci: CacheItem): AnyObject<Ref> {
17✔
309
        const refs = {} as AnyObject<Ref>;
17✔
310
        // Quick exit
17✔
311
        if (ci.subscribed) {
17✔
312
            return refs;
1✔
313
        }
1✔
314
        refs[ci.rid] = { ci, rc: ci.indirect, st: States.NONE };
16✔
315
        this._traverse(ci, this._seekRefs.bind(this, refs), 0, true);
16✔
316
        this._traverse(ci, this._markDelete.bind(this, refs) as never, States.DELETE);
16✔
317
        return refs;
16✔
318
    }
16✔
319

17✔
320
    private async _handleAddEvent(ci: CacheItem<ResCollection>, event: string, data: AddEventData): Promise<boolean> {
17✔
321
        if (ci.type !== COLLECTION_TYPE) {
×
322
            return false;
×
323
        }
×
324

×
325
        await this._cacheResources(data);
×
326
        const v = this._prepareValue(data.value, true);
×
327
        const idx = data.idx;
×
328

×
329
        ci.item.add(v, idx);
×
330
        this.eventBus.emit(ci.item, `${this.namespace}.resource.${ci.rid}.${event}`, { item: v, idx });
×
331
        return true;
×
332
    }
×
333

17✔
334
    private async _handleChangeEvent(cacheItem: CacheItem<ResModel>, event: string, data: ChangeEventData, reset: boolean): Promise<boolean> {
17✔
335
        if (cacheItem.type !== MODEL_TYPE) {
×
336
            return false;
×
337
        }
×
338

×
339
        await this._cacheResources(data);
×
340

×
341
        const item = cacheItem.item;
×
342
        let rid;
×
343
        const vals = data.values;
×
344
        for (const key of Object.keys(vals)) {
×
345
            vals[key] = this._prepareValue(vals[key]!) as string;
×
346
        }
×
347

×
348
        // Update the model with new values
×
349
        const changed = item.update(vals, reset);
×
350
        if (!changed) {
×
351
            return false;
×
352
        }
×
353

×
354
        // Used changed object to determine which resource references has been
×
355
        // added or removed.
×
356
        const ind: Record<string, number> = {};
×
357
        for (const key of Object.keys(changed)) {
×
358
            if ((rid = getRID(changed[key]))) {
×
359
                ind[rid] = (ind[rid] || 0) - 1;
×
360
            }
×
361
            if ((rid = getRID(vals[key]))) {
×
362
                ind[rid] = (ind[rid] || 0) + 1;
×
363
            }
×
364
        }
×
365

×
366
        // Remove indirect reference to resources no longer referenced in the model
×
367
        for (const [key, value] of Object.entries(ind)) {
×
368
            const ci = this.cache[key];
×
369
            assert(ci, `Missing CacheItem (rid: ${key})`);
×
370
            ci.addIndirect(value);
×
371
            if (value > 0) {
×
372
                this._tryDelete(ci);
×
373
            }
×
374
        }
×
375

×
376
        this.eventBus.emit(cacheItem.item, `${this.namespace}.resource.${cacheItem.rid}.${event}`, changed);
×
377
        return true;
×
378
    }
×
379

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

×
409
            // Execute error callback bound to calling object
×
410
            req.reject(err);
×
411
        }
×
412
    }
×
413

17✔
414
    private async _handleEvent(data: { data: unknown; event: string; }): Promise<void> {
17✔
415
        // Event
×
416
        const index = data.event.lastIndexOf(".");
×
417
        if (index === -1 || index === data.event.length - 1) {
×
418
            throw new Error(`Malformed event name: ${data.event}`);
×
419
        }
×
420

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

×
423
        const cacheItem = this.cache[rid];
×
424
        if (!cacheItem?.item) {
×
425
            throw new Error("Resource not found in cache");
×
426
        }
×
427

×
428
        const event = data.event.slice(index + 1);
×
429
        let handled = false;
×
430
        switch (event) {
×
431
            case "change": {
×
432
                handled = await this._handleChangeEvent(cacheItem as CacheItem<ResModel>, event, data.data as ChangeEventData, false);
×
433
                break;
×
434
            }
×
435

×
436
            case "add": {
×
437
                handled = await this._handleAddEvent(cacheItem as CacheItem<ResCollection>, event, data.data as AddEventData);
×
438
                break;
×
439
            }
×
440

×
441
            case "remove": {
×
442
                handled = await this._handleRemoveEvent(cacheItem as CacheItem<ResCollection>, event, data.data as RemoveEventData);
×
443
                break;
×
444
            }
×
445

×
446
            case "unsubscribe": {
×
447
                handled = await this._handleUnsubscribeEvent(cacheItem);
×
448
                break;
×
449
            }
×
450
        }
×
451

×
452
        if (!handled) {
×
453
            this.eventBus.emit(cacheItem.item, `${this.namespace}.resource.${rid}.${event}`, data.data);
×
454
        }
×
455
    }
×
456

17✔
457
    private _handleFailedSubscribe(ci: CacheItem): void {
17✔
458
        ci.addSubscribed(-1);
×
459
        this._tryDelete(ci);
×
460
    }
×
461

17✔
462
    private async _handleRemoveEvent(ci: CacheItem<ResCollection>, event: string, data: RemoveEventData): Promise<boolean> {
17✔
463
        if (ci.type !== COLLECTION_TYPE) {
×
464
            return false;
×
465
        }
×
466

×
467
        const idx = data.idx;
×
468
        const item = ci.item.remove(idx);
×
469
        this.eventBus.emit(ci.item, `${this.namespace}.resource.${ci.rid}.${event}`, { item, idx });
×
470

×
471
        const rid = getRID(item);
×
472
        if (rid) {
×
473
            const refItem = this.cache[rid];
×
474
            if (!refItem) {
×
475
                throw new Error("Removed model is not in cache");
×
476
            }
×
477

×
478
            refItem.addIndirect(-1);
×
479
            this._tryDelete(refItem);
×
480
        }
×
481
        return true;
×
482
    }
×
483

17✔
484
    private async _handleSuccessResponse(req: Request, data: unknown): Promise<void> {
17✔
485
        req.resolve((data as Record<"result", unknown>).result);
×
486
    }
×
487

17✔
488
    private async _handleUnsubscribeEvent(ci: CacheItem): Promise<boolean> {
17✔
489
        await ci.item.dispose();
×
490
        ci.addSubscribed(0);
×
491
        this._tryDelete(ci);
×
492
        this.eventBus.emit(ci.item, `${this.namespace}.resource.${ci.rid}.unsubscribe`, { item: ci.item });
×
493
        return true;
×
494
    }
×
495

17✔
496
    private async _initItems(refs: Refs, type: AnyResType): Promise<void> {
17✔
497
        if (!refs) {
×
498
            return;
×
499
        }
×
500

×
501
        const promises: Array<Promise<AnyRes>> = [];
×
502
        for (const rid of Object.keys(refs)) {
×
503
            const cacheItem = this.cache[rid];
×
504
            assert(cacheItem, `Missing CacheItem (rid: ${rid})`);
×
505
            promises.push(cacheItem.item.init(type.prepareData(refs[rid] as never) as never));
×
506
        }
×
507
        await Promise.all(promises);
×
508
    }
×
509

17✔
510
    private async _listenItems(refs: Refs): Promise<void> {
17✔
511
        if (!refs) {
×
512
            return;
×
513
        }
×
514

×
515
        const promises: Array<Promise<void>> = [];
×
516
        for (const rid of Object.keys(refs)) {
×
517
            const cacheItem = this.cache[rid];
×
518
            assert(cacheItem, `Missing CacheItem (rid: ${rid})`);
×
519
            promises.push((cacheItem.item as unknown as { _listen(on: boolean): Promise<void>; })._listen(true));
×
520
        }
×
521
        await Promise.all(promises);
×
522
    }
×
523

17✔
524
    // @FIXME: this is a mess
17✔
525
    private _markDelete(refs: Record<string, Ref>, ci: CacheItem, state: unknown): unknown {
17✔
526
        // Quick exit if it is already subscribed
61✔
527
        if (ci.subscribed) {
61!
528
            return false;
×
529
        }
×
530

61✔
531
        const rid = ci.rid;
61✔
532
        const r = refs[rid]!;
61✔
533

61✔
534
        if (r.st === States.KEEP) {
61✔
535
            return false;
8✔
536
        }
8✔
537

53✔
538
        if (state === States.DELETE) {
61✔
539

38✔
540
            if (r.rc > 0) {
38✔
541
                r.st = States.KEEP;
7✔
542
                return rid;
7✔
543
            }
7✔
544

31✔
545
            if (r.st !== States.NONE) {
38✔
546
                return false;
2✔
547
            }
2✔
548

29✔
549
            if (r.ci.direct) {
38✔
550
                r.st = States.STALE;
5✔
551
                return rid;
5✔
552
            }
5✔
553

24✔
554
            r.st = States.DELETE;
24✔
555
            return States.DELETE;
24✔
556
        }
24✔
557

15✔
558
        // A stale item can never cover itself
15✔
559
        if (rid === state) {
61✔
560
            return false;
2✔
561
        }
2✔
562

13✔
563
        r.st = States.KEEP;
13✔
564
        return r.rc > 0 ? rid : state;
61!
565
    }
61✔
566

17✔
567
    private async _onClose(e: unknown): Promise<void> {
17✔
568
        if (typeof e === "object" && e !== null) {
×
569
            if ("message" in e) {
×
570
                Debug("ws", "ResClient close", ...[e.message, (e as { code?: string; }).code].filter(Boolean));
×
571
            } else if ("code" in e) {
×
572
                Debug("ws", "ResClient close", e.code);
×
573
            } else {
×
574
                Debug("ws", "ResClient close", e);
×
575
            }
×
576
        }
×
577
        this.connectPromise = null;
×
578
        this.ws = null;
×
579
        const wasConnected = this.connected;
×
580
        if (this.connected) {
×
581
            this.connected = false;
×
582

×
583
            // Set any subscribed item in cache to stale
×
584
            for (const rid of Object.keys(this.cache)) {
×
585
                const ci = this.cache[rid];
×
586
                assert(ci, `Missing CacheItem (rid: ${rid})`);
×
587
                if (ci.subscribed) {
×
588
                    ci.addSubscribed(0);
×
589
                    this._addStale(rid);
×
590
                    this._tryDelete(ci);
×
591
                }
×
592
            }
×
593

×
594
            this._emit("disconnect", e);
×
595
        }
×
596

×
597
        let hasStale = false;
×
598

×
599
        if (Object.keys(this.cache).length !== 0) {
×
600
            hasStale = true;
×
601
        }
×
602

×
603
        this.tryConnect = hasStale && this.tryConnect;
×
604

×
605
        if (this.tryConnect) {
×
606
            await this._reconnect(wasConnected);
×
607
        }
×
608
    }
×
609

17✔
610
    private async _onError(e: unknown): Promise<void> {
17✔
611
        Debug("ws", "ResClient error", e);
×
612
        this._connectReject({ code: ErrorCodes.CONNECTION_ERROR, message: "Connection error", data: e });
×
613
    }
×
614

17✔
615
    private async _onMessage(e: MessageEvent): Promise<void> {
17✔
616
        await this._receive((e as { data: string; }).data);
×
617
    }
×
618

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

17✔
663
    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✔
664
        return lcsDiffAsync<T>(a, b, onKeep, onAdd, onRemove);
×
665
    }
×
666

17✔
667
    private _prepareValue(v: { action?: string; data?: unknown; rid?: string; soft?: boolean; } | string, addIndirect = false): unknown {
17✔
668
        let val: unknown = v;
×
669
        if (v !== null && typeof v === "object") {
×
670
            if (v.rid) {
×
671
                // Resource reference
×
672
                if (v.soft) {
×
673
                    // Soft reference
×
674
                    val = new ResRef(this, v.rid);
×
675
                } else {
×
676
                    // Non-soft reference
×
677
                    const ci = this.cache[v.rid];
×
678
                    assert(ci, `Missing CacheItem (rid: ${v.rid})`);
×
679
                    if (addIndirect) {
×
680
                        ci.addIndirect();
×
681
                    }
×
682
                    val = ci.item;
×
683
                }
×
684
            } else if (Object.hasOwn(v, "data")) {
×
685
                // Data value
×
686
                val = v.data;
×
687
            } else if (v.action === "delete") {
×
688
                val = undefined;
×
689
            } else {
×
690
                throw new Error("Invalid value: " + JSON.stringify(val));
×
691
            }
×
692
        }
×
693

×
694
        return val;
×
695
    }
×
696

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

×
701
        if (Object.hasOwn(data, "id")) {
×
702
            const id = data.id as number;
×
703

×
704
            // Find the stored request
×
705
            const req = this.requests[id];
×
706
            if (!req) {
×
707
                throw new Error("Server response without matching request");
×
708
            }
×
709

×
710
            delete this.requests[id];
×
711

×
712
            if (Object.hasOwn(data, "error")) {
×
713
                await this._handleErrorResponse(req, data);
×
714
            } else {
×
715
                await this._handleSuccessResponse(req, data);
×
716
            }
×
717
        } else if (Object.hasOwn(data, "event")) {
×
718
            await this._handleEvent(data as never);
×
719
        } else {
×
720
            throw new Error("Invalid message from server: " + json);
×
721
        }
×
722
    }
×
723

17✔
724
    private async _reconnect(noDelay = false): Promise<void> {
17✔
725
        if (noDelay) {
×
726
            await this.connect();
×
727
            return;
×
728
        }
×
729
        setTimeout(async() => {
×
730
            if (!this.tryConnect) {
×
731
                return;
×
732
            }
×
733

×
734
            await this.connect();
×
735
        }, RECONNECT_DELAY);
×
736
    }
×
737

17✔
738
    private _removeStale(rid: string): void {
17✔
739
        if (this.stale) {
×
740
            delete this.stale[rid];
×
741
            if (Object.keys(this.stale).length === 0) {
×
742
                this.stale = null;
×
743
            }
×
744
        }
×
745
    }
×
746

17✔
747
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
17✔
748
    private _seekRefs(refs: Record<string, Ref>, ci: CacheItem, state: States): boolean {
17✔
749
        // Quick exit if it is already subscribed
39✔
750
        if (ci.subscribed) {
39!
751
            return false;
×
752
        }
×
753

39✔
754
        const rid = ci.rid;
39✔
755
        const r = refs[rid];
39✔
756
        if (r) {
39✔
757
            r.rc--;
11✔
758
            return false;
11✔
759
        }
11✔
760

28✔
761
        refs[rid] = { ci, rc: ci.indirect - 1, st: States.NONE };
28✔
762
        return true;
28✔
763
    }
28✔
764

17✔
765
    private _send<T = unknown>(action: string, rid: string, method?: string, params?: unknown): Promise<T> {
17✔
766
        if (!rid) {
×
767
            throw new Error("Invalid resource ID");
×
768
        }
×
769

×
770
        if (method === "") {
×
771
            throw new Error("Invalid method");
×
772
        }
×
773

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

×
776
        return this.connected
×
777
            ? this._sendNow<T>(m, params)
×
778
            : this.connect()
×
779
                .catch(async err => {
×
780
                    throw (await (new ResError(this, rid, m, params)).init(err as Error));
×
781
                })
×
782
                .then(() => this._sendNow<T>(m, params));
×
783
    }
×
784

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

×
789
            this.requests[req.id] = {
×
790
                method,
×
791
                params: req.params,
×
792
                resolve,
×
793
                reject
×
794
            };
×
795

×
796
            const json = JSON.stringify(req);
×
797
            Debug("ws:send", "->", req);
×
798
            this.ws!.send(json);
×
799
        });
×
800
    }
×
801

17✔
802
    private _sendUnsubscribe(ci: CacheItem, count: number): void {
17✔
803
        this._send("unsubscribe", ci.rid, undefined, count > 1 ? { count } : null)
×
804
            .then(() => {
×
805
                ci.addSubscribed(-count);
×
806
                this._tryDelete(ci);
×
807
            })
×
808
            .catch(() => this._tryDelete(ci));
×
809
    }
×
810

17✔
811
    private _setStale(rid: string): void {
17✔
812
        this._addStale(rid);
×
813
        if (this.connected) {
×
814
            setTimeout(() => this._subscribeToStale(rid), SUBSCRIBE_STALE_DELAY);
×
815
        }
×
816
    }
×
817

17✔
818
    private async _subscribe(ci: CacheItem, throwError = false): Promise<void> {
17✔
819
        const rid = ci.rid;
×
820
        ci.addSubscribed(1);
×
821
        this._removeStale(rid);
×
822
        return this._send<Shared>("subscribe", rid)
×
823
            .then(response => this._cacheResources(response))
×
824
            .catch(async err => {
×
825
                if (throwError) {
×
826
                    this._handleFailedSubscribe(ci);
×
827
                    throw err;
×
828
                } else {
×
829
                    await this._handleUnsubscribeEvent(ci);
×
830
                }
×
831
            });
×
832
    }
×
833

17✔
834
    private async _subscribeReferred(ci: CacheItem): Promise<void> {
17✔
835
        const i = ci.subscribed;
×
836
        ci.subscribed = 0;
×
837
        const refs = this._getRefState(ci);
×
838
        ci.subscribed = i;
×
839

×
840
        for (const rid of Object.keys(refs)) {
×
841
            const r = refs[rid]!;
×
842
            if (r.st === States.STALE) {
×
843
                await this._subscribe(r.ci);
×
844
            }
×
845
        }
×
846
    }
×
847

17✔
848
    private async _subscribeToAllStale(): Promise<void> {
17✔
849
        if (!this.stale) {
×
850
            return;
×
851
        }
×
852

×
853
        for (const rid of Object.keys(this.stale)) {
×
854
            await this._subscribeToStale(rid);
×
855
        }
×
856
    }
×
857

17✔
858
    private async _subscribeToStale(rid: string): Promise<void> {
17✔
859
        if (!this.connected || !this.stale || !this.stale[rid]) {
×
860
            return;
×
861
        }
×
862

×
863
        const ci = this.cache[rid];
×
864
        assert(ci, `Missing CacheItem (rid: ${rid})`);
×
865
        return this._subscribe(ci);
×
866
    }
×
867

17✔
868
    private async _syncCollection(cacheItem: CacheItem<ResCollection>, data: Array<RIDRef>): Promise<void> {
17✔
869
        const collection = cacheItem.item;
×
870
        let i = collection.length;
×
871
        const a = Array.from({ length: i });
×
872
        while (i--) {
×
873
            a[i] = collection.at(i);
×
874
        }
×
875

×
876
        const b = data.map(v => this._prepareValue(v as never));
×
877
        await this._patchDiff<unknown>(a, b,
×
878
            async () => {},
×
879
            async (id: unknown, n: number, idx: number) => this._handleAddEvent(cacheItem, "add", { value: data[n]!, idx }),
×
880
            async (id: unknown, m: number, idx: number) => this._handleRemoveEvent(cacheItem, "remove", { idx })
×
881
        );
×
882
    }
×
883

17✔
884
    private async _syncItems(refs: AnyObject, type: AnyResType): Promise<void> {
17✔
885
        if (!refs) {
×
886
            return;
×
887
        }
×
888

×
889
        for (const rid of Object.keys((refs))) {
×
890
            const ci = this.cache[rid];
×
891
            await type.synchronize(ci as never, refs[rid] as never);
×
892
        }
×
893
    }
×
894

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

17✔
899
    private _traverse(ci: CacheItem, cb: (ci: CacheItem, state: States) => States | boolean, state: States, skipFirst = false): void {
17✔
900
        // Call callback to get new state to pass to
116✔
901
        // children. If false, we should not traverse deeper
116✔
902
        if (!skipFirst) {
116✔
903
            const s = cb(ci, state);
100✔
904
            if (s === false) {
100✔
905
                return;
23✔
906
            } else {
100✔
907
                state = s as States;
77✔
908
            }
77✔
909
        }
100✔
910

93✔
911
        const item = ci.item;
93✔
912
        switch (ci.type) {
93✔
913
            case COLLECTION_TYPE: {
93✔
914
                for (const v of item as ResCollection) {
93✔
915
                    const cii = this._getRefItem(v);
84✔
916
                    if (cii) {
84✔
917
                        this._traverse(cii, cb, state);
84✔
918
                    }
84✔
919
                }
84✔
920
                break;
93✔
921
            }
93✔
922
            case MODEL_TYPE: {
116!
923
                for (const k in item) {
×
924
                    if (Object.hasOwn(item, k)) {
×
925
                        const cii = this._getRefItem(item[k as never]);
×
926
                        if (cii) {
×
927
                            this._traverse(cii, cb, state);
×
928
                        }
×
929
                    }
×
930
                }
×
931
                break;
×
932
            }
×
933
        }
116✔
934
    }
116✔
935

17✔
936
    private _tryDelete(ci: CacheItem): void {
17✔
937
        const refs = this._getRefState(ci);
×
938

×
939
        for (const rid of Object.keys(refs)) {
×
940
            const r = refs[rid]!;
×
941
            switch (r.st) {
×
942
                case States.STALE: {
×
943
                    this._setStale(rid);
×
944
                    break;
×
945
                }
×
946
                case States.DELETE: {
×
947
                    this._deleteRef(r.ci as never);
×
948
                    break;
×
949
                }
×
950
            }
×
951
        }
×
952
    }
×
953

17✔
954
    private async _unsubscribe(ci: CacheItem): Promise<void> {
17✔
955
        if (!ci.subscribed) {
×
956
            if (this.stale && this.stale[ci.rid]) {
×
957
                this._tryDelete(ci);
×
958
            }
×
959
            return;
×
960
        }
×
961

×
962
        await this._subscribeReferred(ci);
×
963

×
964
        let i = ci.subscribed;
×
965
        if (this.protocol.unsubscribeCountSupported) {
×
966
            this._sendUnsubscribe(ci, i);
×
967
        } else {
×
968
            while (i--) {
×
969
                this._sendUnsubscribe(ci, 1);
×
970
            }
×
971
        }
×
972
    }
×
973

17✔
974
    authenticate<T = unknown>(rid: string, method: string, params: unknown): Promise<T> {
17✔
975
        return this._call<T>("auth", rid, method, params);
×
976
    }
×
977

17✔
978
    call<T = unknown>(rid: string, method: string, params?: unknown): Promise<T> {
17✔
979
        return this._call<T>("call", rid, method, params);
×
980
    }
×
981

17✔
982
    connect(): Promise<void> {
17✔
983
        this.tryConnect = true;
×
984
        if (!this.connectPromise) {
×
985
            this.connectPromise = new Promise<void>((resolve, reject) => {
×
986
                this.connectCallback = { resolve, reject };
×
987
                this.ws = this.wsFactory();
×
988

×
989
                /* eslint-disable unicorn/prefer-add-event-listener */
×
990
                this.ws.onopen = this.onOpen;
×
991
                this.ws.onerror = this.onError;
×
992
                this.ws.onmessage = this.onMessage;
×
993
                this.ws.onclose = this.onClose;
×
994
                /* eslint-enable unicorn/prefer-add-event-listener */
×
995
            });
×
996
            this.connectPromise.catch(err => this._emit("connectError", err));
×
997
        }
×
998

×
999
        return this.connectPromise;
×
1000
    }
×
1001

17✔
1002
    async create(rid: string, params: unknown): Promise<ResModel | ResError | ResCollection> {
17✔
1003
        return this._send<Shared>("new", rid, undefined, params)
×
1004
            .then(async result => {
×
1005
                await this._cacheResources(result);
×
1006
                const _rid = (result as { rid: string; }).rid;
×
1007
                const ci = this.cache[rid];
×
1008
                assert(ci, `Missing CacheItem (rid: ${_rid})`);
×
1009
                ci.addSubscribed(1);
×
1010
                return ci.item;
×
1011
            });
×
1012
    }
×
1013

17✔
1014
    async disconnect(): Promise<void> {
17✔
1015
        this.tryConnect = false;
×
1016
        const err = { code: ErrorCodes.DISCONNECT, message: "Disconnect called" };
×
1017
        const resErr = new ResError(this, "disconnect", undefined, err);
×
1018

×
1019
        const req = Object.values(this.requests);
×
1020
        if (req.length !== 0) {
×
1021
            for (const r of req) r.reject(resErr);
×
1022
        }
×
1023
        if (this.ws) {
×
1024
            const ws = this.ws;
×
1025
            ws.removeEventListener("close", this.onClose);
×
1026
            await this.onClose(resErr);
×
1027
            ws.close();
×
1028
            this._connectReject(err);
×
1029
        }
×
1030
    }
×
1031

17✔
1032
    async get<T extends AnyRes = AnyRes>(rid: string, forceKeep = false): Promise<T> {
17✔
1033
        Debug("client:get", `${rid}${forceKeep ? " (keep)" : ""}`);
×
1034
        return this.subscribe(rid, forceKeep).then(() => this.getCached<T>(rid)!);
×
1035
    }
×
1036

17✔
1037
    getCached<T extends AnyRes = AnyRes>(rid: string): T | null {
17✔
1038
        Debug("client:getCached", rid);
×
1039
        return this.cache[rid]?.item as T ?? null;
×
1040
    }
×
1041

17✔
1042
    async getPaginated<T extends ResModel = ResModel>(rid: string, offset: number, limit: number): Promise<Array<T>> {
17✔
1043
        rid = `${rid}?offset=${offset}&limit=${limit}`;
×
1044
        Debug("client:getPaginated", rid);
×
1045
        const ci = CacheItem.createDefault(rid, this);
×
1046
        this.cache[rid] = ci;
×
1047
        await ci.setPromise(this._subscribe(ci, true));
×
1048
        const item = ci.item as unknown as ResCollection | ResModel;
×
1049
        let items: Array<T>;
×
1050
        if (item instanceof ResModel) {
×
1051
            items = Object.values(item.props as Record<string, T>);
×
1052
        } else if (item instanceof ResCollection) {
×
1053
            items = item.list as Array<T>;
×
1054
        } else {
×
1055
            assert(false, `Invalid resource type for paginated request: ${(item as AnyClass).constructor.name}`);
×
1056
        }
×
1057
        ci.unsubscribe();
×
1058
        return items;
×
1059
    }
×
1060

17✔
1061
    keepCached(item: CacheItem, cb = false): void {
17✔
1062
        Debug("client:keepCached", item.rid);
×
1063
        if (item.forceKeep) return;
×
1064
        if (!cb) item.keep();
×
1065
        item.resetTimeout();
×
1066
    }
×
1067

17✔
1068
    off(handler?: AnyFunction): this;
17✔
1069
    off(events: string | Array<string> | null, handler?: AnyFunction): this;
17✔
1070
    off(...args: [string | Array<string> | null, AnyFunction?] | [AnyFunction?]): this {
17✔
1071
        if (args.length === 0 || args[0] === undefined) {
×
1072
            this.eventBus.off(this, null, undefined, this.namespace);
×
1073
        } else if (args.length === 1 && typeof args[0] === "function") {
×
1074
            this.eventBus.off(this, null, args[0], this.namespace);
×
1075
        } else if (args.length === 1) {
×
1076
            this.eventBus.off(this, args[0] as null, undefined, this.namespace);
×
1077
        } else {
×
1078
            this.eventBus.off(this, args[0] as null, args.at(-1) as AnyFunction, this.namespace);
×
1079
        }
×
1080
        return this;
×
1081
    }
×
1082

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

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

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

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

×
1107
        cacheItem.removeDirect();
×
1108
        this.eventBus.off(cacheItem.item, events, handler, `${this.namespace}.resource.${rid}`);
×
1109
    }
×
1110

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

×
1118
        cacheItem.addDirect();
×
1119
        this.eventBus.on(cacheItem.item, events, handler, `${this.namespace}.resource.${rid}`);
×
1120
    }
×
1121

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

×
1132
        return this._send("call", modelId, "set", props);
×
1133
    }
×
1134

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

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

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

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

17✔
1166
    unregisterCollectionType(pattern: string): this {
17✔
1167
        this.types.collection.list.removeFactory(pattern);
×
1168
        return this;
×
1169
    }
×
1170

17✔
1171
    unregisterModelType(pattern: string): this {
17✔
1172
        this.types.model.list.removeFactory(pattern);
×
1173
        return this;
×
1174
    }
×
1175
}
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