egos-transfer 0.2.6 → 0.2.7

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,565 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ var __importDefault = (this && this.__importDefault) || function (mod) {
12
+ return (mod && mod.__esModule) ? mod : { "default": mod };
13
+ };
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.PeerTransfer = exports.PEER_CLOSE_RECONNECT_TIMEOUT = void 0;
16
+ const constant_1 = require("./constant");
17
+ const peerjs_1 = require("peerjs");
18
+ const entity_1 = require("./entity");
19
+ const connection_manager_1 = require("./connection-manager");
20
+ const router_1 = require("./router");
21
+ const axios_1 = __importDefault(require("axios"));
22
+ const delay_1 = __importDefault(require("delay"));
23
+ const MAX_RECONNECT_BACKOFF = 30000;
24
+ const BASE_RECONNECT_BACKOFF = 1000;
25
+ /** 30 分钟无消息活动则重建 Peer,防止长时间空闲后 signaling 连接僵死 */
26
+ const IDLE_RECREATE_TIMEOUT = 30 * 60 * 1000;
27
+ /** 30 秒未收到服务端 HEARTBEAT 则重连 */
28
+ const HEARTBEAT_TIMEOUT = 30000;
29
+ exports.PEER_CLOSE_RECONNECT_TIMEOUT = 3000;
30
+ class PeerTransfer {
31
+ constructor(deviceId, config) {
32
+ this.requests = new Map();
33
+ this.router = new router_1.PeerRouter();
34
+ this.reconnectAttempt = 0;
35
+ this.destroyed = false;
36
+ /** connectionId → Set<requestId> — settle in-flight requests on connection drop */
37
+ this.connRequests = new Map();
38
+ this._reconnecting = false;
39
+ /** 退出标志:为 true 时禁止自动重连,直到 createPeer 被调用 */
40
+ this._exited = false;
41
+ /** 空闲重连定时器:超时无消息活动则重建 Peer */
42
+ this.idleTimer = null;
43
+ /** 心跳超时定时器:30 秒未收到 HEARTBEAT 则重连 */
44
+ this.heartbeatTimer = null;
45
+ this.peerConfig = Object.assign(Object.assign({}, config), { key: String(Date.now()) });
46
+ this.peerId = deviceId;
47
+ this.deviceId = deviceId;
48
+ this.connectionManager = new connection_manager_1.ConnectionManager({
49
+ getPeer: () => this.peer,
50
+ peerId: this.peerId,
51
+ onConnClosed: (conn) => this.onConnClosed(conn),
52
+ ping: (peerId) => this.ping(peerId),
53
+ });
54
+ }
55
+ // ── Peer lifecycle ──────────────────────────────────────────────
56
+ createPeer(force) {
57
+ return __awaiter(this, void 0, void 0, function* () {
58
+ var _a, _b;
59
+ console.info('createPeer##', (_a = this.peer) === null || _a === void 0 ? void 0 : _a.open, force);
60
+ this._exited = false;
61
+ if (((_b = this.peer) === null || _b === void 0 ? void 0 : _b.open) && !force) {
62
+ return this.peer;
63
+ }
64
+ this.destroyPeer();
65
+ yield this.reloadConfig();
66
+ const peer = new peerjs_1.Peer(this.peerId, Object.assign(Object.assign({}, this.peerConfig), { pingInterval: 9000 }));
67
+ this.peer = peer;
68
+ yield this.waitForOpen(peer);
69
+ this.destroyed = false;
70
+ peer.on('close', () => {
71
+ setTimeout(() => this.destroyed && this.reconnect(), exports.PEER_CLOSE_RECONNECT_TIMEOUT);
72
+ });
73
+ peer.on('error', (err) => {
74
+ // 覆盖所有与 signaling server 断连相关的错误类型
75
+ if (err.type === 'network' ||
76
+ err.type === 'server-error' ||
77
+ err.type === 'disconnected' ||
78
+ err.type === 'socket-error' ||
79
+ err.type === 'socket-closed') {
80
+ this.reconnect();
81
+ }
82
+ });
83
+ peer.on('connection', (conn) => {
84
+ conn.label = 'incoming';
85
+ this.onConnect(conn);
86
+ });
87
+ const socket = peer.socket;
88
+ socket === null || socket === void 0 ? void 0 : socket.on('message', (message) => {
89
+ this.handleSocketMessage(message);
90
+ });
91
+ // 启动连接健康检查
92
+ this.connectionManager.startHealthCheck();
93
+ // 启动空闲重连定时器
94
+ this.resetIdleTimer();
95
+ // 启动心跳超时定时器
96
+ this.resetHeartbeatTimer();
97
+ return peer;
98
+ });
99
+ }
100
+ waitForOpen(peer) {
101
+ return new Promise((resolve) => {
102
+ const timer = setTimeout(() => resolve(), constant_1.PEER_OPEN_TIMEOUT);
103
+ peer.on('open', () => {
104
+ clearTimeout(timer);
105
+ this.reconnectAttempt = 0;
106
+ resolve();
107
+ });
108
+ peer.once('error', (err) => {
109
+ if (err.message === constant_1.CONNECTION_DUPLICATED) {
110
+ clearTimeout(timer);
111
+ // signaling server 仍持有旧连接,主动 destroy 以清理服务端状态
112
+ peer.destroy();
113
+ resolve();
114
+ return;
115
+ }
116
+ clearTimeout(timer);
117
+ if (err.message === constant_1.CONNECTION_PEER_CONFLICT) {
118
+ resolve();
119
+ return;
120
+ }
121
+ if (err.message === constant_1.CONNECTION_LIMIT_EXCEED) {
122
+ this.onConnectLimit();
123
+ resolve();
124
+ return;
125
+ }
126
+ if (err.message === constant_1.UNAUTHORIZED) {
127
+ this.requireAuth();
128
+ resolve();
129
+ return;
130
+ }
131
+ resolve();
132
+ });
133
+ });
134
+ }
135
+ handleSocketMessage(message) {
136
+ var _a;
137
+ switch (message.type) {
138
+ case 'ONLINE':
139
+ this.online(message.payload.deviceId);
140
+ break;
141
+ case 'OFFLINE':
142
+ this.ping(message.payload.deviceId);
143
+ break;
144
+ case 'HEARTBEAT':
145
+ this.resetHeartbeatTimer();
146
+ break;
147
+ case 'ERROR':
148
+ if (((_a = message.payload) === null || _a === void 0 ? void 0 : _a.msg) === constant_1.CONNECTION_PEER_CONFLICT) {
149
+ this.getStore().dispatch({
150
+ type: 'global/updateNetworkState',
151
+ payload: { error: true, code: 'PEER_CONNECTION_CONFLICT', message: 'peer conflict' },
152
+ });
153
+ }
154
+ break;
155
+ }
156
+ }
157
+ // ── Connection management ───────────────────────────────────────
158
+ connectToPeer(peerId) {
159
+ return __awaiter(this, void 0, void 0, function* () {
160
+ if (peerId === this.deviceId)
161
+ return;
162
+ const existing = this.connectionManager.get(peerId);
163
+ if (existing) {
164
+ return existing;
165
+ }
166
+ return this.createConnection(peerId);
167
+ });
168
+ }
169
+ createConnection(peerId) {
170
+ return __awaiter(this, void 0, void 0, function* () {
171
+ var _a, _b;
172
+ for (let retry = 0; retry < 3; retry++) {
173
+ if (!((_a = this.peer) === null || _a === void 0 ? void 0 : _a.open)) {
174
+ try {
175
+ yield this.createPeer();
176
+ }
177
+ catch (_c) {
178
+ /* fall through */
179
+ }
180
+ }
181
+ if (!((_b = this.peer) === null || _b === void 0 ? void 0 : _b.open))
182
+ continue;
183
+ const pooled = this.connectionManager.get(peerId);
184
+ if (pooled) {
185
+ return pooled;
186
+ }
187
+ const conn = this.peer.connect(peerId, {
188
+ label: 'outgoing',
189
+ metadata: { time: Date.now() },
190
+ });
191
+ if (!conn)
192
+ continue;
193
+ const result = yield this.onConnect(conn).catch(() => undefined);
194
+ if (result) {
195
+ return result;
196
+ }
197
+ const pooled2 = this.connectionManager.get(peerId);
198
+ if (pooled2) {
199
+ return pooled2;
200
+ }
201
+ }
202
+ // 所有重试均失败,强制销毁 peer 以确保下次连接使用全新实例
203
+ // (peer.open 可能在 signaling socket 已断连的情况下仍返回 true)
204
+ if (!this.destroyed) {
205
+ this.destroyPeer();
206
+ }
207
+ return undefined;
208
+ });
209
+ }
210
+ onConnect(conn) {
211
+ return __awaiter(this, void 0, void 0, function* () {
212
+ if (!conn)
213
+ return;
214
+ if (conn.open) {
215
+ this.setupConnection(conn);
216
+ return conn;
217
+ }
218
+ return new Promise((resolve) => {
219
+ const timer = setTimeout(() => {
220
+ conn.off('open', onOpen);
221
+ conn.off('error', onError);
222
+ conn.off('close', onClose);
223
+ resolve(this.isValidConnection(conn) ? this.setupConnection(conn) : undefined);
224
+ }, constant_1.CONNECTION_TIMEOUT);
225
+ const onOpen = () => {
226
+ clearTimeout(timer);
227
+ resolve(this.setupConnection(conn));
228
+ };
229
+ const onError = () => {
230
+ clearTimeout(timer);
231
+ const pooled = this.connectionManager.get(conn.peer);
232
+ resolve(pooled);
233
+ };
234
+ const onClose = () => {
235
+ clearTimeout(timer);
236
+ resolve(undefined);
237
+ this.removeConnection(conn.peer, conn.connectionId);
238
+ };
239
+ conn.once('open', onOpen);
240
+ conn.once('error', onError);
241
+ conn.once('close', onClose);
242
+ });
243
+ });
244
+ }
245
+ setupConnection(conn) {
246
+ return this.connectionManager.setup(conn, (message) => {
247
+ switch (message.scope) {
248
+ case entity_1.PeerScope.REQUEST:
249
+ return this.onRequest(conn, message);
250
+ case entity_1.PeerScope.RESPONSE:
251
+ return this.onResponse(conn, message);
252
+ default:
253
+ return this.responseFail(conn, message, 'unknown scope');
254
+ }
255
+ });
256
+ }
257
+ onConnClosed(conn) {
258
+ const reqIds = this.connRequests.get(conn.connectionId);
259
+ if (reqIds) {
260
+ // for (const reqId of reqIds) {
261
+ // const cb = this.requests.get(reqId);
262
+ // if (cb) {
263
+ // cb({ error: { message: 'connection closed', code: 'CONN_CLOSED' } } as any);
264
+ // this.requests.delete(reqId);
265
+ // }
266
+ // }
267
+ this.connRequests.delete(conn.connectionId);
268
+ }
269
+ }
270
+ removeConnection(peerId, connectionId) {
271
+ this.connectionManager.remove(peerId, connectionId);
272
+ }
273
+ isValidConnection(conn) {
274
+ return this.connectionManager.isValid(conn);
275
+ }
276
+ // ── Request / Reply ─────────────────────────────────────────────
277
+ request(peerId, payload) {
278
+ return __awaiter(this, void 0, void 0, function* () {
279
+ var _a;
280
+ const requestId = payload.requestId || (yield this.genRequestId());
281
+ let res;
282
+ for (let attempt = 0; attempt <= 2; payload.route, attempt++) {
283
+ res = yield this.doRequestOnce(peerId, payload, requestId);
284
+ const code = (_a = res.error) === null || _a === void 0 ? void 0 : _a.code;
285
+ if (!res.error || (code !== 'CONN_CLOSED' && code !== 'CONN_FAILED')) {
286
+ return res;
287
+ }
288
+ }
289
+ return res;
290
+ });
291
+ }
292
+ doRequestOnce(peerId, payload, requestId) {
293
+ return __awaiter(this, void 0, void 0, function* () {
294
+ if (this.destroyed) {
295
+ return Object.assign(Object.assign({}, payload), { body: {}, error: { message: 'destroyed', code: 'CONN_CLOSED' } });
296
+ }
297
+ const conn = yield this.connectToPeer(peerId);
298
+ if (!this.isValidConnection(conn)) {
299
+ return Object.assign(Object.assign({}, payload), { body: {}, error: { message: 'connection failed', code: 'CONN_FAILED' } });
300
+ }
301
+ return new Promise((resolve) => {
302
+ let timeout = null;
303
+ const cleanup = () => {
304
+ if (timeout) {
305
+ clearTimeout(timeout);
306
+ timeout = null;
307
+ }
308
+ this.requests.delete(requestId);
309
+ const reqs = this.connRequests.get(conn.connectionId);
310
+ if (reqs) {
311
+ reqs.delete(requestId);
312
+ // connection pool cleanup
313
+ if (reqs.size === 0) {
314
+ this.connRequests.delete(conn.connectionId);
315
+ }
316
+ }
317
+ };
318
+ timeout = setTimeout(() => {
319
+ cleanup();
320
+ if (!this.isValidConnection(conn)) {
321
+ this.removeConnection(conn.peer, conn.connectionId);
322
+ }
323
+ resolve(Object.assign(Object.assign({}, payload), { body: {}, error: { message: 'timeout', code: 'TIMEOUT' } }));
324
+ }, payload.timeout || entity_1.REQUEST_TIMEOUT);
325
+ let reqs = this.connRequests.get(conn.connectionId);
326
+ if (!reqs) {
327
+ reqs = new Set();
328
+ this.connRequests.set(conn.connectionId, reqs);
329
+ }
330
+ reqs.add(requestId);
331
+ this.requests.set(requestId, (message) => {
332
+ cleanup();
333
+ resolve(message);
334
+ });
335
+ this.send(conn, Object.assign(Object.assign({}, payload), { requestId })).catch(() => {
336
+ cleanup();
337
+ resolve(Object.assign(Object.assign({}, payload), { body: {}, error: { message: 'send failed', code: 'CONN_CLOSED' } }));
338
+ });
339
+ });
340
+ });
341
+ }
342
+ reply(conn, oldMessage, message) {
343
+ return __awaiter(this, void 0, void 0, function* () {
344
+ const data = Object.assign(Object.assign(Object.assign({}, this.inheritMessage(oldMessage)), message), { dest: conn.peer, scope: entity_1.PeerScope.RESPONSE, updatedAt: Date.now() });
345
+ if (!this.isValidConnection(conn)) {
346
+ const c = yield this.connectToPeer(conn.peer);
347
+ return c === null || c === void 0 ? void 0 : c.send(data);
348
+ }
349
+ return conn.send(data);
350
+ });
351
+ }
352
+ send(conn, message) {
353
+ return __awaiter(this, void 0, void 0, function* () {
354
+ const internalApi = yield this.getApi(conn.peer);
355
+ let connection = conn;
356
+ if (!this.isValidConnection(conn)) {
357
+ const c = yield this.connectToPeer(conn.peer);
358
+ if (!c)
359
+ return this.responseFail(conn, message, 'send with invalid connection');
360
+ connection = c;
361
+ }
362
+ const requestId = message.requestId || (yield this.genRequestId());
363
+ return connection.send(Object.assign(Object.assign({}, message), { requestId, headers: Object.assign(Object.assign({}, message.headers), { ['x-device-token']: internalApi === null || internalApi === void 0 ? void 0 : internalApi.token }), scope: entity_1.PeerScope.REQUEST, action: entity_1.PeerAction.SEND, src: this.peerId, dest: conn.peer, createdAt: Date.now() }), false);
364
+ });
365
+ }
366
+ // ── Ping ────────────────────────────────────────────────────────
367
+ ping(peerId) {
368
+ return __awaiter(this, void 0, void 0, function* () {
369
+ if (this.deviceId === peerId) {
370
+ return false;
371
+ }
372
+ const conn = this.connectionManager.get(peerId);
373
+ if (!conn) {
374
+ return false;
375
+ }
376
+ try {
377
+ const requestId = yield this.genRequestId();
378
+ return new Promise((resolve) => {
379
+ const timer = setTimeout(() => {
380
+ this.requests.delete(requestId);
381
+ resolve(false);
382
+ }, constant_1.PING_TIMEOUT);
383
+ this.requests.set(requestId, () => {
384
+ clearTimeout(timer);
385
+ resolve(true);
386
+ });
387
+ conn.send({
388
+ route: entity_1.PeerRoutes.HEARTBEAT,
389
+ method: 'get',
390
+ body: {},
391
+ requestId,
392
+ scope: entity_1.PeerScope.REQUEST,
393
+ action: entity_1.PeerAction.SEND,
394
+ src: this.peerId,
395
+ dest: peerId,
396
+ headers: {},
397
+ createdAt: Date.now(),
398
+ });
399
+ });
400
+ }
401
+ catch (_a) {
402
+ return false;
403
+ }
404
+ });
405
+ }
406
+ // ── Server helpers ──────────────────────────────────────────────
407
+ getApiHost() {
408
+ var _a, _b, _c;
409
+ const protocol = ((_a = this.peerConfig) === null || _a === void 0 ? void 0 : _a.secure) ? 'https' : 'http';
410
+ const host = ((_b = this.peerConfig) === null || _b === void 0 ? void 0 : _b.host) || 'localhost';
411
+ const port = ((_c = this.peerConfig) === null || _c === void 0 ? void 0 : _c.port) || 9000;
412
+ return `${protocol}://${host}:${port}`;
413
+ }
414
+ getDeviceStatus(peerId) {
415
+ return __awaiter(this, void 0, void 0, function* () {
416
+ try {
417
+ const res = yield axios_1.default.get(`${this.getApiHost()}/api/v1/devices/${peerId}/status`);
418
+ if (res.status < 400)
419
+ return res.data.data;
420
+ }
421
+ catch (_a) {
422
+ /* ignore */
423
+ }
424
+ });
425
+ }
426
+ // ── Reconnection ────────────────────────────────────────────────
427
+ reconnect(force) {
428
+ return __awaiter(this, void 0, void 0, function* () {
429
+ if (this._reconnecting || this._exited)
430
+ return;
431
+ this._reconnecting = true;
432
+ try {
433
+ this.reconnectAttempt++;
434
+ const backoff = Math.min(BASE_RECONNECT_BACKOFF * Math.pow(2, this.reconnectAttempt - 1), MAX_RECONNECT_BACKOFF);
435
+ yield (0, delay_1.default)(backoff);
436
+ yield this.reloadConfig();
437
+ return yield this.createPeer(force);
438
+ }
439
+ catch (_a) {
440
+ return this.createPeer(force);
441
+ }
442
+ finally {
443
+ this._reconnecting = false;
444
+ }
445
+ });
446
+ }
447
+ // ── Message helpers ─────────────────────────────────────────────
448
+ inheritMessage(receiveMsg) {
449
+ return {
450
+ src: this.peerId,
451
+ id: receiveMsg.id,
452
+ requestId: receiveMsg.requestId,
453
+ route: receiveMsg.route,
454
+ createdAt: receiveMsg.createdAt,
455
+ action: entity_1.PeerAction.RECEIVE,
456
+ };
457
+ }
458
+ // ── Router integration ──────────────────────────────────────────
459
+ onRequest(conn, message) {
460
+ return __awaiter(this, void 0, void 0, function* () {
461
+ this.resetIdleTimer();
462
+ try {
463
+ yield this.router.run(conn, message);
464
+ }
465
+ catch (err) {
466
+ this.responseFail(conn, message, { message: err.message, code: err.code });
467
+ }
468
+ });
469
+ }
470
+ onResponse(_conn, message) {
471
+ return __awaiter(this, void 0, void 0, function* () {
472
+ this.resetIdleTimer();
473
+ // 优先检查 health check ping 回调
474
+ const cb = this.requests.get(message.requestId);
475
+ if (cb) {
476
+ cb.apply(this, [message]);
477
+ this.requests.delete(message.requestId);
478
+ }
479
+ });
480
+ }
481
+ responseOk(conn, message) {
482
+ return __awaiter(this, void 0, void 0, function* () {
483
+ return this.reply(conn, message, { body: { ok: true } });
484
+ });
485
+ }
486
+ responseFail(conn, message, err) {
487
+ return __awaiter(this, void 0, void 0, function* () {
488
+ return this.reply(conn, message, { body: {}, error: err || 'service error' });
489
+ });
490
+ }
491
+ use(handler, prefix = '') {
492
+ this.router.use(handler, prefix);
493
+ }
494
+ addRoute(route) {
495
+ this.router.add(route);
496
+ }
497
+ // ── Lifecycle ───────────────────────────────────────────────────
498
+ resetIdleTimer() {
499
+ if (this.idleTimer !== null) {
500
+ clearTimeout(this.idleTimer);
501
+ }
502
+ this.idleTimer = setTimeout(() => {
503
+ if (!this.destroyed) {
504
+ this.reconnect(true);
505
+ }
506
+ }, IDLE_RECREATE_TIMEOUT);
507
+ }
508
+ clearIdleTimer() {
509
+ if (this.idleTimer !== null) {
510
+ clearTimeout(this.idleTimer);
511
+ this.idleTimer = null;
512
+ }
513
+ }
514
+ resetHeartbeatTimer() {
515
+ if (this.heartbeatTimer !== null) {
516
+ clearTimeout(this.heartbeatTimer);
517
+ }
518
+ this.heartbeatTimer = setTimeout(() => {
519
+ if (!this.destroyed) {
520
+ this.reconnect(true);
521
+ }
522
+ }, HEARTBEAT_TIMEOUT);
523
+ }
524
+ clearHeartbeatTimer() {
525
+ if (this.heartbeatTimer !== null) {
526
+ clearTimeout(this.heartbeatTimer);
527
+ this.heartbeatTimer = null;
528
+ }
529
+ }
530
+ clearInstance() {
531
+ this.destroyed = true;
532
+ this.clearIdleTimer();
533
+ this.clearHeartbeatTimer();
534
+ this.connectionManager.destroy();
535
+ this.requests.clear();
536
+ this.connRequests.clear();
537
+ this.destroyPeer();
538
+ }
539
+ destroy() {
540
+ this.clearInstance();
541
+ this.onDestroy();
542
+ }
543
+ /**
544
+ * 退出:关闭 peer 和所有连接,停止自动重连。
545
+ * 与 destroy stop 后仍可调用 createPeer 恢复工作。
546
+ */
547
+ stop() {
548
+ this._exited = true;
549
+ this.clearIdleTimer();
550
+ this.clearHeartbeatTimer();
551
+ this.connectionManager.stopHealthCheck();
552
+ this.connectionManager.clean();
553
+ this.requests.clear();
554
+ this.connRequests.clear();
555
+ this.destroyPeer();
556
+ this.reconnectAttempt = 0;
557
+ }
558
+ destroyPeer() {
559
+ var _a, _b;
560
+ (_a = this.peer) === null || _a === void 0 ? void 0 : _a.destroy();
561
+ (_b = this.peer) === null || _b === void 0 ? void 0 : _b.removeAllListeners();
562
+ this.peer = undefined;
563
+ }
564
+ }
565
+ exports.PeerTransfer = PeerTransfer;
package/dist/server.d.ts CHANGED
@@ -2,10 +2,6 @@ import { PeerMessage, RequestCallbackFn, RouteItem } from './interfaces';
2
2
  import { DataConnection } from 'peerjs';
3
3
  import { PeerRouter } from './router';
4
4
  import { PeerTransfer } from './index';
5
- export interface ContentPayload {
6
- file: string;
7
- parent: string;
8
- }
9
5
  type ResponseCb<T = any> = (args: T) => void;
10
6
  type SetupServerCB = () => Promise<PeerTransfer>;
11
7
  export declare class PeerServer {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "egos-transfer",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "1234",
5
5
  "homepage": "https://github.com/superbogy/peer-transfer#readme",
6
6
  "bugs": {