dignity.js 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.
@@ -0,0 +1,604 @@
1
+ const EventEmitter = require('../utils/event-emitter');
2
+ const { MessageSecurityService } = require('../security/message-security-service');
3
+
4
+ /**
5
+ * Core node API for replicated object collections.
6
+ *
7
+ * Interface shape is intentionally REST-like:
8
+ * - create(collection, data)
9
+ * - read(collection, id)
10
+ * - list(collection)
11
+ * - update(collection, id, patch)
12
+ * - remove(collection, id)
13
+ *
14
+ * Authorization model:
15
+ * - object creator is the owner
16
+ * - only owner can update or delete
17
+ */
18
+ class DignityP2P extends EventEmitter {
19
+ constructor({ nodeId, networkAdapter, idGenerator, now, security } = {}) {
20
+ super();
21
+
22
+ if (!nodeId) {
23
+ throw new Error('DignityP2P requires nodeId');
24
+ }
25
+
26
+ if (!networkAdapter) {
27
+ throw new Error('DignityP2P requires networkAdapter');
28
+ }
29
+
30
+ this.nodeId = nodeId;
31
+ this.networkAdapter = networkAdapter;
32
+ this.idGenerator = idGenerator || (() => `${Date.now()}-${Math.random().toString(16).slice(2)}`);
33
+ this.now = now || (() => Date.now());
34
+ this.securityService = new MessageSecurityService({
35
+ nodeId: this.nodeId,
36
+ options: security || {},
37
+ now: this.now
38
+ });
39
+ this.bannedPeers = new Map();
40
+ this.peerBanDurationMs = security && typeof security.banDurationMs === 'number'
41
+ ? security.banDurationMs
42
+ : 48 * 60 * 60 * 1000;
43
+ this.resolveBroadcastScope = security && typeof security.resolveBroadcastScope === 'function'
44
+ ? security.resolveBroadcastScope
45
+ : (() => 'default');
46
+ this.defaultDiscoveryHeartbeatMs = security && typeof security.discoveryHeartbeatMs === 'number'
47
+ ? security.discoveryHeartbeatMs
48
+ : 15000;
49
+ this.defaultPresenceTtlMs = security && typeof security.presenceTtlMs === 'number'
50
+ ? security.presenceTtlMs
51
+ : 45000;
52
+ this.discoveryRooms = new Map(); // scope -> { metadata, heartbeatIntervalMs, ttlMs, timer }
53
+ this.presenceByScope = new Map(); // scope -> Map(peerId -> presence)
54
+
55
+ this.state = new Map(); // collection -> Map(id -> record)
56
+ this.appliedOperations = new Set();
57
+ this.boundMessageHandler = this.handleIncomingMessage.bind(this);
58
+ }
59
+
60
+ async start() {
61
+ this.networkAdapter.onMessage(this.boundMessageHandler);
62
+ await this.networkAdapter.start(this.nodeId);
63
+ }
64
+
65
+ async stop() {
66
+ const joinedScopes = Array.from(this.discoveryRooms.keys());
67
+ for (const scope of joinedScopes) {
68
+ // Best effort leave announce; do not fail node shutdown if network is interrupted.
69
+ try {
70
+ await this.leaveDiscovery(scope);
71
+ } catch (error) {
72
+ this.emit('warning', { type: 'presence-leave-failed', scope, error });
73
+ }
74
+ }
75
+
76
+ this.networkAdapter.offMessage(this.boundMessageHandler);
77
+ await this.networkAdapter.stop();
78
+ }
79
+
80
+ getCollection(collectionName) {
81
+ if (!collectionName) {
82
+ throw new Error('collectionName is required');
83
+ }
84
+
85
+ if (!this.state.has(collectionName)) {
86
+ this.state.set(collectionName, new Map());
87
+ }
88
+
89
+ return this.state.get(collectionName);
90
+ }
91
+
92
+ normalizeRecord(record) {
93
+ if (!record || record.deletedAt) {
94
+ return null;
95
+ }
96
+
97
+ return {
98
+ id: record.id,
99
+ ownerId: record.ownerId,
100
+ createdAt: record.createdAt,
101
+ updatedAt: record.updatedAt,
102
+ version: record.version,
103
+ data: { ...record.data }
104
+ };
105
+ }
106
+
107
+ async create(collectionName, data, options = {}) {
108
+ const collection = this.getCollection(collectionName);
109
+ const id = options.id || this.idGenerator();
110
+
111
+ if (collection.has(id) && !collection.get(id).deletedAt) {
112
+ throw new Error(`Object ${id} already exists in ${collectionName}`);
113
+ }
114
+
115
+ const timestamp = this.now();
116
+ const operation = {
117
+ opId: this.idGenerator(),
118
+ kind: 'create',
119
+ collectionName,
120
+ id,
121
+ actorId: this.nodeId,
122
+ ownerId: this.nodeId,
123
+ timestamp,
124
+ payload: { ...data }
125
+ };
126
+
127
+ this.applyOperation(operation);
128
+ await this.broadcastMessage('operation', operation, {
129
+ broadcastScope: options.broadcastScope || this.resolveBroadcastScope({
130
+ messageType: 'operation',
131
+ operation,
132
+ collectionName
133
+ })
134
+ });
135
+
136
+ return this.read(collectionName, id);
137
+ }
138
+
139
+ read(collectionName, id) {
140
+ const collection = this.getCollection(collectionName);
141
+ return this.normalizeRecord(collection.get(id));
142
+ }
143
+
144
+ list(collectionName, options = {}) {
145
+ const collection = this.getCollection(collectionName);
146
+ const includeDeleted = options.includeDeleted || false;
147
+
148
+ const records = [];
149
+ for (const record of collection.values()) {
150
+ if (record.deletedAt && !includeDeleted) {
151
+ continue;
152
+ }
153
+
154
+ if (record.deletedAt && includeDeleted) {
155
+ records.push({
156
+ id: record.id,
157
+ ownerId: record.ownerId,
158
+ deletedAt: record.deletedAt,
159
+ version: record.version
160
+ });
161
+ continue;
162
+ }
163
+
164
+ records.push(this.normalizeRecord(record));
165
+ }
166
+
167
+ return records;
168
+ }
169
+
170
+ async update(collectionName, id, partialData, options = {}) {
171
+ const existing = this.getCollection(collectionName).get(id);
172
+
173
+ if (!existing || existing.deletedAt) {
174
+ throw new Error(`Object ${id} does not exist in ${collectionName}`);
175
+ }
176
+
177
+ if (existing.ownerId !== this.nodeId) {
178
+ throw new Error(`Only owner ${existing.ownerId} can update object ${id}`);
179
+ }
180
+
181
+ const operation = {
182
+ opId: this.idGenerator(),
183
+ kind: 'update',
184
+ collectionName,
185
+ id,
186
+ actorId: this.nodeId,
187
+ timestamp: this.now(),
188
+ baseVersion: existing.version,
189
+ payload: { ...partialData }
190
+ };
191
+
192
+ this.applyOperation(operation);
193
+ await this.broadcastMessage('operation', operation, {
194
+ broadcastScope: options.broadcastScope || this.resolveBroadcastScope({
195
+ messageType: 'operation',
196
+ operation,
197
+ collectionName
198
+ })
199
+ });
200
+
201
+ return this.read(collectionName, id);
202
+ }
203
+
204
+ async remove(collectionName, id, options = {}) {
205
+ const existing = this.getCollection(collectionName).get(id);
206
+
207
+ if (!existing || existing.deletedAt) {
208
+ throw new Error(`Object ${id} does not exist in ${collectionName}`);
209
+ }
210
+
211
+ if (existing.ownerId !== this.nodeId) {
212
+ throw new Error(`Only owner ${existing.ownerId} can delete object ${id}`);
213
+ }
214
+
215
+ const operation = {
216
+ opId: this.idGenerator(),
217
+ kind: 'delete',
218
+ collectionName,
219
+ id,
220
+ actorId: this.nodeId,
221
+ timestamp: this.now(),
222
+ baseVersion: existing.version
223
+ };
224
+
225
+ this.applyOperation(operation);
226
+ await this.broadcastMessage('operation', operation, {
227
+ broadcastScope: options.broadcastScope || this.resolveBroadcastScope({
228
+ messageType: 'operation',
229
+ operation,
230
+ collectionName
231
+ })
232
+ });
233
+ }
234
+
235
+ registerPeerPublicKey(peerId, publicKey) {
236
+ this.securityService.registerPeerPublicKey(peerId, publicKey);
237
+ }
238
+
239
+ getPublicKey() {
240
+ return this.securityService.getPublicKey();
241
+ }
242
+
243
+ async broadcastMessage(messageType, payload, securityContext = {}) {
244
+ const envelope = await this.securityService.secureOutgoingMessage({
245
+ messageType,
246
+ payload,
247
+ targetId: null,
248
+ securityContext
249
+ });
250
+ await this.networkAdapter.broadcast(envelope);
251
+ }
252
+
253
+ async sendDirectMessage(targetId, messageType, payload) {
254
+ const envelope = await this.securityService.secureOutgoingMessage({
255
+ messageType,
256
+ payload,
257
+ targetId
258
+ });
259
+ await this.networkAdapter.broadcast(envelope);
260
+ }
261
+
262
+ getPresenceMap(scope) {
263
+ if (!this.presenceByScope.has(scope)) {
264
+ this.presenceByScope.set(scope, new Map());
265
+ }
266
+
267
+ return this.presenceByScope.get(scope);
268
+ }
269
+
270
+ upsertPresence(scope, peerId, metadata, ttlMs, announcedAt) {
271
+ const map = this.getPresenceMap(scope);
272
+ const existing = map.get(peerId);
273
+ const next = {
274
+ peerId,
275
+ scope,
276
+ metadata: metadata ? { ...metadata } : {},
277
+ lastSeenAt: announcedAt,
278
+ expiresAt: announcedAt + ttlMs
279
+ };
280
+ map.set(peerId, next);
281
+
282
+ if (!existing) {
283
+ this.emit('peerdiscovered', { scope, peerId, metadata: next.metadata });
284
+ }
285
+
286
+ return next;
287
+ }
288
+
289
+ prunePresence(scope) {
290
+ const map = this.presenceByScope.get(scope);
291
+ if (!map) {
292
+ return;
293
+ }
294
+
295
+ const now = this.now();
296
+ for (const [peerId, entry] of map.entries()) {
297
+ if (entry.expiresAt <= now) {
298
+ map.delete(peerId);
299
+ this.emit('peerleft', { scope, peerId, reason: 'timeout' });
300
+ }
301
+ }
302
+ }
303
+
304
+ async joinDiscovery(scope = 'main', options = {}) {
305
+ const normalizedScope = scope || 'main';
306
+ const heartbeatIntervalMs = options.heartbeatIntervalMs || this.defaultDiscoveryHeartbeatMs;
307
+ const ttlMs = options.ttlMs || this.defaultPresenceTtlMs;
308
+ const metadata = options.metadata || {};
309
+
310
+ const existing = this.discoveryRooms.get(normalizedScope);
311
+ if (existing && existing.timer) {
312
+ clearInterval(existing.timer);
313
+ }
314
+
315
+ const timer = setInterval(() => {
316
+ this.announcePresence(normalizedScope).catch((error) => {
317
+ this.emit('warning', { type: 'presence-heartbeat-failed', scope: normalizedScope, error });
318
+ });
319
+ }, heartbeatIntervalMs);
320
+
321
+ this.discoveryRooms.set(normalizedScope, {
322
+ metadata,
323
+ heartbeatIntervalMs,
324
+ ttlMs,
325
+ timer
326
+ });
327
+
328
+ this.upsertPresence(normalizedScope, this.nodeId, metadata, ttlMs, this.now());
329
+ await this.announcePresence(normalizedScope);
330
+ }
331
+
332
+ async announcePresence(scope = 'main', metadataOverride = null) {
333
+ const normalizedScope = scope || 'main';
334
+ const room = this.discoveryRooms.get(normalizedScope);
335
+ if (!room) {
336
+ throw new Error(`Scope ${normalizedScope} has not been joined for discovery`);
337
+ }
338
+
339
+ const metadata = metadataOverride || room.metadata || {};
340
+ const announcedAt = this.now();
341
+ this.upsertPresence(normalizedScope, this.nodeId, metadata, room.ttlMs, announcedAt);
342
+
343
+ await this.broadcastMessage(
344
+ 'presence:announce',
345
+ {
346
+ scope: normalizedScope,
347
+ peerId: this.nodeId,
348
+ metadata,
349
+ ttlMs: room.ttlMs,
350
+ announcedAt
351
+ },
352
+ { broadcastScope: normalizedScope }
353
+ );
354
+ }
355
+
356
+ async leaveDiscovery(scope = 'main') {
357
+ const normalizedScope = scope || 'main';
358
+ const room = this.discoveryRooms.get(normalizedScope);
359
+ if (!room) {
360
+ return;
361
+ }
362
+
363
+ if (room.timer) {
364
+ clearInterval(room.timer);
365
+ }
366
+ this.discoveryRooms.delete(normalizedScope);
367
+
368
+ const map = this.presenceByScope.get(normalizedScope);
369
+ if (map) {
370
+ map.delete(this.nodeId);
371
+ }
372
+
373
+ await this.broadcastMessage(
374
+ 'presence:leave',
375
+ {
376
+ scope: normalizedScope,
377
+ peerId: this.nodeId,
378
+ leftAt: this.now()
379
+ },
380
+ { broadcastScope: normalizedScope }
381
+ );
382
+ }
383
+
384
+ listPeers(scope = 'main', options = {}) {
385
+ const normalizedScope = scope || 'main';
386
+ const includeSelf = options.includeSelf !== false;
387
+ this.prunePresence(normalizedScope);
388
+
389
+ const map = this.presenceByScope.get(normalizedScope);
390
+ if (!map) {
391
+ return [];
392
+ }
393
+
394
+ return Array.from(map.values())
395
+ .filter((entry) => includeSelf || entry.peerId !== this.nodeId)
396
+ .map((entry) => ({
397
+ peerId: entry.peerId,
398
+ scope: entry.scope,
399
+ metadata: { ...entry.metadata },
400
+ lastSeenAt: entry.lastSeenAt,
401
+ expiresAt: entry.expiresAt
402
+ }));
403
+ }
404
+
405
+ async handleIncomingMessage(message) {
406
+ // Backward compatibility for raw operation payloads
407
+ if (message && message.opId && message.kind) {
408
+ this.applyOperation(message);
409
+ return;
410
+ }
411
+
412
+ if (message && message.senderId && this.isPeerBanned(message.senderId)) {
413
+ this.emit('messageignored', {
414
+ senderId: message.senderId,
415
+ reason: 'peer-banned'
416
+ });
417
+ return;
418
+ }
419
+
420
+ let decrypted;
421
+ try {
422
+ decrypted = await this.securityService.decryptIncomingMessage(message);
423
+ } catch (error) {
424
+ const senderId = message ? message.senderId : null;
425
+ if (senderId && (error.code === 'INVALID_SIGNATURE' || error.code === 'INVALID_POW')) {
426
+ this.banPeer(senderId, this.peerBanDurationMs, error.code);
427
+ }
428
+
429
+ this.emit('securityerror', {
430
+ senderId,
431
+ error
432
+ });
433
+ return;
434
+ }
435
+
436
+ if (!decrypted || decrypted.ignored) {
437
+ return;
438
+ }
439
+
440
+ if (decrypted.messageType === 'operation') {
441
+ this.applyOperation(decrypted.payload);
442
+ return;
443
+ }
444
+
445
+ if (decrypted.messageType === 'presence:announce') {
446
+ const payload = decrypted.payload || {};
447
+ const scope = payload.scope || 'main';
448
+ const peerId = payload.peerId || decrypted.senderId;
449
+ if (!peerId) {
450
+ return;
451
+ }
452
+
453
+ const presenceMap = this.getPresenceMap(scope);
454
+ const isNewPeerInScope = !presenceMap.has(peerId);
455
+
456
+ this.upsertPresence(
457
+ scope,
458
+ peerId,
459
+ payload.metadata || {},
460
+ payload.ttlMs || this.defaultPresenceTtlMs,
461
+ payload.announcedAt || this.now()
462
+ );
463
+
464
+ // Discovery handshake: when a new peer appears in a joined scope,
465
+ // send our current presence so late joiners quickly converge.
466
+ if (isNewPeerInScope && peerId !== this.nodeId && this.discoveryRooms.has(scope)) {
467
+ this.announcePresence(scope).catch((error) => {
468
+ this.emit('warning', { type: 'presence-handshake-failed', scope, error });
469
+ });
470
+ }
471
+ return;
472
+ }
473
+
474
+ if (decrypted.messageType === 'presence:leave') {
475
+ const payload = decrypted.payload || {};
476
+ const scope = payload.scope || 'main';
477
+ const peerId = payload.peerId || decrypted.senderId;
478
+ const map = this.presenceByScope.get(scope);
479
+ if (map && peerId && map.has(peerId)) {
480
+ map.delete(peerId);
481
+ this.emit('peerleft', { scope, peerId, reason: 'leave' });
482
+ }
483
+ return;
484
+ }
485
+
486
+ this.emit('message', {
487
+ senderId: decrypted.senderId,
488
+ targetId: decrypted.targetId,
489
+ type: decrypted.messageType,
490
+ payload: decrypted.payload
491
+ });
492
+ }
493
+
494
+ banPeer(peerId, durationMs = this.peerBanDurationMs, reason = 'manual') {
495
+ if (!peerId) {
496
+ return;
497
+ }
498
+
499
+ const bannedUntil = this.now() + Math.max(1, durationMs);
500
+ this.bannedPeers.set(peerId, {
501
+ peerId,
502
+ reason,
503
+ bannedAt: this.now(),
504
+ bannedUntil
505
+ });
506
+
507
+ this.emit('peerbanned', {
508
+ peerId,
509
+ reason,
510
+ bannedUntil
511
+ });
512
+ }
513
+
514
+ unbanPeer(peerId) {
515
+ this.bannedPeers.delete(peerId);
516
+ this.emit('peerunbanned', { peerId });
517
+ }
518
+
519
+ getBanInfo(peerId) {
520
+ const info = this.bannedPeers.get(peerId);
521
+ if (!info) {
522
+ return null;
523
+ }
524
+
525
+ if (info.bannedUntil <= this.now()) {
526
+ this.bannedPeers.delete(peerId);
527
+ return null;
528
+ }
529
+
530
+ return { ...info };
531
+ }
532
+
533
+ isPeerBanned(peerId) {
534
+ return this.getBanInfo(peerId) !== null;
535
+ }
536
+
537
+ applyOperation(operation) {
538
+ if (!operation || !operation.opId || this.appliedOperations.has(operation.opId)) {
539
+ return false;
540
+ }
541
+
542
+ const collection = this.getCollection(operation.collectionName);
543
+ const current = collection.get(operation.id);
544
+
545
+ if (operation.kind === 'create') {
546
+ if (current && !current.deletedAt) {
547
+ return false;
548
+ }
549
+
550
+ collection.set(operation.id, {
551
+ id: operation.id,
552
+ ownerId: operation.ownerId,
553
+ data: { ...operation.payload },
554
+ createdAt: operation.timestamp,
555
+ updatedAt: operation.timestamp,
556
+ deletedAt: null,
557
+ version: 1
558
+ });
559
+
560
+ this.appliedOperations.add(operation.opId);
561
+ this.emit('change', { kind: 'create', collection: operation.collectionName, id: operation.id });
562
+ return true;
563
+ }
564
+
565
+ if (!current || current.deletedAt) {
566
+ return false;
567
+ }
568
+
569
+ if (operation.actorId !== current.ownerId) {
570
+ return false;
571
+ }
572
+
573
+ if (typeof operation.baseVersion === 'number' && operation.baseVersion !== current.version) {
574
+ return false;
575
+ }
576
+
577
+ if (operation.kind === 'update') {
578
+ current.data = {
579
+ ...current.data,
580
+ ...operation.payload
581
+ };
582
+ current.updatedAt = operation.timestamp;
583
+ current.version += 1;
584
+
585
+ this.appliedOperations.add(operation.opId);
586
+ this.emit('change', { kind: 'update', collection: operation.collectionName, id: operation.id });
587
+ return true;
588
+ }
589
+
590
+ if (operation.kind === 'delete') {
591
+ current.deletedAt = operation.timestamp;
592
+ current.updatedAt = operation.timestamp;
593
+ current.version += 1;
594
+
595
+ this.appliedOperations.add(operation.opId);
596
+ this.emit('change', { kind: 'delete', collection: operation.collectionName, id: operation.id });
597
+ return true;
598
+ }
599
+
600
+ return false;
601
+ }
602
+ }
603
+
604
+ module.exports = DignityP2P;
package/src/index.js ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * dignity.js public API.
3
+ *
4
+ * This package exposes:
5
+ * - `DignityP2P`: REST-like object CRUD over peer-to-peer operation replication
6
+ * - signaling providers and pool helpers
7
+ * - in-memory adapter utilities for tests and local prototyping
8
+ */
9
+ const DignityP2P = require('./core/dignity-p2p');
10
+ const createDefaultSignalingPool = require('./signaling/create-default-signaling-pool');
11
+ const SignalingPool = require('./signaling/signaling-pool');
12
+ const WebSocketSignalingProvider = require('./signaling/websocket-signaling-provider');
13
+ const {
14
+ InMemoryNetworkHub,
15
+ InMemoryNetworkAdapter
16
+ } = require('./network/in-memory-network');
17
+ const {
18
+ DEFAULT_CLOUDFLARE_SIGNALING_URLS,
19
+ DEFAULT_SIGNALING_FALLBACK_URLS
20
+ } = require('./signaling/default-signaling-config');
21
+ const VDF = require('./security/vdf');
22
+ const SlothPermutation = require('./security/sloth-vdf');
23
+ const {
24
+ MessageSecurityService,
25
+ DEFAULT_SECURITY_OPTIONS
26
+ } = require('./security/message-security-service');
27
+
28
+ module.exports = {
29
+ DignityP2P,
30
+ createDefaultSignalingPool,
31
+ SignalingPool,
32
+ WebSocketSignalingProvider,
33
+ InMemoryNetworkHub,
34
+ InMemoryNetworkAdapter,
35
+ DEFAULT_CLOUDFLARE_SIGNALING_URLS,
36
+ DEFAULT_SIGNALING_FALLBACK_URLS,
37
+ VDF,
38
+ SlothPermutation,
39
+ MessageSecurityService,
40
+ DEFAULT_SECURITY_OPTIONS
41
+ };
@@ -0,0 +1,77 @@
1
+ class InMemoryNetworkHub {
2
+ constructor() {
3
+ this.adapters = new Map();
4
+ }
5
+
6
+ register(adapter) {
7
+ this.adapters.set(adapter.nodeId, adapter);
8
+ }
9
+
10
+ unregister(nodeId) {
11
+ this.adapters.delete(nodeId);
12
+ }
13
+
14
+ async broadcast(senderId, message) {
15
+ const deliveries = [];
16
+ for (const [nodeId, adapter] of this.adapters.entries()) {
17
+ if (nodeId !== senderId) {
18
+ deliveries.push(adapter.receive(message));
19
+ }
20
+ }
21
+ await Promise.all(deliveries);
22
+ }
23
+ }
24
+
25
+ class InMemoryNetworkAdapter {
26
+ constructor(hub) {
27
+ if (!hub) {
28
+ throw new Error('InMemoryNetworkAdapter requires an InMemoryNetworkHub');
29
+ }
30
+
31
+ this.hub = hub;
32
+ this.nodeId = null;
33
+ this.messageHandlers = new Set();
34
+ }
35
+
36
+ async start(nodeId) {
37
+ this.nodeId = nodeId;
38
+ this.hub.register(this);
39
+ }
40
+
41
+ async stop() {
42
+ if (this.nodeId) {
43
+ this.hub.unregister(this.nodeId);
44
+ }
45
+
46
+ this.nodeId = null;
47
+ }
48
+
49
+ async broadcast(message) {
50
+ if (!this.nodeId) {
51
+ throw new Error('Network adapter has not been started');
52
+ }
53
+
54
+ await this.hub.broadcast(this.nodeId, message);
55
+ }
56
+
57
+ onMessage(handler) {
58
+ this.messageHandlers.add(handler);
59
+ }
60
+
61
+ offMessage(handler) {
62
+ this.messageHandlers.delete(handler);
63
+ }
64
+
65
+ async receive(message) {
66
+ const deliveries = [];
67
+ for (const handler of this.messageHandlers) {
68
+ deliveries.push(handler(message));
69
+ }
70
+ await Promise.all(deliveries);
71
+ }
72
+ }
73
+
74
+ module.exports = {
75
+ InMemoryNetworkHub,
76
+ InMemoryNetworkAdapter
77
+ };