@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,63 @@
|
|
|
1
|
+
// The apps that speak Grapevine, so each one can show the others on its Connected apps page,
|
|
2
|
+
// even when they aren't installed: name, summary, logo, where to get it, and which release first
|
|
3
|
+
// supported each capability version (to say "Update Pear Drops to 0.1.7"). Add a new app here.
|
|
4
|
+
'use strict';
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
const ICONS = path.join(__dirname, '..', 'assets', 'apps');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {object} KnownApp
|
|
13
|
+
* @property {string} id the app's install id (the hello's app.id)
|
|
14
|
+
* @property {string} slug its socket name
|
|
15
|
+
* @property {string} name
|
|
16
|
+
* @property {string} summary one sentence, for its card
|
|
17
|
+
* @property {string} icon a bundled 256×256 PNG
|
|
18
|
+
* @property {string} homepage
|
|
19
|
+
* @property {string} download its latest release
|
|
20
|
+
* @property {string} dataFolder its folder in the OS's app data folder: no folder means not installed
|
|
21
|
+
* @property {Record<string, string>} firstRelease "capability@major.minor" (or "protocol@N") -> the app version that first had it
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** @type {KnownApp[]} */
|
|
25
|
+
const KNOWN_APPS = [
|
|
26
|
+
{
|
|
27
|
+
id: 'com.streamorchard.peardrops',
|
|
28
|
+
slug: 'pear-drops',
|
|
29
|
+
name: 'Pear Drops',
|
|
30
|
+
summary: "Collects your Kick emote and badge drops for you, watching with Kick's own player.",
|
|
31
|
+
icon: path.join(ICONS, 'pear-drops.png'),
|
|
32
|
+
homepage: 'https://github.com/Stream-Orchard/pear-drops',
|
|
33
|
+
download: 'https://github.com/Stream-Orchard/pear-drops/releases/latest',
|
|
34
|
+
dataFolder: 'Pear Drops',
|
|
35
|
+
firstRelease: {},
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: 'com.glowingpear.streammultiview',
|
|
39
|
+
slug: 'stream-multiview',
|
|
40
|
+
name: 'Stream Multiview',
|
|
41
|
+
summary: 'Watch Kick, Twitch and YouTube streams and chats side by side, with custom layouts, live alerts and color themes.',
|
|
42
|
+
icon: path.join(ICONS, 'stream-multiview.png'),
|
|
43
|
+
homepage: 'https://github.com/GlowingPear/stream-window-multiview',
|
|
44
|
+
download: 'https://github.com/GlowingPear/stream-window-multiview/releases/latest',
|
|
45
|
+
dataFolder: 'Stream Multiview',
|
|
46
|
+
firstRelease: {},
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
const byId = (id) => KNOWN_APPS.find((a) => a.id === id) ?? null;
|
|
51
|
+
const bySlug = (slug) => KNOWN_APPS.find((a) => a.slug === slug) ?? null;
|
|
52
|
+
|
|
53
|
+
/** Whether an app looks installed: its data folder exists (it's made the first time the app runs). */
|
|
54
|
+
function isInstalled(app, appDataDir, fsImpl = fs) {
|
|
55
|
+
return fsImpl.existsSync(path.join(appDataDir, app.dataFolder));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** An app's logo as a data URL, for a hello or an <img>. */
|
|
59
|
+
function iconDataUrl(file) {
|
|
60
|
+
return `data:image/png;base64,${fs.readFileSync(file).toString('base64')}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { KNOWN_APPS, byId, bySlug, isInstalled, iconDataUrl };
|
package/src/paths.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Where each app's socket lives. One shared, private folder per user (a named pipe prefix on
|
|
2
|
+
// Windows), so apps find each other by listing it. PROTOCOL.md, "Finding apps".
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const crypto = require('crypto');
|
|
9
|
+
|
|
10
|
+
const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
11
|
+
const MAX_SOCKET_PATH = 100; // macOS allows 104 bytes for a socket path, Linux 108
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {object} Place
|
|
15
|
+
* @property {NodeJS.Platform} [platform]
|
|
16
|
+
* @property {NodeJS.ProcessEnv} [env]
|
|
17
|
+
* @property {string} [home]
|
|
18
|
+
* @property {number} [uid]
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** The shared folder: GRAPEVINE_DIR if set (tests, dev copies), else the OS's per-user place. */
|
|
22
|
+
function linkDir({ platform = process.platform, env = process.env, home = os.homedir() } = {}) {
|
|
23
|
+
if (env.GRAPEVINE_DIR) return env.GRAPEVINE_DIR;
|
|
24
|
+
if (platform === 'darwin') return path.join(home, 'Library', 'Application Support', 'Grapevine');
|
|
25
|
+
if (platform === 'win32') return path.join(env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'Grapevine');
|
|
26
|
+
return env.XDG_RUNTIME_DIR ? path.join(env.XDG_RUNTIME_DIR, 'grapevine') : path.join(home, '.local', 'state', 'grapevine');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A short tag for the folder, so pipes of different users (or test folders) never mix on Windows. */
|
|
30
|
+
function tagOf(dir) {
|
|
31
|
+
return crypto.createHash('sha256').update(dir.toLowerCase()).digest('hex').slice(0, 8);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The folder the sockets are actually in: the shared folder, or a short one in /tmp if its paths would be too long. */
|
|
35
|
+
function socketDir(place = {}) {
|
|
36
|
+
const dir = linkDir(place);
|
|
37
|
+
const { platform = process.platform, uid = process.getuid?.() ?? 0 } = place;
|
|
38
|
+
if (platform === 'win32') return dir;
|
|
39
|
+
const longest = path.join(dir, `${'x'.repeat(40)}.sock`);
|
|
40
|
+
return Buffer.byteLength(longest) > MAX_SOCKET_PATH ? path.join('/tmp', `grapevine-${uid}-${tagOf(dir)}`) : dir;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The socket (or pipe) an app listens on. */
|
|
44
|
+
function socketPath(slug, place = {}) {
|
|
45
|
+
if (!SLUG_RE.test(slug)) throw new Error(`"${slug}" isn't a valid app slug (a-z, 0-9 and -, up to 40)`);
|
|
46
|
+
const { platform = process.platform } = place;
|
|
47
|
+
if (platform === 'win32') return `\\\\.\\pipe\\grapevine-${tagOf(linkDir(place))}-${slug}`;
|
|
48
|
+
return path.join(socketDir(place), `${slug}.sock`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The slugs of every app with a socket in the folder right now (running, or left behind by a crash). */
|
|
52
|
+
function listSlugs(place = {}, fsImpl = fs) {
|
|
53
|
+
const { platform = process.platform } = place;
|
|
54
|
+
try {
|
|
55
|
+
if (platform === 'win32') {
|
|
56
|
+
const prefix = `grapevine-${tagOf(linkDir(place))}-`;
|
|
57
|
+
return fsImpl
|
|
58
|
+
.readdirSync('\\\\.\\pipe\\')
|
|
59
|
+
.filter((n) => n.startsWith(prefix))
|
|
60
|
+
.map((n) => n.slice(prefix.length))
|
|
61
|
+
.filter((s) => SLUG_RE.test(s));
|
|
62
|
+
}
|
|
63
|
+
return fsImpl
|
|
64
|
+
.readdirSync(socketDir(place))
|
|
65
|
+
.filter((n) => n.endsWith('.sock'))
|
|
66
|
+
.map((n) => n.slice(0, -5))
|
|
67
|
+
.filter((s) => SLUG_RE.test(s));
|
|
68
|
+
} catch {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Creates the socket folder, private to this user. Refuses one owned by someone else. */
|
|
74
|
+
function ensureDir(place = {}, fsImpl = fs) {
|
|
75
|
+
const { platform = process.platform } = place;
|
|
76
|
+
if (platform === 'win32') return; // pipes don't live in a folder
|
|
77
|
+
const dir = socketDir(place);
|
|
78
|
+
fsImpl.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
79
|
+
const uid = place.uid ?? process.getuid?.();
|
|
80
|
+
const st = fsImpl.statSync(dir);
|
|
81
|
+
if (uid !== undefined && st.uid !== uid) throw new Error(`${dir} belongs to another user`);
|
|
82
|
+
if ((st.mode & 0o077) !== 0) fsImpl.chmodSync(dir, 0o700);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { SLUG_RE, MAX_SOCKET_PATH, linkDir, tagOf, socketDir, socketPath, listSlugs, ensureDir };
|
package/src/report.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The compatibility report for a Connected apps card: works, works with something missing, or
|
|
2
|
+
// can't connect, and which app to update to which release. PROTOCOL.md, "Compatibility".
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
const { byId } = require('./known-apps');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Which app needs updating so that both have `major` of `capability` (or protocol `major`).
|
|
9
|
+
* @param {{id: string, name: string}} self
|
|
10
|
+
* @param {{id: string, name: string}} peer
|
|
11
|
+
* @param {number[]} mine majors this app has
|
|
12
|
+
* @param {number[]} theirs majors the other app has
|
|
13
|
+
* @param {string} feature "capability@" or "protocol@"
|
|
14
|
+
*/
|
|
15
|
+
function whoUpdates(self, peer, mine, theirs, feature) {
|
|
16
|
+
const myTop = Math.max(0, ...mine);
|
|
17
|
+
const theirTop = Math.max(0, ...theirs);
|
|
18
|
+
// The app with the older versions is the one to update, to the newest the other one has.
|
|
19
|
+
const [app, major] = theirTop < myTop ? [peer, myTop] : [self, theirTop];
|
|
20
|
+
const version = byId(app.id)?.firstRelease?.[`${feature}${major}${feature === 'protocol@' ? '' : '.0'}`] ?? null;
|
|
21
|
+
return { app: app.name, appId: app.id, version };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {{id: string, name: string}} self
|
|
26
|
+
* @param {{id: string, name: string}} peer
|
|
27
|
+
* @param {{missing: {capability: string, direction: string, mine: number[], theirs: number[]}[]}} agreed
|
|
28
|
+
* @returns {{state: 'works' | 'limited', missing: {capability: string, update: {app: string, appId: string, version: string | null}}[], text: string}}
|
|
29
|
+
*/
|
|
30
|
+
function compatibility(self, peer, agreed) {
|
|
31
|
+
const missing = agreed.missing.map((m) => ({ capability: m.capability, update: whoUpdates(self, peer, m.mine, m.theirs, `${m.capability}@`) }));
|
|
32
|
+
if (!missing.length) return { state: 'works', missing, text: 'Works' };
|
|
33
|
+
const first = missing[0].update;
|
|
34
|
+
const more = missing.length > 1 ? ` (and ${missing.length - 1} more)` : '';
|
|
35
|
+
return {
|
|
36
|
+
state: 'limited',
|
|
37
|
+
missing,
|
|
38
|
+
text: `Works, with a feature missing: update ${first.app}${first.version ? ` to ${first.version} or later` : ''}${more}`,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The report when no protocol version is in common. */
|
|
43
|
+
function incompatible(self, peer, myProtocol, theirProtocol) {
|
|
44
|
+
const span = (p) => Array.from({ length: p.max - p.min + 1 }, (_, i) => p.min + i);
|
|
45
|
+
const update = whoUpdates(self, peer, span(myProtocol), span(theirProtocol), 'protocol@');
|
|
46
|
+
return { state: 'incompatible', missing: [], update, text: `Can't connect: update ${update.app}${update.version ? ` to ${update.version} or later` : ''}` };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { compatibility, incompatible, whoUpdates };
|
package/src/rpc.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// JSON-RPC 2.0 bookkeeping for one connection: numbered requests waiting for their replies, and
|
|
2
|
+
// sorting what comes in into replies, requests and notifications (events).
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
const { CODES, GrapevineError } = require('./errors');
|
|
6
|
+
|
|
7
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
8
|
+
|
|
9
|
+
const isId = (id) => (typeof id === 'number' && Number.isFinite(id)) || typeof id === 'string';
|
|
10
|
+
|
|
11
|
+
class Rpc {
|
|
12
|
+
/**
|
|
13
|
+
* @param {(message: object) => void} send
|
|
14
|
+
* @param {{ onRequest: (msg: {id: string | number, method: string, params: any}) => void, onNotification: (msg: {method: string, params: any}) => void }} handlers
|
|
15
|
+
*/
|
|
16
|
+
constructor(send, { onRequest, onNotification }) {
|
|
17
|
+
this.send = send;
|
|
18
|
+
this.onRequest = onRequest;
|
|
19
|
+
this.onNotification = onNotification;
|
|
20
|
+
this.nextId = 1;
|
|
21
|
+
/** @type {Map<string | number, {resolve: (v: any) => void, reject: (e: Error) => void, timer: NodeJS.Timeout}>} */
|
|
22
|
+
this.pending = new Map();
|
|
23
|
+
this.closed = false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Sends a request; resolves with its result, or rejects with a GrapevineError. */
|
|
27
|
+
request(method, params = {}, { timeout = REQUEST_TIMEOUT_MS } = {}) {
|
|
28
|
+
if (this.closed) return Promise.reject(new GrapevineError(CODES.CLOSED, 'The connection is closed'));
|
|
29
|
+
const id = this.nextId++;
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
const timer = setTimeout(() => {
|
|
32
|
+
this.pending.delete(id);
|
|
33
|
+
reject(new GrapevineError(CODES.TIMEOUT, `No reply to ${method} in ${timeout} ms`));
|
|
34
|
+
}, timeout);
|
|
35
|
+
timer.unref?.();
|
|
36
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
37
|
+
this.send({ jsonrpc: '2.0', id, method, params });
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Sends a notification: an event, with no reply. */
|
|
42
|
+
notify(method, params = {}) {
|
|
43
|
+
if (!this.closed) this.send({ jsonrpc: '2.0', method, params });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
reply(id, result) {
|
|
47
|
+
if (!this.closed) this.send({ jsonrpc: '2.0', id, result: result ?? {} });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** @param {string | number | null} id @param {GrapevineError} err */
|
|
51
|
+
fail(id, err) {
|
|
52
|
+
if (!this.closed) this.send({ jsonrpc: '2.0', id, error: err.toJSON() });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Sorts one incoming message. */
|
|
56
|
+
handle(msg) {
|
|
57
|
+
if (msg.jsonrpc !== '2.0') return this.fail(isId(msg.id) ? msg.id : null, new GrapevineError(CODES.INVALID_REQUEST, 'Not JSON-RPC 2.0'));
|
|
58
|
+
if ('result' in msg || 'error' in msg) return this.settle(msg);
|
|
59
|
+
if (typeof msg.method !== 'string' || !msg.method) return this.fail(isId(msg.id) ? msg.id : null, new GrapevineError(CODES.INVALID_REQUEST, 'No method'));
|
|
60
|
+
const params = msg.params ?? {};
|
|
61
|
+
if (typeof params !== 'object' || Array.isArray(params))
|
|
62
|
+
return isId(msg.id) ? this.fail(msg.id, new GrapevineError(CODES.INVALID_PARAMS, 'params must be an object')) : undefined;
|
|
63
|
+
if ('id' in msg) {
|
|
64
|
+
if (!isId(msg.id)) return this.fail(null, new GrapevineError(CODES.INVALID_REQUEST, 'Bad id'));
|
|
65
|
+
return this.onRequest({ id: msg.id, method: msg.method, params });
|
|
66
|
+
}
|
|
67
|
+
this.onNotification({ method: msg.method, params });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
settle(msg) {
|
|
71
|
+
const waiting = this.pending.get(msg.id);
|
|
72
|
+
if (!waiting) return; // a reply to nothing (late, after a timeout): ignored
|
|
73
|
+
this.pending.delete(msg.id);
|
|
74
|
+
clearTimeout(waiting.timer);
|
|
75
|
+
if ('error' in msg) {
|
|
76
|
+
const e = msg.error ?? {};
|
|
77
|
+
waiting.reject(new GrapevineError(Number.isInteger(e.code) ? e.code : CODES.INTERNAL_ERROR, String(e.message ?? 'Error'), e.data));
|
|
78
|
+
} else waiting.resolve(msg.result);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Rejects everything still waiting. */
|
|
82
|
+
close() {
|
|
83
|
+
this.closed = true;
|
|
84
|
+
for (const [, waiting] of this.pending) {
|
|
85
|
+
clearTimeout(waiting.timer);
|
|
86
|
+
waiting.reject(new GrapevineError(CODES.CLOSED, 'The connection closed'));
|
|
87
|
+
}
|
|
88
|
+
this.pending.clear();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = { Rpc, REQUEST_TIMEOUT_MS };
|
package/src/schema.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// A small JSON Schema checker for the subset the capability schemas use, so the package needs no
|
|
2
|
+
// dependencies. Unknown properties are always allowed: a newer minor version may add fields, and
|
|
3
|
+
// apps must ignore what they don't know (PROTOCOL.md, "Versions").
|
|
4
|
+
'use strict';
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
const SCHEMA_DIR = path.join(__dirname, '..', 'schemas');
|
|
10
|
+
const DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
|
|
11
|
+
|
|
12
|
+
const typeOf = (v) => (v === null ? 'null' : Array.isArray(v) ? 'array' : Number.isInteger(v) ? 'integer' : typeof v);
|
|
13
|
+
const fits = (actual, wanted) => actual === wanted || (wanted === 'number' && actual === 'integer');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Checks `value` against `schema`. Returns a list of problems ("drop.progress.done: expected integer"),
|
|
17
|
+
* empty when it's valid.
|
|
18
|
+
* @param {any} value
|
|
19
|
+
* @param {any} schema
|
|
20
|
+
* @param {(ref: string) => any} resolve looks up a "$ref"
|
|
21
|
+
*/
|
|
22
|
+
function check(value, schema, resolve, at = '') {
|
|
23
|
+
if (schema.$ref) return check(value, resolve(schema.$ref), resolve, at);
|
|
24
|
+
const where = at || '(message)';
|
|
25
|
+
const problems = [];
|
|
26
|
+
if (schema.type) {
|
|
27
|
+
const types = [].concat(schema.type);
|
|
28
|
+
if (!types.some((t) => fits(typeOf(value), t))) return [`${where}: expected ${types.join(' or ')}, got ${typeOf(value)}`];
|
|
29
|
+
}
|
|
30
|
+
if (schema.anyOf && !schema.anyOf.some((s) => check(value, s, resolve, at).length === 0)) problems.push(`${where}: matches none of the allowed shapes`);
|
|
31
|
+
if (schema.enum && !schema.enum.includes(value)) problems.push(`${where}: must be one of ${schema.enum.join(', ')}`);
|
|
32
|
+
if (typeof value === 'string') {
|
|
33
|
+
if (schema.minLength !== undefined && value.length < schema.minLength) problems.push(`${where}: shorter than ${schema.minLength}`);
|
|
34
|
+
if (schema.maxLength !== undefined && value.length > schema.maxLength) problems.push(`${where}: longer than ${schema.maxLength}`);
|
|
35
|
+
if (schema.pattern && !new RegExp(schema.pattern).test(value)) problems.push(`${where}: doesn't match ${schema.pattern}`);
|
|
36
|
+
if (schema.format === 'date-time' && !DATE_TIME.test(value)) problems.push(`${where}: not an ISO 8601 time`);
|
|
37
|
+
}
|
|
38
|
+
if (typeof value === 'number') {
|
|
39
|
+
if (schema.minimum !== undefined && value < schema.minimum) problems.push(`${where}: below ${schema.minimum}`);
|
|
40
|
+
if (schema.maximum !== undefined && value > schema.maximum) problems.push(`${where}: above ${schema.maximum}`);
|
|
41
|
+
}
|
|
42
|
+
if (Array.isArray(value)) {
|
|
43
|
+
if (schema.maxItems !== undefined && value.length > schema.maxItems) problems.push(`${where}: more than ${schema.maxItems} items`);
|
|
44
|
+
if (schema.items) value.forEach((item, i) => problems.push(...check(item, schema.items, resolve, `${at}[${i}]`)));
|
|
45
|
+
}
|
|
46
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
47
|
+
for (const key of schema.required ?? []) if (!(key in value)) problems.push(`${at ? `${at}.` : ''}${key}: missing`);
|
|
48
|
+
for (const [key, sub] of Object.entries(schema.properties ?? {})) {
|
|
49
|
+
if (key in value) problems.push(...check(value[key], sub, resolve, at ? `${at}.${key}` : key));
|
|
50
|
+
}
|
|
51
|
+
if (schema.additionalProperties && typeof schema.additionalProperties === 'object') {
|
|
52
|
+
for (const [key, v] of Object.entries(value)) {
|
|
53
|
+
if (!(key in (schema.properties ?? {}))) problems.push(...check(v, schema.additionalProperties, resolve, at ? `${at}.${key}` : key));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return problems;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A capability's schemas, one file per major version: { capability, major, minor, events, methods, defs }.
|
|
62
|
+
* "$ref" is "#Name" for the file's own defs and "common:Name" for the shared types.
|
|
63
|
+
*/
|
|
64
|
+
class Schemas {
|
|
65
|
+
constructor() {
|
|
66
|
+
this.common = JSON.parse(fs.readFileSync(path.join(SCHEMA_DIR, 'common.json'), 'utf8')).defs;
|
|
67
|
+
/** @type {Map<string, any>} "capability@major" -> schema file */
|
|
68
|
+
this.byKey = new Map();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Loads every schema in the package, plus any extra folders (for apps adding their own capabilities). */
|
|
72
|
+
static load(extraDirs = []) {
|
|
73
|
+
const s = new Schemas();
|
|
74
|
+
for (const dir of [SCHEMA_DIR, ...extraDirs]) {
|
|
75
|
+
for (const name of fs.readdirSync(dir)) {
|
|
76
|
+
const full = path.join(dir, name);
|
|
77
|
+
if (!fs.statSync(full).isDirectory()) continue;
|
|
78
|
+
for (const file of fs.readdirSync(full).filter((f) => /^\d+\.json$/.test(f))) s.add(JSON.parse(fs.readFileSync(path.join(full, file), 'utf8')));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return s;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
add(schema) {
|
|
85
|
+
if (!schema.capability || !Number.isInteger(schema.major) || !Number.isInteger(schema.minor)) throw new Error('A schema needs capability, major and minor');
|
|
86
|
+
this.byKey.set(`${schema.capability}@${schema.major}`, schema);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
get(capability, major) {
|
|
90
|
+
return this.byKey.get(`${capability}@${major}`) ?? null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** "drops.farming.changed" -> "drops.farming": the capability a method or event belongs to. */
|
|
94
|
+
capabilityOf(name) {
|
|
95
|
+
let best = null;
|
|
96
|
+
for (const key of this.byKey.keys()) {
|
|
97
|
+
const cap = key.slice(0, key.lastIndexOf('@'));
|
|
98
|
+
if (name.startsWith(`${cap}.`) && (!best || cap.length > best.length)) best = cap;
|
|
99
|
+
}
|
|
100
|
+
return best;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
resolver(file) {
|
|
104
|
+
return (ref) => {
|
|
105
|
+
const found = ref.startsWith('common:') ? this.common[ref.slice(7)] : ref.startsWith('#') ? file.defs?.[ref.slice(1)] : undefined;
|
|
106
|
+
if (!found) throw new Error(`Unknown schema reference ${ref}`);
|
|
107
|
+
return found;
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Checks an event's params, or a method's params or result, for one major version.
|
|
113
|
+
* @param {'event' | 'params' | 'result'} kind
|
|
114
|
+
* @returns {string[]} problems; empty when valid
|
|
115
|
+
*/
|
|
116
|
+
validate(capability, major, kind, name, value) {
|
|
117
|
+
const file = this.get(capability, major);
|
|
118
|
+
if (!file) return [`no schema for ${capability}@${major}`];
|
|
119
|
+
const schema = kind === 'event' ? file.events?.[name] : file.methods?.[name]?.[kind];
|
|
120
|
+
if (!schema) return [`${name} isn't part of ${capability}@${major}`];
|
|
121
|
+
return check(value, schema, this.resolver(file));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = { check, Schemas, SCHEMA_DIR };
|
package/stand-in-peer.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// A stand-in for another app, for an app's own tests: it speaks Grapevine as that app (any version,
|
|
3
|
+
// any capabilities) without the real app installed.
|
|
4
|
+
//
|
|
5
|
+
// In a test:
|
|
6
|
+
// const { standInPeer } = require('@streamorchard/grapevine/stand-in-peer');
|
|
7
|
+
// const peer = await standInPeer({ as: 'com.glowingpear.streammultiview', dir, uses: { 'drops.farming': '1.0' } });
|
|
8
|
+
// peer.link.on('event', (from, name, params) => …); peer.link.publish('players.changed', {…});
|
|
9
|
+
//
|
|
10
|
+
// From the command line (a launch test can spawn it): it prints one JSON line per thing that
|
|
11
|
+
// happens ({"type":"connected"|"event"|"disconnected", …}) and reads lines on stdin:
|
|
12
|
+
// {"publish":"players.changed","data":{…}} {"request":"drops.farming.get","params":{},"to":"<app id>"}
|
|
13
|
+
// node stand-in-peer.js --as com.glowingpear.streammultiview --dir /tmp/x --uses drops.farming@1.0 --provides players@1.0
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const readline = require('readline');
|
|
17
|
+
const { createLink, KNOWN_APPS, memoryKeyStore } = require('./src');
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {object} o
|
|
21
|
+
* @param {string} o.as the known app to pretend to be (its id)
|
|
22
|
+
* @param {string} o.dir a GRAPEVINE_DIR shared with the app under test
|
|
23
|
+
* @param {string} [o.version] its version (default 99.0.0)
|
|
24
|
+
* @param {Record<string, string | string[]>} [o.provides]
|
|
25
|
+
* @param {Record<string, string | string[]>} [o.uses]
|
|
26
|
+
* @param {(appId: string) => boolean} [o.allowed] its switch (default: on for every app)
|
|
27
|
+
* @param {object} [o.timing]
|
|
28
|
+
* @param {import('./src/keys').KeyStore} [o.keys]
|
|
29
|
+
*/
|
|
30
|
+
async function standInPeer({ as, dir, version = '99.0.0', provides = {}, uses = {}, allowed = () => true, timing, keys = memoryKeyStore() }) {
|
|
31
|
+
const known = KNOWN_APPS.find((a) => a.id === as);
|
|
32
|
+
if (!known) throw new Error(`stand-in-peer: ${as} isn't a known app`);
|
|
33
|
+
const link = createLink({
|
|
34
|
+
app: {
|
|
35
|
+
id: known.id,
|
|
36
|
+
slug: known.slug,
|
|
37
|
+
name: `${known.name} (stand-in)`,
|
|
38
|
+
version,
|
|
39
|
+
summary: known.summary,
|
|
40
|
+
homepage: known.homepage,
|
|
41
|
+
download: known.download,
|
|
42
|
+
},
|
|
43
|
+
provides,
|
|
44
|
+
uses,
|
|
45
|
+
allowed,
|
|
46
|
+
keys,
|
|
47
|
+
place: { env: { ...process.env, GRAPEVINE_DIR: dir } },
|
|
48
|
+
timing,
|
|
49
|
+
strict: false,
|
|
50
|
+
});
|
|
51
|
+
await link.start();
|
|
52
|
+
return { link, stop: () => link.stop() };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** "drops.farming@1.0,players@1.0" -> { 'drops.farming': '1.0', players: '1.0' } */
|
|
56
|
+
function parseCaps(text) {
|
|
57
|
+
/** @type {Record<string, string[]>} */
|
|
58
|
+
const out = {};
|
|
59
|
+
for (const part of String(text ?? '')
|
|
60
|
+
.split(',')
|
|
61
|
+
.filter(Boolean)) {
|
|
62
|
+
const [cap, v] = part.split('@');
|
|
63
|
+
(out[cap] ??= []).push(v || '1.0');
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function main(argv) {
|
|
69
|
+
const arg = (name) => {
|
|
70
|
+
const i = argv.indexOf(`--${name}`);
|
|
71
|
+
return i === -1 ? undefined : argv[i + 1];
|
|
72
|
+
};
|
|
73
|
+
const out = (o) => process.stdout.write(`${JSON.stringify(o)}\n`);
|
|
74
|
+
const { link } = await standInPeer({
|
|
75
|
+
as: arg('as'),
|
|
76
|
+
dir: arg('dir') ?? process.env.GRAPEVINE_DIR,
|
|
77
|
+
version: arg('version'),
|
|
78
|
+
provides: parseCaps(arg('provides')),
|
|
79
|
+
uses: parseCaps(arg('uses')),
|
|
80
|
+
});
|
|
81
|
+
link.on('connected', (id) => out({ type: 'connected', app: id }));
|
|
82
|
+
link.on('disconnected', (id, reason) => out({ type: 'disconnected', app: id, reason }));
|
|
83
|
+
link.on('event', (from, name, params) => out({ type: 'event', from, name, params }));
|
|
84
|
+
out({ type: 'started' });
|
|
85
|
+
readline.createInterface({ input: process.stdin }).on('line', async (line) => {
|
|
86
|
+
try {
|
|
87
|
+
const cmd = JSON.parse(line);
|
|
88
|
+
if (cmd.publish) out({ type: 'published', params: link.publish(cmd.publish, cmd.data ?? {}) });
|
|
89
|
+
if (cmd.request) out({ type: 'result', method: cmd.request, result: await link.request(cmd.to, cmd.request, cmd.params ?? {}) });
|
|
90
|
+
if (cmd.quit) {
|
|
91
|
+
await link.stop();
|
|
92
|
+
process.exit(0);
|
|
93
|
+
}
|
|
94
|
+
} catch (err) {
|
|
95
|
+
out({ type: 'error', message: err.message, code: err.code });
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
const quit = () => link.stop().then(() => process.exit(0));
|
|
99
|
+
process.on('SIGTERM', quit);
|
|
100
|
+
process.on('SIGINT', quit);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (require.main === module)
|
|
104
|
+
main(process.argv.slice(2)).catch((err) => {
|
|
105
|
+
process.stderr.write(`${err.stack}\n`);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
module.exports = { standInPeer, parseCaps };
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Generated from schemas/ by scripts/types.js. Don't edit it: change a schema and run `npm run types`.
|
|
2
|
+
|
|
3
|
+
/** ISO 8601, in UTC */
|
|
4
|
+
export type Timestamp = string;
|
|
5
|
+
|
|
6
|
+
/** A streaming site */
|
|
7
|
+
export type Platform = "kick" | "twitch" | "youtube" | (string & {});
|
|
8
|
+
|
|
9
|
+
export type Channel = {
|
|
10
|
+
platform: Platform;
|
|
11
|
+
/** As in the channel's address */
|
|
12
|
+
slug: string;
|
|
13
|
+
/** As shown on the site */
|
|
14
|
+
name?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Who an app is signed in as: the username only, never a token or cookie */
|
|
18
|
+
export type Account = {
|
|
19
|
+
platform: Platform;
|
|
20
|
+
user: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type Progress = {
|
|
24
|
+
done: number;
|
|
25
|
+
total: number;
|
|
26
|
+
unit: "minutes" | (string & {});
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type Empty = Record<string, unknown>;
|
|
30
|
+
|
|
31
|
+
// drops.farming 1.0: What a drops app is farming. Provided by Pear Drops.
|
|
32
|
+
export type DropsFarmingV1State = {
|
|
33
|
+
/** Counts up with each change; 0 for a reply to drops.farming.get */
|
|
34
|
+
seq: number;
|
|
35
|
+
at: Timestamp;
|
|
36
|
+
/** farming: its player is watching a stream for drop credit. Any other value, known or not, means it isn't. */
|
|
37
|
+
state: "farming" | "waiting" | "paused" | "done" | "signed-out" | (string & {});
|
|
38
|
+
account: Account | null;
|
|
39
|
+
/** The stream being watched, while farming */
|
|
40
|
+
channel: Channel | null;
|
|
41
|
+
drop: {
|
|
42
|
+
campaign: string;
|
|
43
|
+
category: string;
|
|
44
|
+
progress: Progress;
|
|
45
|
+
} | null;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// players 1.0: An app's stream players, by site: whether they're signed in, and which channels are open. Provided by Stream Multiview.
|
|
49
|
+
export type PlayersV1State = {
|
|
50
|
+
seq: number;
|
|
51
|
+
at: Timestamp;
|
|
52
|
+
/** Keyed by Platform; a site with no players open may be left out */
|
|
53
|
+
platforms: {
|
|
54
|
+
[key: string]: PlayersV1Site;
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type PlayersV1Site = {
|
|
59
|
+
/** Whether the players play as the account right now */
|
|
60
|
+
signedIn: boolean;
|
|
61
|
+
/** auto: signed in, except while a connected drops app is farming */
|
|
62
|
+
mode: "signed-in" | "signed-out" | "auto" | (string & {});
|
|
63
|
+
account: Account | null;
|
|
64
|
+
open: Array<Channel>;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Every event, by name: its params. */
|
|
68
|
+
export interface Events {
|
|
69
|
+
"drops.farming.changed": DropsFarmingV1State;
|
|
70
|
+
"players.changed": PlayersV1State;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Every method, by name: its params and result. */
|
|
74
|
+
export interface Methods {
|
|
75
|
+
"drops.farming.get": { params: Empty; result: DropsFarmingV1State };
|
|
76
|
+
"players.get": { params: Empty; result: PlayersV1State };
|
|
77
|
+
}
|