@streamorchard/grapevine 0.0.0-stage → 0.1.1
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/LICENSE +674 -0
- package/PROTOCOL.md +266 -0
- package/README.md +161 -2
- package/assets/apps/pear-drops.png +0 -0
- package/assets/apps/stream-multiview.png +0 -0
- package/fixtures/drops.farming/1/invalid/bad-time.json +1 -0
- package/fixtures/drops.farming/1/invalid/channel-without-slug.json +4 -0
- package/fixtures/drops.farming/1/invalid/no-state.json +1 -0
- package/fixtures/drops.farming/1/valid/farming.json +11 -0
- package/fixtures/drops.farming/1/valid/future-state-and-field.json +13 -0
- package/fixtures/drops.farming/1/valid/get-result.json +1 -0
- package/fixtures/drops.farming/1/valid/waiting.json +4 -0
- package/fixtures/index.js +36 -0
- package/fixtures/players/1/invalid/no-open-list.json +4 -0
- package/fixtures/players/1/invalid/signed-in-not-boolean.json +4 -0
- package/fixtures/players/1/valid/no-sites.json +1 -0
- package/fixtures/players/1/valid/signed-out.json +18 -0
- package/package.json +66 -4
- package/schemas/common.json +40 -0
- package/schemas/drops.farming/1.json +45 -0
- package/schemas/players/1.json +43 -0
- package/src/connection.js +316 -0
- package/src/errors.js +39 -0
- package/src/framing.js +65 -0
- package/src/handshake.js +134 -0
- package/src/index.js +421 -0
- package/src/keys.js +71 -0
- package/src/known-apps.js +63 -0
- package/src/paths.js +85 -0
- package/src/report.js +49 -0
- package/src/rpc.js +92 -0
- package/src/schema.js +125 -0
- package/stand-in-peer.js +109 -0
- package/types/capabilities.d.ts +77 -0
- package/types/index.d.ts +198 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "Types every capability shares. Lists marked x-open may gain values: an app must accept a value it doesn't know.",
|
|
3
|
+
"defs": {
|
|
4
|
+
"Timestamp": { "type": "string", "format": "date-time", "description": "ISO 8601, in UTC" },
|
|
5
|
+
"Platform": {
|
|
6
|
+
"type": "string",
|
|
7
|
+
"pattern": "^[a-z0-9-]{1,32}$",
|
|
8
|
+
"x-open": ["kick", "twitch", "youtube"],
|
|
9
|
+
"description": "A streaming site"
|
|
10
|
+
},
|
|
11
|
+
"Channel": {
|
|
12
|
+
"type": "object",
|
|
13
|
+
"required": ["platform", "slug"],
|
|
14
|
+
"properties": {
|
|
15
|
+
"platform": { "$ref": "common:Platform" },
|
|
16
|
+
"slug": { "type": "string", "minLength": 1, "maxLength": 100, "description": "As in the channel's address" },
|
|
17
|
+
"name": { "type": "string", "maxLength": 200, "description": "As shown on the site" }
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"Account": {
|
|
21
|
+
"type": "object",
|
|
22
|
+
"required": ["platform", "user"],
|
|
23
|
+
"description": "Who an app is signed in as: the username only, never a token or cookie",
|
|
24
|
+
"properties": {
|
|
25
|
+
"platform": { "$ref": "common:Platform" },
|
|
26
|
+
"user": { "type": "string", "minLength": 1, "maxLength": 100 }
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"Progress": {
|
|
30
|
+
"type": "object",
|
|
31
|
+
"required": ["done", "total", "unit"],
|
|
32
|
+
"properties": {
|
|
33
|
+
"done": { "type": "number", "minimum": 0 },
|
|
34
|
+
"total": { "type": "number", "minimum": 0 },
|
|
35
|
+
"unit": { "type": "string", "x-open": ["minutes"] }
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"Empty": { "type": "object" }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"capability": "drops.farming",
|
|
3
|
+
"major": 1,
|
|
4
|
+
"minor": 0,
|
|
5
|
+
"description": "What a drops app is farming. Provided by Pear Drops.",
|
|
6
|
+
"events": {
|
|
7
|
+
"drops.farming.changed": { "$ref": "#State" }
|
|
8
|
+
},
|
|
9
|
+
"methods": {
|
|
10
|
+
"drops.farming.get": { "params": { "$ref": "common:Empty" }, "result": { "$ref": "#State" } }
|
|
11
|
+
},
|
|
12
|
+
"defs": {
|
|
13
|
+
"State": {
|
|
14
|
+
"type": "object",
|
|
15
|
+
"required": ["seq", "at", "state", "account", "channel", "drop"],
|
|
16
|
+
"properties": {
|
|
17
|
+
"seq": { "type": "integer", "minimum": 0, "description": "Counts up with each change; 0 for a reply to drops.farming.get" },
|
|
18
|
+
"at": { "$ref": "common:Timestamp" },
|
|
19
|
+
"state": {
|
|
20
|
+
"type": "string",
|
|
21
|
+
"minLength": 1,
|
|
22
|
+
"maxLength": 32,
|
|
23
|
+
"x-open": ["farming", "waiting", "paused", "done", "signed-out"],
|
|
24
|
+
"description": "farming: its player is watching a stream for drop credit. Any other value, known or not, means it isn't."
|
|
25
|
+
},
|
|
26
|
+
"account": { "anyOf": [{ "$ref": "common:Account" }, { "type": "null" }] },
|
|
27
|
+
"channel": { "anyOf": [{ "$ref": "common:Channel" }, { "type": "null" }], "description": "The stream being watched, while farming" },
|
|
28
|
+
"drop": {
|
|
29
|
+
"anyOf": [
|
|
30
|
+
{
|
|
31
|
+
"type": "object",
|
|
32
|
+
"required": ["campaign", "category", "progress"],
|
|
33
|
+
"properties": {
|
|
34
|
+
"campaign": { "type": "string", "maxLength": 200 },
|
|
35
|
+
"category": { "type": "string", "maxLength": 200 },
|
|
36
|
+
"progress": { "$ref": "common:Progress" }
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
{ "type": "null" }
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"capability": "players",
|
|
3
|
+
"major": 1,
|
|
4
|
+
"minor": 0,
|
|
5
|
+
"description": "An app's stream players, by site: whether they're signed in, and which channels are open. Provided by Stream Multiview.",
|
|
6
|
+
"events": {
|
|
7
|
+
"players.changed": { "$ref": "#State" }
|
|
8
|
+
},
|
|
9
|
+
"methods": {
|
|
10
|
+
"players.get": { "params": { "$ref": "common:Empty" }, "result": { "$ref": "#State" } }
|
|
11
|
+
},
|
|
12
|
+
"defs": {
|
|
13
|
+
"State": {
|
|
14
|
+
"type": "object",
|
|
15
|
+
"required": ["seq", "at", "platforms"],
|
|
16
|
+
"properties": {
|
|
17
|
+
"seq": { "type": "integer", "minimum": 0 },
|
|
18
|
+
"at": { "$ref": "common:Timestamp" },
|
|
19
|
+
"platforms": {
|
|
20
|
+
"type": "object",
|
|
21
|
+
"description": "Keyed by Platform; a site with no players open may be left out",
|
|
22
|
+
"additionalProperties": { "$ref": "#Site" }
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"Site": {
|
|
27
|
+
"type": "object",
|
|
28
|
+
"required": ["signedIn", "mode", "account", "open"],
|
|
29
|
+
"properties": {
|
|
30
|
+
"signedIn": { "type": "boolean", "description": "Whether the players play as the account right now" },
|
|
31
|
+
"mode": {
|
|
32
|
+
"type": "string",
|
|
33
|
+
"minLength": 1,
|
|
34
|
+
"maxLength": 32,
|
|
35
|
+
"x-open": ["signed-in", "signed-out", "auto"],
|
|
36
|
+
"description": "auto: signed in, except while a connected drops app is farming"
|
|
37
|
+
},
|
|
38
|
+
"account": { "anyOf": [{ "$ref": "common:Account" }, { "type": "null" }] },
|
|
39
|
+
"open": { "type": "array", "maxItems": 100, "items": { "$ref": "common:Channel" } }
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// One connection to another app, from the hello to the end: pairing and proofs, then requests and
|
|
2
|
+
// events for the capabilities both apps agreed on, a ping to notice a hung app, and a message limit.
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
const { EventEmitter } = require('events');
|
|
6
|
+
const { LineReader, encode } = require('./framing');
|
|
7
|
+
const { Rpc } = require('./rpc');
|
|
8
|
+
const { CODES, GrapevineError } = require('./errors');
|
|
9
|
+
const hs = require('./handshake');
|
|
10
|
+
|
|
11
|
+
const TIMING = Object.freeze({
|
|
12
|
+
handshakeMs: 3000, // the hello and the proofs must be done by then
|
|
13
|
+
pingMs: 20_000, // the connecting app pings this often
|
|
14
|
+
pongMs: 10_000, // and closes if there's no answer in this long
|
|
15
|
+
rateMax: 50, // messages a second
|
|
16
|
+
strikes: 3, // over the limit this many times in a minute closes the connection
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* What a connection needs from the link that owns it.
|
|
21
|
+
* @typedef {object} Host
|
|
22
|
+
* @property {object} self this app's description (the hello's "app")
|
|
23
|
+
* @property {{provides: Record<string, string[]>, uses: Record<string, string[]>}} mine
|
|
24
|
+
* @property {{get(id: string): {key: string, pairedAt?: string} | null, set(id: string, key: string): void, delete(id: string): void}} keys
|
|
25
|
+
* @property {(id: string) => boolean} allowed whether this app's switch for that app is on
|
|
26
|
+
* @property {import('./schema').Schemas} schemas
|
|
27
|
+
* @property {(conn: Connection, method: string, params: any) => Promise<any>} call runs a capability method
|
|
28
|
+
* @property {(conn: Connection, events: string[]) => string[]} subscribe
|
|
29
|
+
* @property {(conn: Connection) => void} [afterSubscribe] sends the latest state to a new subscriber
|
|
30
|
+
* @property {(level: string, text: string) => void} log
|
|
31
|
+
* @property {typeof TIMING} timing
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
class Connection extends EventEmitter {
|
|
35
|
+
/**
|
|
36
|
+
* @param {import('net').Socket} socket
|
|
37
|
+
* @param {Host} host
|
|
38
|
+
* @param {{initiator: boolean, expectedId?: string}} role
|
|
39
|
+
*/
|
|
40
|
+
constructor(socket, host, { initiator, expectedId }) {
|
|
41
|
+
super();
|
|
42
|
+
this.socket = socket;
|
|
43
|
+
this.host = host;
|
|
44
|
+
this.initiator = initiator;
|
|
45
|
+
this.expectedId = expectedId ?? null;
|
|
46
|
+
this.state = 'hello'; // hello -> auth -> ready -> closed (the connecting app: hello -> authing -> ready)
|
|
47
|
+
this.peer = null; // the other app's description, from its hello
|
|
48
|
+
this.peerId = null;
|
|
49
|
+
this.agreed = { incoming: new Map(), outgoing: new Map(), missing: [] };
|
|
50
|
+
this.protocol = null;
|
|
51
|
+
this.subscribed = new Set(); // events the other app subscribed to
|
|
52
|
+
this.queue = []; // messages that came while finishing the proofs
|
|
53
|
+
this.rate = { windowStart: Date.now(), count: 0, strikes: [] };
|
|
54
|
+
this.lastHeard = Date.now();
|
|
55
|
+
this.closeReason = null;
|
|
56
|
+
|
|
57
|
+
this.rpc = new Rpc((m) => this.send(m), {
|
|
58
|
+
onRequest: (m) => this.onRequest(m),
|
|
59
|
+
onNotification: (m) => this.onNotification(m),
|
|
60
|
+
});
|
|
61
|
+
this.reader = new LineReader(
|
|
62
|
+
(m) => this.onMessage(m),
|
|
63
|
+
(why) => {
|
|
64
|
+
if (why === 'too-long') this.close('A message was too long');
|
|
65
|
+
else
|
|
66
|
+
this.rpc.fail(
|
|
67
|
+
null,
|
|
68
|
+
new GrapevineError(why === 'parse' ? CODES.PARSE_ERROR : CODES.INVALID_REQUEST, why === 'parse' ? 'Not JSON' : 'Not a JSON object'),
|
|
69
|
+
);
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
socket.on('data', (d) => this.reader.push(d));
|
|
73
|
+
socket.on('error', (/** @type {NodeJS.ErrnoException} */ e) => this.close(`Socket error: ${e.code ?? e.message}`));
|
|
74
|
+
socket.on('close', () => this.close('The other app closed the connection'));
|
|
75
|
+
this.handshakeTimer = setTimeout(() => this.close('The hello took too long'), host.timing.handshakeMs);
|
|
76
|
+
this.handshakeTimer.unref?.();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
send(message) {
|
|
80
|
+
if (this.state !== 'closed' && !this.socket.destroyed) this.socket.write(encode(message));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ------------------------------------------------------------------ the connecting app
|
|
84
|
+
|
|
85
|
+
/** Says hello, checks the reply's proof, proves itself, and is ready. */
|
|
86
|
+
async start() {
|
|
87
|
+
const { self, mine, keys } = this.host;
|
|
88
|
+
const stored = keys.get(this.expectedId);
|
|
89
|
+
const clientNonce = hs.nonce();
|
|
90
|
+
let res;
|
|
91
|
+
try {
|
|
92
|
+
res = await this.rpc.request(
|
|
93
|
+
'link.hello',
|
|
94
|
+
{ protocol: hs.PROTOCOL, app: self, capabilities: hs.helloCapabilities(mine.provides, mine.uses), auth: { nonce: clientNonce, pair: !stored } },
|
|
95
|
+
{ timeout: this.host.timing.handshakeMs },
|
|
96
|
+
);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
return this.refused(err);
|
|
99
|
+
}
|
|
100
|
+
const problems = hs.checkApp(res?.app);
|
|
101
|
+
if (problems.length) return this.close(`Its hello reply isn't valid: ${problems.join('; ')}`);
|
|
102
|
+
if (this.expectedId && res.app.id !== this.expectedId) return this.close(`Expected ${this.expectedId}, but ${res.app.id} answered`);
|
|
103
|
+
if (hs.agreeProtocol(hs.PROTOCOL, { min: res.protocol, max: res.protocol }) === null)
|
|
104
|
+
return this.close(`It chose protocol ${res.protocol}, which this app doesn't speak`);
|
|
105
|
+
const key = stored?.key ?? res.auth?.key;
|
|
106
|
+
if (typeof key !== 'string' || Buffer.from(key, 'base64').length !== 32) return this.close('No pairing key');
|
|
107
|
+
if (!hs.proofOk(key, 'server', clientNonce, res.auth?.proof)) {
|
|
108
|
+
this.emit('status', { status: 'pair-again', keyHeldBy: stored ? 'us' : 'them' });
|
|
109
|
+
return this.close("Its proof was wrong: the pairing doesn't match");
|
|
110
|
+
}
|
|
111
|
+
this.peer = res.app;
|
|
112
|
+
this.peerId = res.app.id;
|
|
113
|
+
this.protocol = res.protocol;
|
|
114
|
+
this.agreed = hs.agree(mine, res.capabilities);
|
|
115
|
+
this.state = 'authing';
|
|
116
|
+
try {
|
|
117
|
+
await this.rpc.request('link.auth', { proof: hs.proof(key, 'client', res.auth.nonce) }, { timeout: this.host.timing.handshakeMs });
|
|
118
|
+
} catch (err) {
|
|
119
|
+
return this.refused(err);
|
|
120
|
+
}
|
|
121
|
+
if (!stored) keys.set(this.peerId, key);
|
|
122
|
+
this.ready();
|
|
123
|
+
for (const m of this.queue.splice(0)) this.onMessage(m);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
refused(err) {
|
|
127
|
+
if (err.code === CODES.NOT_PAIRED && err.data?.reason === 'pair-again') {
|
|
128
|
+
this.emit('status', { status: 'pair-again', keyHeldBy: err.data.keyHeldBy === 'listener' ? 'them' : 'us' });
|
|
129
|
+
} else if (err.code === CODES.SWITCHED_OFF) this.emit('status', { status: 'waiting', reason: 'switched-off' });
|
|
130
|
+
else if (err.code === CODES.UNSUPPORTED) this.emit('status', { status: 'incompatible', theirProtocol: err.data?.protocol ?? null });
|
|
131
|
+
this.close(`Refused: ${err.message}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ------------------------------------------------------------------ the listening app
|
|
135
|
+
|
|
136
|
+
handleHello(id, params) {
|
|
137
|
+
const problems = hs.checkHello(params);
|
|
138
|
+
if (problems.length) return this.reject(id, new GrapevineError(CODES.INVALID_PARAMS, 'Invalid hello', { problems }));
|
|
139
|
+
const protocol = hs.agreeProtocol(hs.PROTOCOL, params.protocol);
|
|
140
|
+
if (protocol === null) {
|
|
141
|
+
this.emit('status', { status: 'incompatible', peer: params.app, theirProtocol: params.protocol });
|
|
142
|
+
return this.reject(id, new GrapevineError(CODES.UNSUPPORTED, 'No protocol version in common', { protocol: hs.PROTOCOL }));
|
|
143
|
+
}
|
|
144
|
+
const peerId = params.app.id;
|
|
145
|
+
if (!this.host.allowed(peerId))
|
|
146
|
+
return this.reject(id, new GrapevineError(CODES.SWITCHED_OFF, `${this.host.self.name} isn't set to connect with ${params.app.name}`));
|
|
147
|
+
const stored = this.host.keys.get(peerId);
|
|
148
|
+
let key;
|
|
149
|
+
if (params.auth.pair) {
|
|
150
|
+
// An app asking to pair while this one still has a key: only once you press Pair again here.
|
|
151
|
+
if (stored) {
|
|
152
|
+
this.emit('status', { status: 'pair-again', peer: params.app, keyHeldBy: 'us' });
|
|
153
|
+
return this.reject(id, new GrapevineError(CODES.NOT_PAIRED, 'Already paired with a different key', { reason: 'pair-again', keyHeldBy: 'listener' }));
|
|
154
|
+
}
|
|
155
|
+
key = hs.newKey();
|
|
156
|
+
} else {
|
|
157
|
+
if (!stored) {
|
|
158
|
+
this.emit('status', { status: 'pair-again', peer: params.app, keyHeldBy: 'them' });
|
|
159
|
+
return this.reject(id, new GrapevineError(CODES.NOT_PAIRED, 'Not paired', { reason: 'pair-again', keyHeldBy: 'connector' }));
|
|
160
|
+
}
|
|
161
|
+
key = stored.key;
|
|
162
|
+
}
|
|
163
|
+
const serverNonce = hs.nonce();
|
|
164
|
+
this.pending = { key, serverNonce, pairing: !!params.auth.pair };
|
|
165
|
+
this.peer = params.app;
|
|
166
|
+
this.peerId = peerId;
|
|
167
|
+
this.protocol = protocol;
|
|
168
|
+
this.agreed = hs.agree(this.host.mine, params.capabilities);
|
|
169
|
+
this.state = 'auth';
|
|
170
|
+
const agreed = {};
|
|
171
|
+
for (const [cap, v] of [...this.agreed.incoming, ...this.agreed.outgoing]) agreed[cap] = String(v.major);
|
|
172
|
+
this.rpc.reply(id, {
|
|
173
|
+
protocol,
|
|
174
|
+
app: this.host.self,
|
|
175
|
+
capabilities: hs.helloCapabilities(this.host.mine.provides, this.host.mine.uses),
|
|
176
|
+
agreed,
|
|
177
|
+
auth: { proof: hs.proof(key, 'server', params.auth.nonce), nonce: serverNonce, ...(this.pending.pairing ? { key } : {}) },
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
handleAuth(id, params) {
|
|
182
|
+
const { key, serverNonce, pairing } = this.pending;
|
|
183
|
+
if (!hs.proofOk(key, 'client', serverNonce, params.proof)) {
|
|
184
|
+
this.emit('status', { status: 'pair-again', peer: this.peer, keyHeldBy: 'them' });
|
|
185
|
+
return this.reject(id, new GrapevineError(CODES.NOT_PAIRED, 'Wrong proof', { reason: 'pair-again', keyHeldBy: 'connector' }));
|
|
186
|
+
}
|
|
187
|
+
if (pairing) this.host.keys.set(this.peerId, key);
|
|
188
|
+
this.pending = null;
|
|
189
|
+
this.rpc.reply(id, {});
|
|
190
|
+
this.ready();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Refuses a request, then closes. */
|
|
194
|
+
reject(id, err) {
|
|
195
|
+
this.rpc.fail(id, err);
|
|
196
|
+
this.close(err.message);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ------------------------------------------------------------------ ready
|
|
200
|
+
|
|
201
|
+
ready() {
|
|
202
|
+
clearTimeout(this.handshakeTimer);
|
|
203
|
+
this.state = 'ready';
|
|
204
|
+
this.host.log('info', `Connected to ${this.peer.name} ${this.peer.version}`);
|
|
205
|
+
if (this.initiator) {
|
|
206
|
+
this.pingTimer = setInterval(() => {
|
|
207
|
+
this.rpc.request('link.ping', {}, { timeout: this.host.timing.pongMs }).catch((err) => {
|
|
208
|
+
if (err.code === CODES.TIMEOUT) this.close('The other app stopped answering');
|
|
209
|
+
});
|
|
210
|
+
}, this.host.timing.pingMs);
|
|
211
|
+
} else {
|
|
212
|
+
// The listening app hears a ping at least every pingMs; three missed means it's hung.
|
|
213
|
+
this.pingTimer = setInterval(() => {
|
|
214
|
+
if (Date.now() - this.lastHeard > this.host.timing.pingMs * 3) this.close('The other app stopped answering');
|
|
215
|
+
}, this.host.timing.pingMs);
|
|
216
|
+
}
|
|
217
|
+
this.pingTimer.unref?.();
|
|
218
|
+
this.emit('ready');
|
|
219
|
+
// Every event of every capability this app uses from the other one.
|
|
220
|
+
const events = [];
|
|
221
|
+
for (const [cap, { major }] of this.agreed.incoming) events.push(...Object.keys(this.host.schemas.get(cap, major)?.events ?? {}));
|
|
222
|
+
if (events.length) {
|
|
223
|
+
this.rpc.request('link.subscribe', { events }).catch((err) => this.host.log('warn', `Couldn't subscribe to ${this.peer?.name}: ${err.message}`));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ------------------------------------------------------------------ incoming
|
|
228
|
+
|
|
229
|
+
onMessage(m) {
|
|
230
|
+
this.lastHeard = Date.now();
|
|
231
|
+
if (this.state === 'authing' && !('result' in m || 'error' in m)) return void this.queue.push(m);
|
|
232
|
+
if (!this.withinRate(m)) return;
|
|
233
|
+
this.rpc.handle(m);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** At most rateMax messages a second; going over it three times in a minute closes the connection. */
|
|
237
|
+
withinRate(m) {
|
|
238
|
+
const now = Date.now();
|
|
239
|
+
const r = this.rate;
|
|
240
|
+
if (now - r.windowStart >= 1000) {
|
|
241
|
+
r.windowStart = now;
|
|
242
|
+
r.count = 0;
|
|
243
|
+
}
|
|
244
|
+
r.count += 1;
|
|
245
|
+
if (r.count <= this.host.timing.rateMax) return true;
|
|
246
|
+
if (r.count === this.host.timing.rateMax + 1) {
|
|
247
|
+
r.strikes = r.strikes.filter((t) => now - t < 60_000).concat(now);
|
|
248
|
+
if (r.strikes.length >= this.host.timing.strikes) {
|
|
249
|
+
this.close('Too many messages');
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if ('id' in m && typeof m.method === 'string') this.rpc.fail(m.id, new GrapevineError(CODES.TOO_MANY, 'Too many messages: slow down'));
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async onRequest({ id, method, params }) {
|
|
258
|
+
if (this.state === 'hello' && !this.initiator && method === 'link.hello') return this.handleHello(id, params);
|
|
259
|
+
if (this.state === 'auth' && method === 'link.auth') return this.handleAuth(id, params);
|
|
260
|
+
if (this.state !== 'ready') return this.rpc.fail(id, new GrapevineError(CODES.NOT_PAIRED, 'Say hello and prove the pairing first'));
|
|
261
|
+
if (method === 'link.ping') return this.rpc.reply(id, {});
|
|
262
|
+
if (method === 'link.subscribe') {
|
|
263
|
+
const subscribed = this.host.subscribe(this, Array.isArray(params.events) ? params.events.filter((e) => typeof e === 'string') : []);
|
|
264
|
+
this.rpc.reply(id, { subscribed });
|
|
265
|
+
return this.host.afterSubscribe?.(this);
|
|
266
|
+
}
|
|
267
|
+
if (method === 'link.unpair') {
|
|
268
|
+
this.host.keys.delete(this.peerId);
|
|
269
|
+
this.rpc.reply(id, {});
|
|
270
|
+
this.emit('unpaired');
|
|
271
|
+
return this.close('Disconnected in the other app');
|
|
272
|
+
}
|
|
273
|
+
try {
|
|
274
|
+
this.rpc.reply(id, await this.host.call(this, method, params));
|
|
275
|
+
} catch (err) {
|
|
276
|
+
this.rpc.fail(id, err instanceof GrapevineError ? err : new GrapevineError(CODES.INTERNAL_ERROR, 'The app failed to answer'));
|
|
277
|
+
if (!(err instanceof GrapevineError)) this.host.log('error', `${method} failed: ${err?.message ?? err}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
onNotification({ method, params }) {
|
|
282
|
+
if (method === 'link.bye') return this.close(`The other app said goodbye${params.reason ? ` (${params.reason})` : ''}`);
|
|
283
|
+
if (this.state !== 'ready') return;
|
|
284
|
+
const cap = this.host.schemas.capabilityOf(method);
|
|
285
|
+
const agreed = cap && this.agreed.incoming.get(cap);
|
|
286
|
+
if (!agreed) return this.host.log('warn', `Ignored ${method}: not agreed with ${this.peer.name}`);
|
|
287
|
+
const problems = this.host.schemas.validate(cap, agreed.major, 'event', method, params);
|
|
288
|
+
if (problems.length) return this.host.log('warn', `Dropped an invalid ${method} from ${this.peer.name}: ${problems.join('; ')}`);
|
|
289
|
+
this.emit('event', method, params);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ------------------------------------------------------------------ the end
|
|
293
|
+
|
|
294
|
+
/** Says goodbye (a reason the other app can show) and closes. */
|
|
295
|
+
bye(reason) {
|
|
296
|
+
this.rpc.notify('link.bye', { reason });
|
|
297
|
+
this.close(reason);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
close(reason) {
|
|
301
|
+
if (this.state === 'closed') return;
|
|
302
|
+
const wasReady = this.state === 'ready';
|
|
303
|
+
this.state = 'closed';
|
|
304
|
+
this.closeReason = reason;
|
|
305
|
+
clearTimeout(this.handshakeTimer);
|
|
306
|
+
clearInterval(this.pingTimer);
|
|
307
|
+
this.rpc.close();
|
|
308
|
+
// end() lets a last reply (why it was refused) reach the other app; destroy() if it doesn't finish.
|
|
309
|
+
this.socket.end();
|
|
310
|
+
setTimeout(() => this.socket.destroy(), 500).unref?.();
|
|
311
|
+
if (wasReady) this.host.log('info', `Disconnected from ${this.peer?.name}: ${reason}`);
|
|
312
|
+
this.emit('close', reason, wasReady);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
module.exports = { Connection, TIMING };
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// JSON-RPC 2.0's error codes, and the ones Grapevine adds (PROTOCOL.md, "Errors").
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const CODES = Object.freeze({
|
|
5
|
+
PARSE_ERROR: -32700,
|
|
6
|
+
INVALID_REQUEST: -32600,
|
|
7
|
+
METHOD_NOT_FOUND: -32601,
|
|
8
|
+
INVALID_PARAMS: -32602,
|
|
9
|
+
INTERNAL_ERROR: -32603,
|
|
10
|
+
NOT_PAIRED: -32001, // not paired yet, or the proof was wrong
|
|
11
|
+
NOT_AGREED: -32002, // the capability wasn't agreed on this connection
|
|
12
|
+
UNSUPPORTED: -32003, // no version both apps support
|
|
13
|
+
TOO_MANY: -32004, // too many messages
|
|
14
|
+
SWITCHED_OFF: -32005, // the app's switch for the other app is off
|
|
15
|
+
CLOSED: -32099, // the connection closed before the reply came (local only, never sent)
|
|
16
|
+
TIMEOUT: -32098, // no reply in time (local only, never sent)
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/** An error with a JSON-RPC code, thrown by request() and by handlers that want a specific code. */
|
|
20
|
+
class GrapevineError extends Error {
|
|
21
|
+
/**
|
|
22
|
+
* @param {number} code
|
|
23
|
+
* @param {string} message
|
|
24
|
+
* @param {unknown} [data]
|
|
25
|
+
*/
|
|
26
|
+
constructor(code, message, data) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = 'GrapevineError';
|
|
29
|
+
this.code = code;
|
|
30
|
+
this.data = data;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The `error` member of a JSON-RPC reply. */
|
|
34
|
+
toJSON() {
|
|
35
|
+
return this.data === undefined ? { code: this.code, message: this.message } : { code: this.code, message: this.message, data: this.data };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = { CODES, GrapevineError };
|
package/src/framing.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// A socket carries bytes, not messages: each message is one line of JSON ending in "\n".
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { StringDecoder } = require('string_decoder');
|
|
5
|
+
|
|
6
|
+
const MAX_LINE = 1024 * 1024; // bytes; a longer line closes the connection
|
|
7
|
+
|
|
8
|
+
/** Turns a message into its line. */
|
|
9
|
+
function encode(message) {
|
|
10
|
+
return `${JSON.stringify(message)}\n`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Collects bytes into lines and parses each one. `onMessage(object)` gets every JSON object,
|
|
15
|
+
* `onBad(reason)` anything else: 'parse' (not JSON), 'not-object' (an array, number, …) or
|
|
16
|
+
* 'too-long' (over the limit, after which the reader stops).
|
|
17
|
+
*/
|
|
18
|
+
class LineReader {
|
|
19
|
+
/**
|
|
20
|
+
* @param {(message: Record<string, unknown>) => void} onMessage
|
|
21
|
+
* @param {(reason: 'parse' | 'not-object' | 'too-long') => void} onBad
|
|
22
|
+
* @param {number} [max]
|
|
23
|
+
*/
|
|
24
|
+
constructor(onMessage, onBad, max = MAX_LINE) {
|
|
25
|
+
this.onMessage = onMessage;
|
|
26
|
+
this.onBad = onBad;
|
|
27
|
+
this.max = max;
|
|
28
|
+
this.buffer = '';
|
|
29
|
+
this.done = false;
|
|
30
|
+
this.decoder = new StringDecoder('utf8'); // a character split across two chunks stays whole
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** @param {Buffer | string} chunk */
|
|
34
|
+
push(chunk) {
|
|
35
|
+
if (this.done) return;
|
|
36
|
+
this.buffer += typeof chunk === 'string' ? chunk : this.decoder.write(chunk);
|
|
37
|
+
for (let at = this.buffer.indexOf('\n'); at !== -1; at = this.buffer.indexOf('\n')) {
|
|
38
|
+
const line = this.buffer.slice(0, at);
|
|
39
|
+
this.buffer = this.buffer.slice(at + 1);
|
|
40
|
+
if (Buffer.byteLength(line) > this.max) return this.stop();
|
|
41
|
+
if (line.trim()) this.parse(line);
|
|
42
|
+
if (this.done) return;
|
|
43
|
+
}
|
|
44
|
+
if (Buffer.byteLength(this.buffer) > this.max) this.stop();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
parse(line) {
|
|
48
|
+
let value;
|
|
49
|
+
try {
|
|
50
|
+
value = JSON.parse(line);
|
|
51
|
+
} catch {
|
|
52
|
+
return this.onBad('parse');
|
|
53
|
+
}
|
|
54
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return this.onBad('not-object');
|
|
55
|
+
this.onMessage(value);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
stop() {
|
|
59
|
+
this.done = true;
|
|
60
|
+
this.buffer = '';
|
|
61
|
+
this.onBad('too-long');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = { MAX_LINE, encode, LineReader };
|