@vgai/p2p-colyseus 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/runtime.ts ADDED
@@ -0,0 +1,371 @@
1
+ import type { PacketChannel } from './channels';
2
+ import { clone, createStatePatch, encodeSnapshot } from './codec';
3
+ import type { Envelope, Snapshot } from './protocol';
4
+ import { P2P_CLOSE_CODES } from './protocol';
5
+ import type { BroadcastOptions, CompatClient, Room } from './room';
6
+
7
+ export type RoomClass<T extends Room = Room> = new () => T;
8
+
9
+ interface RuntimeClient extends CompatClient {
10
+ readonly channel: PacketChannel;
11
+ lastState: unknown;
12
+ }
13
+
14
+ interface PendingReconnection {
15
+ readonly previousClient: RuntimeClient;
16
+ readonly deferred: ReconnectionDeferred;
17
+ readonly timer?: ReturnType<typeof setTimeout>;
18
+ }
19
+
20
+ export class UniversalRoomRuntime<T extends Room = Room> {
21
+ readonly room: T;
22
+ private clock = 0;
23
+ private readonly clients = new Map<string, RuntimeClient>();
24
+ private readonly reconnections = new Map<string, PendingReconnection>();
25
+
26
+ constructor(
27
+ roomClass: RoomClass<T>,
28
+ private readonly roomName: string,
29
+ options?: unknown,
30
+ ) {
31
+ this.room = new roomClass();
32
+ this.room._attachRuntime({
33
+ broadcastMessage: (type, payload, options) => this.broadcastMessage(type, payload, options),
34
+ broadcastState: () => this.broadcastState(),
35
+ disconnect: (code) => this.disconnect(code),
36
+ send: (client, type, payload) => this.sendToClient(client, type, payload),
37
+ removeClient: (client, code, reason) =>
38
+ this.removeClient(client as RuntimeClient, true, code, reason),
39
+ allowReconnection: (client, seconds) =>
40
+ this.allowReconnection(client as RuntimeClient, seconds),
41
+ });
42
+ this.room.onCreate?.(options);
43
+ }
44
+
45
+ attach(channel: PacketChannel): void {
46
+ channel.onEnvelope((envelope) => this.handleEnvelope(channel, envelope));
47
+ channel.onClose(() => this.removeByChannel(channel, true));
48
+ }
49
+
50
+ private handleEnvelope(channel: PacketChannel, envelope: Envelope): void {
51
+ if (envelope.kind === 'ping') {
52
+ channel.send({ kind: 'pong', t: envelope.t });
53
+ return;
54
+ }
55
+
56
+ if (envelope.kind === 'join') {
57
+ if (envelope.room !== this.roomName) {
58
+ channel.send({
59
+ kind: 'join-error',
60
+ requestId: envelope.requestId,
61
+ message: `Room not found: ${envelope.room}`,
62
+ });
63
+ closeAfterSend(channel, String(P2P_CLOSE_CODES.roomNotFound));
64
+ return;
65
+ }
66
+ void this.join(channel, envelope.requestId, envelope.options);
67
+ return;
68
+ }
69
+
70
+ const client = this.findByChannel(channel);
71
+ if (!client) return;
72
+
73
+ if (envelope.kind === 'message') {
74
+ try {
75
+ this.room._dispatchMessage(client, envelope.type, envelope.payload);
76
+ } catch (error) {
77
+ channel.send({
78
+ kind: 'message',
79
+ type: 'error',
80
+ payload: {
81
+ code: P2P_CLOSE_CODES.internalError,
82
+ message: error instanceof Error ? error.message : 'server error',
83
+ },
84
+ });
85
+ }
86
+ } else if (envelope.kind === 'leave') {
87
+ this.removeClient(client, true);
88
+ } else if (envelope.kind === 'state-resync') {
89
+ this.sendSnapshot(client);
90
+ }
91
+ }
92
+
93
+ private async join(channel: PacketChannel, requestId: string, options?: unknown): Promise<void> {
94
+ if (this.room.locked) {
95
+ channel.send({
96
+ kind: 'join-error',
97
+ requestId,
98
+ message: String(P2P_CLOSE_CODES.roomFull),
99
+ });
100
+ closeAfterSend(channel, String(P2P_CLOSE_CODES.roomFull));
101
+ return;
102
+ }
103
+
104
+ const reconnectionToken = getReconnectionToken(options);
105
+ const pendingReconnection = reconnectionToken
106
+ ? this.reconnections.get(reconnectionToken)
107
+ : undefined;
108
+
109
+ const sessionId = pendingReconnection?.previousClient.sessionId ?? channel.peerId;
110
+ const events = new MiniEmitter();
111
+ const client: RuntimeClient = {
112
+ id: sessionId,
113
+ sessionId,
114
+ state: 1,
115
+ reconnectionToken: pendingReconnection?.previousClient.reconnectionToken ?? sessionId,
116
+ ref: events,
117
+ channel,
118
+ lastState: undefined,
119
+ raw: (data, _options, cb) => {
120
+ channel.send({ kind: 'message', type: 'raw', payload: data });
121
+ cb?.();
122
+ },
123
+ enqueueRaw: (data) => channel.send({ kind: 'message', type: 'raw', payload: data }),
124
+ send: (type, payload) => channel.send({ kind: 'message', type: String(type), payload }),
125
+ sendBytes: (type, bytes) =>
126
+ channel.send({ kind: 'message', type: String(type), payload: bytes }),
127
+ leave: (code, data) => this.removeClient(client, true, code, data),
128
+ close: (code, data) => this.removeClient(client, true, code, data),
129
+ error: (code, message) =>
130
+ channel.send({ kind: 'message', type: 'error', payload: { code, message } }),
131
+ };
132
+ try {
133
+ const authResult = await this.room.onAuth?.(client, options);
134
+ if (authResult === false) {
135
+ channel.send({
136
+ kind: 'join-error',
137
+ requestId,
138
+ message: String(P2P_CLOSE_CODES.unauthorized),
139
+ });
140
+ closeAfterSend(channel, String(P2P_CLOSE_CODES.unauthorized));
141
+ return;
142
+ }
143
+ client.auth = authResult;
144
+ } catch (error) {
145
+ channel.send({
146
+ kind: 'join-error',
147
+ requestId,
148
+ message: error instanceof Error ? error.message : String(P2P_CLOSE_CODES.unauthorized),
149
+ });
150
+ closeAfterSend(channel, String(P2P_CLOSE_CODES.unauthorized));
151
+ return;
152
+ }
153
+ if (pendingReconnection) {
154
+ if (pendingReconnection.timer) clearTimeout(pendingReconnection.timer);
155
+ this.reconnections.delete(reconnectionToken!);
156
+ }
157
+ if (!pendingReconnection && this.room.locked) {
158
+ channel.send({
159
+ kind: 'join-error',
160
+ requestId,
161
+ message: String(P2P_CLOSE_CODES.roomFull),
162
+ });
163
+ closeAfterSend(channel, String(P2P_CLOSE_CODES.roomFull));
164
+ return;
165
+ }
166
+ this.clients.set(sessionId, client);
167
+ this.room.clients.push(client);
168
+ if (pendingReconnection) {
169
+ this.room.onReconnect?.(client);
170
+ pendingReconnection.deferred.resolve(client);
171
+ } else {
172
+ this.room.onJoin?.(client, options);
173
+ }
174
+ const snapshot = this.createSnapshot();
175
+ client.lastState = clone(snapshot.state);
176
+ channel.send({
177
+ kind: 'join-ok',
178
+ requestId,
179
+ sessionId,
180
+ snapshot,
181
+ clock: ++this.clock,
182
+ });
183
+ this.broadcastState();
184
+ }
185
+
186
+ private removeByChannel(channel: PacketChannel, consented: boolean): void {
187
+ const client = this.findByChannel(channel);
188
+ if (client) this.removeClient(client, consented);
189
+ }
190
+
191
+ private removeClient(
192
+ client: RuntimeClient,
193
+ consented: boolean,
194
+ code = 1000,
195
+ reason?: string,
196
+ ): void {
197
+ if (!this.clients.delete(client.sessionId)) return;
198
+ client.state = 5;
199
+ this.room.clients.delete(client);
200
+ if (!consented) this.room.onDrop?.(client, code);
201
+ this.room.onLeave?.(client, code);
202
+ client.channel.send(
203
+ reason === undefined ? { kind: 'leave', code } : { kind: 'leave', code, reason },
204
+ );
205
+ closeAfterSend(client.channel, reason ?? 'left');
206
+ this.broadcastState();
207
+ this.disposeIfIdle();
208
+ }
209
+
210
+ private findByChannel(channel: PacketChannel): RuntimeClient | undefined {
211
+ for (const client of this.clients.values()) {
212
+ if (client.channel === channel) return client;
213
+ }
214
+ return undefined;
215
+ }
216
+
217
+ private sendToClient(client: CompatClient, type: string | number, payload?: unknown): void {
218
+ const runtimeClient = this.clients.get(client.sessionId);
219
+ runtimeClient?.channel.send({ kind: 'message', type: String(type), payload });
220
+ }
221
+
222
+ private broadcastMessage(
223
+ type: string | number,
224
+ payload?: unknown,
225
+ options?: BroadcastOptions,
226
+ ): void {
227
+ const except = new Set(
228
+ Array.isArray(options?.except)
229
+ ? options.except.map((client) => client.sessionId)
230
+ : options?.except
231
+ ? [options.except.sessionId]
232
+ : [],
233
+ );
234
+ for (const client of this.clients.values()) {
235
+ if (!except.has(client.sessionId))
236
+ client.channel.send({ kind: 'message', type: String(type), payload });
237
+ }
238
+ }
239
+
240
+ broadcastState(): boolean {
241
+ const snapshot = this.createSnapshot();
242
+ let clock: number | undefined;
243
+ let sent = false;
244
+ for (const client of this.clients.values()) {
245
+ const patch = createStatePatch(client.lastState, snapshot.state);
246
+ if (patch.operations.length === 0) continue;
247
+ clock ??= ++this.clock;
248
+ client.lastState = clone(snapshot.state);
249
+ client.channel.send({ kind: 'state-patch', patch, clock });
250
+ sent = true;
251
+ }
252
+ return sent;
253
+ }
254
+
255
+ private async disconnect(code = 4000): Promise<void> {
256
+ for (const client of [...this.clients.values()])
257
+ this.removeClient(client, true, code, 'disconnect');
258
+ }
259
+
260
+ private allowReconnection(
261
+ client: RuntimeClient,
262
+ seconds: number | 'manual',
263
+ ): ReconnectionDeferred {
264
+ const token = client.reconnectionToken;
265
+ const existing = this.reconnections.get(token);
266
+ if (existing?.timer) clearTimeout(existing.timer);
267
+ const deferred = new ReconnectionDeferred();
268
+ const timer =
269
+ seconds === 'manual'
270
+ ? undefined
271
+ : setTimeout(
272
+ () => {
273
+ this.reconnections.delete(token);
274
+ deferred.reject(new Error('reconnection timeout'));
275
+ this.disposeIfIdle();
276
+ },
277
+ Math.max(0, seconds * 1000),
278
+ );
279
+ if (timer) (timer as unknown as { unref?: () => void }).unref?.();
280
+ this.reconnections.set(token, {
281
+ previousClient: client,
282
+ deferred,
283
+ ...(timer ? { timer } : {}),
284
+ });
285
+ return deferred;
286
+ }
287
+
288
+ private disposeIfIdle(): void {
289
+ if (this.clients.size === 0 && this.reconnections.size === 0 && this.room.autoDispose) {
290
+ this.room.onDispose?.();
291
+ this.room._disposeRuntime();
292
+ }
293
+ }
294
+
295
+ private sendSnapshot(client: RuntimeClient): void {
296
+ const snapshot = this.createSnapshot();
297
+ client.lastState = clone(snapshot.state);
298
+ client.channel.send({ kind: 'state-snapshot', snapshot, clock: ++this.clock });
299
+ }
300
+
301
+ private createSnapshot(): Snapshot {
302
+ return { type: 'snapshot', state: encodeSnapshot(this.room._snapshot()) };
303
+ }
304
+ }
305
+
306
+ class MiniEmitter {
307
+ private readonly handlers = new Map<string, Set<(...args: unknown[]) => void>>();
308
+
309
+ on(event: string, handler: (...args: unknown[]) => void): void {
310
+ let eventHandlers = this.handlers.get(event);
311
+ if (!eventHandlers) {
312
+ eventHandlers = new Set();
313
+ this.handlers.set(event, eventHandlers);
314
+ }
315
+ eventHandlers.add(handler);
316
+ }
317
+
318
+ off(event: string, handler: (...args: unknown[]) => void): void {
319
+ this.handlers.get(event)?.delete(handler);
320
+ }
321
+
322
+ emit(event: string, ...args: unknown[]): void {
323
+ for (const handler of this.handlers.get(event) ?? []) handler(...args);
324
+ }
325
+ }
326
+
327
+ class ReconnectionDeferred implements Promise<CompatClient> {
328
+ readonly [Symbol.toStringTag] = 'Promise';
329
+ private resolvePromise!: (client: CompatClient) => void;
330
+ private rejectPromise!: (reason?: unknown) => void;
331
+ private readonly promise = new Promise<CompatClient>((resolve, reject) => {
332
+ this.resolvePromise = resolve;
333
+ this.rejectPromise = reject;
334
+ });
335
+
336
+ resolve(client: CompatClient): void {
337
+ this.resolvePromise(client);
338
+ }
339
+
340
+ reject(reason?: unknown): void {
341
+ this.rejectPromise(reason);
342
+ }
343
+
344
+ // biome-ignore lint/suspicious/noThenProperty: allowReconnection returns a Promise-compatible thenable in Colyseus.
345
+ then<TResult1 = CompatClient, TResult2 = never>(
346
+ onfulfilled?: ((value: CompatClient) => TResult1 | PromiseLike<TResult1>) | null,
347
+ onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
348
+ ): Promise<TResult1 | TResult2> {
349
+ return this.promise.then(onfulfilled, onrejected);
350
+ }
351
+
352
+ catch<TResult = never>(
353
+ onrejected?: ((reason: unknown) => TResult | PromiseLike<TResult>) | null,
354
+ ): Promise<CompatClient | TResult> {
355
+ return this.promise.catch(onrejected);
356
+ }
357
+
358
+ finally(onfinally?: (() => void) | null): Promise<CompatClient> {
359
+ return this.promise.finally(onfinally);
360
+ }
361
+ }
362
+
363
+ function getReconnectionToken(options: unknown): string | undefined {
364
+ if (typeof options !== 'object' || options === null) return undefined;
365
+ const token = (options as { reconnectionToken?: unknown }).reconnectionToken;
366
+ return typeof token === 'string' ? token : undefined;
367
+ }
368
+
369
+ function closeAfterSend(channel: PacketChannel, reason: string): void {
370
+ queueMicrotask(() => channel.close(reason));
371
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,287 @@
1
+ const SCHEMA_TYPES = Symbol('p2pColyseusSchemaTypes');
2
+
3
+ export const $refId = '~refId';
4
+ export const $track = '~track';
5
+ export const $encoder = '~encoder';
6
+ export const $decoder = '~decoder';
7
+ export const $filter = '~filter';
8
+ export const $getByIndex = '~getByIndex';
9
+ export const $deleteByIndex = '~deleteByIndex';
10
+ export const $changes = '~changes';
11
+ export const $childType = '~childType';
12
+
13
+ export const OPERATION = {
14
+ ADD: 128,
15
+ REPLACE: 0,
16
+ DELETE: 64,
17
+ DELETE_AND_MOVE: 96,
18
+ MOVE_AND_ADD: 160,
19
+ DELETE_AND_ADD: 192,
20
+ CLEAR: 10,
21
+ REVERSE: 15,
22
+ MOVE: 32,
23
+ DELETE_BY_REFID: 33,
24
+ ADD_BY_REFID: 129,
25
+ } as const;
26
+
27
+ export class Schema {
28
+ constructor(props?: Record<string, unknown>) {
29
+ if (props) this.assign(props as Partial<this>);
30
+ }
31
+
32
+ static getSchemaTypes(): ReadonlyMap<string, unknown> {
33
+ const combined = new Map<string, unknown>();
34
+ // biome-ignore lint/complexity/noThisInStatic: schema metadata must be read from the concrete subclass.
35
+ const parent = Object.getPrototypeOf(this) as {
36
+ getSchemaTypes?: () => ReadonlyMap<string, unknown>;
37
+ } | null;
38
+ if (parent && parent !== Schema && parent.getSchemaTypes) {
39
+ for (const [key, value] of parent.getSchemaTypes()) combined.set(key, value);
40
+ }
41
+ // biome-ignore lint/complexity/noThisInStatic: schema metadata must be read from the concrete subclass.
42
+ const own = (this as unknown as { [SCHEMA_TYPES]?: Map<string, unknown> })[SCHEMA_TYPES];
43
+ for (const [key, value] of own ?? []) combined.set(key, value);
44
+ return combined;
45
+ }
46
+
47
+ static is(type: unknown): boolean {
48
+ return typeof type === 'function' && (type === Schema || type.prototype instanceof Schema);
49
+ }
50
+
51
+ static isSchema(obj: unknown): obj is Schema {
52
+ return obj instanceof Schema;
53
+ }
54
+
55
+ assign<T extends Partial<this>>(props: T): this {
56
+ Object.assign(this, props);
57
+ return this;
58
+ }
59
+
60
+ restore(jsonData: Record<string, unknown>): this {
61
+ Object.assign(this, jsonData);
62
+ return this;
63
+ }
64
+
65
+ setDirty(_property?: string | number | symbol, _operation?: unknown): void {
66
+ // This shim snapshots mutable state each tick, so explicit dirty marking is unnecessary.
67
+ }
68
+
69
+ clone(): this {
70
+ const cloned = new (this.constructor as new () => this)();
71
+ return cloned.restore(deepClone(this.toJSON() as Record<string, unknown>));
72
+ }
73
+
74
+ toJSON(): unknown {
75
+ return encodeSnapshotState(this);
76
+ }
77
+
78
+ discardAllChanges(): void {
79
+ // Compatibility no-op. Change tracking is handled by snapshot diffing.
80
+ }
81
+ }
82
+
83
+ export class MapSchema<T> extends Map<string, T> {
84
+ constructor(entries?: Iterable<readonly [string, T]> | null) {
85
+ super(entries);
86
+ }
87
+ }
88
+
89
+ export class ArraySchema<T> extends Array<T> {
90
+ constructor(...items: T[]) {
91
+ super(...items);
92
+ }
93
+ }
94
+
95
+ export class CollectionSchema<T> extends Set<T> {
96
+ constructor(values?: Iterable<T> | null) {
97
+ super(values);
98
+ }
99
+ }
100
+
101
+ export class SetSchema<T> extends Set<T> {
102
+ constructor(values?: Iterable<T> | null) {
103
+ super(values);
104
+ }
105
+ }
106
+
107
+ export function type(_kind: unknown): PropertyDecorator {
108
+ return (target, propertyKey) => {
109
+ const ctor = target.constructor as unknown as { [SCHEMA_TYPES]?: Map<string, unknown> };
110
+ ctor[SCHEMA_TYPES] ??= new Map();
111
+ ctor[SCHEMA_TYPES].set(String(propertyKey), _kind);
112
+ };
113
+ }
114
+
115
+ export const schema = type;
116
+ export const view = type;
117
+ export const entity = type;
118
+ export const deprecated = type;
119
+
120
+ export function defineTypes<T extends typeof Schema>(
121
+ target: T,
122
+ fields: Record<string, unknown>,
123
+ ): T {
124
+ const ctor = target as unknown as { [SCHEMA_TYPES]?: Map<string, unknown> };
125
+ ctor[SCHEMA_TYPES] ??= new Map();
126
+ for (const [key, value] of Object.entries(fields)) ctor[SCHEMA_TYPES].set(key, value);
127
+ return target;
128
+ }
129
+
130
+ export function defineCustomTypes<T extends Record<string, unknown>>(types: T): T {
131
+ return types;
132
+ }
133
+
134
+ export function registerType(): void {
135
+ // Custom binary type registration is not needed by the JSON snapshot codec.
136
+ }
137
+
138
+ export function dumpChanges(value: unknown): unknown {
139
+ return encodeSnapshotState(value);
140
+ }
141
+
142
+ export const encode = {
143
+ number(value: number): number {
144
+ return value;
145
+ },
146
+ string(value: string): string {
147
+ return value;
148
+ },
149
+ boolean(value: boolean): boolean {
150
+ return value;
151
+ },
152
+ };
153
+
154
+ export const decode = {
155
+ number(value: number): number {
156
+ return value;
157
+ },
158
+ string(value: string): string {
159
+ return value;
160
+ },
161
+ boolean(value: boolean): boolean {
162
+ return value;
163
+ },
164
+ };
165
+
166
+ export class Encoder<T = unknown> {
167
+ constructor(public state?: T) {}
168
+
169
+ encode(): Uint8Array {
170
+ return new TextEncoder().encode(JSON.stringify(encodeSnapshotState(this.state)));
171
+ }
172
+
173
+ encodeAll(): Uint8Array {
174
+ return this.encode();
175
+ }
176
+
177
+ discardChanges(): void {}
178
+ }
179
+
180
+ export class Decoder<T = unknown> {
181
+ state: T | undefined;
182
+
183
+ decode(bytes: Uint8Array): T | undefined {
184
+ const text = new TextDecoder().decode(bytes);
185
+ this.state = text ? (JSON.parse(text) as T) : undefined;
186
+ return this.state;
187
+ }
188
+ }
189
+
190
+ export class StateView {
191
+ readonly items = new Set<unknown>();
192
+
193
+ add(item: unknown): void {
194
+ this.items.add(item);
195
+ }
196
+
197
+ remove(item: unknown): void {
198
+ this.items.delete(item);
199
+ }
200
+
201
+ has(item: unknown): boolean {
202
+ return this.items.has(item);
203
+ }
204
+ }
205
+
206
+ export class Reflection {}
207
+ export class ReflectionType extends Schema {}
208
+ export class ReflectionField extends Schema {}
209
+ export class Metadata {}
210
+ export class TypeContext {}
211
+ export class ChangeTree {}
212
+
213
+ export class Callbacks {
214
+ static get(state: unknown): unknown {
215
+ return state;
216
+ }
217
+ }
218
+
219
+ export const StateCallbackStrategy = {};
220
+
221
+ export function getDecoderStateCallbacks(state: unknown): unknown {
222
+ return state;
223
+ }
224
+
225
+ export function getRawChangesCallback(
226
+ callback: (...args: unknown[]) => void,
227
+ ): (...args: unknown[]) => void {
228
+ return callback;
229
+ }
230
+
231
+ export function encodeSchemaOperation(value: unknown): unknown {
232
+ return value;
233
+ }
234
+
235
+ export function encodeArray(value: unknown): unknown {
236
+ return value;
237
+ }
238
+
239
+ export function encodeKeyValueOperation(value: unknown): unknown {
240
+ return value;
241
+ }
242
+
243
+ export function decodeSchemaOperation(value: unknown): unknown {
244
+ return value;
245
+ }
246
+
247
+ export function decodeKeyValueOperation(value: unknown): unknown {
248
+ return value;
249
+ }
250
+
251
+ export function encodeSnapshotState(value: unknown): unknown {
252
+ if (value instanceof ArraySchema || Array.isArray(value)) {
253
+ return value.map((item) => encodeSnapshotState(item));
254
+ }
255
+
256
+ if (value instanceof CollectionSchema || value instanceof SetSchema || value instanceof Set) {
257
+ return [...value.values()].map((item) => encodeSnapshotState(item));
258
+ }
259
+
260
+ if (value instanceof MapSchema || value instanceof Map) {
261
+ const out: Record<string, unknown> = {};
262
+ for (const [key, item] of value.entries()) out[String(key)] = encodeSnapshotState(item);
263
+ return out;
264
+ }
265
+
266
+ if (value instanceof Schema || isPlainObject(value)) {
267
+ const out: Record<string, unknown> = {};
268
+ for (const [key, item] of Object.entries(value)) {
269
+ if (typeof item === 'function') continue;
270
+ out[key] = encodeSnapshotState(item);
271
+ }
272
+ return out;
273
+ }
274
+
275
+ return value;
276
+ }
277
+
278
+ function deepClone<T>(value: T): T {
279
+ if (typeof structuredClone === 'function') return structuredClone(value);
280
+ return JSON.parse(JSON.stringify(value)) as T;
281
+ }
282
+
283
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
284
+ return (
285
+ typeof value === 'object' && value !== null && Object.getPrototypeOf(value) === Object.prototype
286
+ );
287
+ }