@streamorchard/grapevine 0.0.0-stage → 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,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 };