@source-repo/rpc-cli 3.0.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.
- package/LICENSE +21 -0
- package/README.md +1131 -0
- package/dist/bench.d.ts +66 -0
- package/dist/bench.d.ts.map +1 -0
- package/dist/bench.js +109 -0
- package/dist/bench.js.map +1 -0
- package/dist/broker.d.ts +61 -0
- package/dist/broker.d.ts.map +1 -0
- package/dist/broker.js +56 -0
- package/dist/broker.js.map +1 -0
- package/dist/bus.d.ts +142 -0
- package/dist/bus.d.ts.map +1 -0
- package/dist/bus.js +272 -0
- package/dist/bus.js.map +1 -0
- package/dist/bus.types.json +269 -0
- package/dist/conform.d.ts +75 -0
- package/dist/conform.d.ts.map +1 -0
- package/dist/conform.js +152 -0
- package/dist/conform.js.map +1 -0
- package/dist/console.d.ts +285 -0
- package/dist/console.d.ts.map +1 -0
- package/dist/console.js +686 -0
- package/dist/console.js.map +1 -0
- package/dist/console.types.json +1730 -0
- package/dist/extract.d.ts +37 -0
- package/dist/extract.d.ts.map +1 -0
- package/dist/extract.js +272 -0
- package/dist/extract.js.map +1 -0
- package/dist/fake.d.ts +58 -0
- package/dist/fake.d.ts.map +1 -0
- package/dist/fake.js +164 -0
- package/dist/fake.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +905 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp.d.ts +15 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +507 -0
- package/dist/mcp.js.map +1 -0
- package/dist/network.d.ts +58 -0
- package/dist/network.d.ts.map +1 -0
- package/dist/network.js +64 -0
- package/dist/network.js.map +1 -0
- package/dist/record.d.ts +74 -0
- package/dist/record.d.ts.map +1 -0
- package/dist/record.js +221 -0
- package/dist/record.js.map +1 -0
- package/dist/tapping.d.ts +11 -0
- package/dist/tapping.d.ts.map +1 -0
- package/dist/tapping.js +71 -0
- package/dist/tapping.js.map +1 -0
- package/dist/verbs.d.ts +59 -0
- package/dist/verbs.d.ts.map +1 -0
- package/dist/verbs.js +324 -0
- package/dist/verbs.js.map +1 -0
- package/dist/web/app.css +1 -0
- package/dist/web/app.js +2063 -0
- package/dist/web/app.js.map +1 -0
- package/dist/web/index.html +13 -0
- package/package.json +65 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,905 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { createHmacSigner, createHmacVerifier, createTokenAuthenticator, defaultSecureWebPort, defaultSecureWebSocketPort, defaultWebPort, defaultWebSocketPort, namespaceProblems, readableNameFor } from '@source-repo/rpc';
|
|
5
|
+
import { extractSchema } from './extract.js';
|
|
6
|
+
import { startConsole } from './console.js';
|
|
7
|
+
import { startBroker } from './broker.js';
|
|
8
|
+
import { startMcp } from './mcp.js';
|
|
9
|
+
import { processOutput, runCall, runDescribe, runPeers, runWatch } from './verbs.js';
|
|
10
|
+
import { startFake } from './fake.js';
|
|
11
|
+
import { replaySession, startRecording } from './record.js';
|
|
12
|
+
import { checkPeer, diffPeers } from './conform.js';
|
|
13
|
+
import { bench, benchArguments } from './bench.js';
|
|
14
|
+
/**
|
|
15
|
+
* msgrpc extract - read the contract out of TypeScript source and write it to a file
|
|
16
|
+
* msgrpc check - compare the source against a written contract and report breaking changes
|
|
17
|
+
*
|
|
18
|
+
* check is the one worth wiring into CI. It uses the same comparison the server uses at runtime,
|
|
19
|
+
* so a change that would refuse an older peer is caught before it ships rather than when that peer
|
|
20
|
+
* next calls.
|
|
21
|
+
*/
|
|
22
|
+
const usage = `source-rpc <command> [options]
|
|
23
|
+
|
|
24
|
+
extract write the contract described by the source to a file
|
|
25
|
+
check compare the source against a written contract and fail on a breaking change
|
|
26
|
+
console browse a live network in a browser: peers, what they expose, calls and events
|
|
27
|
+
broker run a WebSocket bus: relays between the peers that connect to it, until Ctrl-C
|
|
28
|
+
mcp serve the network to an MCP client over stdio: list peers, describe them, call them
|
|
29
|
+
|
|
30
|
+
bench call one method over and over and report what it cost
|
|
31
|
+
diff compare what two live peers expose, when one of them behaves differently
|
|
32
|
+
serve stand a peer up from a contract: answers every method, refuses what it would refuse
|
|
33
|
+
record write what the network is carrying to a file, until Ctrl-C
|
|
34
|
+
replay send a recording's calls at a peer and compare the answers
|
|
35
|
+
|
|
36
|
+
peers who is on the network right now
|
|
37
|
+
describe <peer> what one peer exposes
|
|
38
|
+
call <peer> <ns.method> [a…] call it, and exit 1 if it refuses
|
|
39
|
+
watch <peer> <ns.event> stream its events as jsonl until Ctrl-C
|
|
40
|
+
|
|
41
|
+
extract / check
|
|
42
|
+
--project <tsconfig.json> default ./tsconfig.json
|
|
43
|
+
--out <file> default ./msgrpc.types.json (extract)
|
|
44
|
+
--against <file> default ./msgrpc.types.json (check)
|
|
45
|
+
--keep-history move the previous contract into history before writing
|
|
46
|
+
--peer <name> (check) ask a live peer what it serves instead of reading source
|
|
47
|
+
needs --broker or --hub
|
|
48
|
+
|
|
49
|
+
bench <peer> <ns.method> [args…]
|
|
50
|
+
--rate <n> calls per second to aim for, default 10
|
|
51
|
+
--for <ms> how long to keep going, default 10000
|
|
52
|
+
--concurrency <n> calls outstanding at once before the rest count as fallen behind
|
|
53
|
+
default 50
|
|
54
|
+
--json machine-readable report
|
|
55
|
+
exits 1 if any call failed
|
|
56
|
+
|
|
57
|
+
diff <peerA> <peerB>
|
|
58
|
+
--broker / --hub / --prefix / --timeout / --name / --sign as above
|
|
59
|
+
--json machine-readable output
|
|
60
|
+
|
|
61
|
+
peers / describe / call / watch
|
|
62
|
+
--broker <url> an MQTT network, e.g. mqtt://localhost:1883
|
|
63
|
+
--hub <url> a socket.io network, e.g. http://hub:7843
|
|
64
|
+
one of --broker and --hub is required; both watches both
|
|
65
|
+
--prefix <topic> topic namespace, default the transport's own
|
|
66
|
+
--timeout <ms> call timeout, default 10000
|
|
67
|
+
--wait <ms> how long to wait for the peer to appear, default 5000
|
|
68
|
+
--name <peer> how it identifies itself, default cli-<three words>
|
|
69
|
+
--sign <keyfile> HMAC keys, for a signed network
|
|
70
|
+
--insecure-tls accept any certificate on an https/wss/mqtts link
|
|
71
|
+
unsafe by design: for a development bus, never a plant
|
|
72
|
+
--json machine-readable output
|
|
73
|
+
--args <json> (call) the whole argument list as a JSON array, instead of words
|
|
74
|
+
--idempotency-key <key> (call) names the command, so calling twice with one key is two
|
|
75
|
+
attempts at one command rather than two commands
|
|
76
|
+
|
|
77
|
+
console
|
|
78
|
+
--broker <url> an MQTT network, e.g. mqtt://localhost:1883
|
|
79
|
+
--hub <url> a socket.io network, e.g. http://hub:7843
|
|
80
|
+
one of --broker and --hub is required; both watches both
|
|
81
|
+
--prefix <topic> topic namespace, default the transport's own
|
|
82
|
+
--port <n> default 7844, or 8844 with --cert
|
|
83
|
+
--host <address> default 127.0.0.1 - see the warning it prints before widening this
|
|
84
|
+
--cert <file> --key <file> serve HTTPS, and WSS with it; moves the default port to 8844
|
|
85
|
+
--base-path <path> publish under a path, for a reverse proxy that forwards the prefix
|
|
86
|
+
instead of stripping it; not needed for the ordinary rule
|
|
87
|
+
--timeout <ms> call timeout, default 10000
|
|
88
|
+
--name <peer> how the console identifies itself, default console-<three words>
|
|
89
|
+
--sign <keyfile> HMAC keys, so the console can talk to a signed network
|
|
90
|
+
--insecure-tls accept any certificate on an https/wss/mqtts link
|
|
91
|
+
unsafe by design: for a development bus, never a plant
|
|
92
|
+
|
|
93
|
+
mcp
|
|
94
|
+
--broker <url> an MQTT network
|
|
95
|
+
--hub <url> a socket.io network
|
|
96
|
+
one of --broker and --hub is required; both watches both
|
|
97
|
+
--prefix <topic> topic namespace, default the transport's own
|
|
98
|
+
--timeout <ms> call timeout, default 10000
|
|
99
|
+
--name <peer> how it identifies itself, default mcp-<three words>
|
|
100
|
+
--sign <keyfile> HMAC keys, for a signed network
|
|
101
|
+
--insecure-tls accept any certificate on an https/wss/mqtts link
|
|
102
|
+
unsafe by design: for a development bus, never a plant
|
|
103
|
+
--contracts <dir> let it save and load contracts here; without it those tools
|
|
104
|
+
are not offered at all
|
|
105
|
+
stdio carries the protocol, so it is not for interactive use
|
|
106
|
+
|
|
107
|
+
serve
|
|
108
|
+
--contract <file> the contract to serve; every namespace in it is exposed
|
|
109
|
+
--script <file> canned returns, deliberate failures and events on a timer
|
|
110
|
+
--fail <ns.method=Code> answer with that RPC error code, repeatable
|
|
111
|
+
Timeout is the special one: the call is never answered at all
|
|
112
|
+
--broker / --hub / --prefix / --timeout / --name / --sign as above
|
|
113
|
+
|
|
114
|
+
record
|
|
115
|
+
--out <file> where to write the recording, as jsonl
|
|
116
|
+
--peer <name> only frames this peer sent or received
|
|
117
|
+
--namespace <name> only this namespace
|
|
118
|
+
--no-payloads leave arguments and results out
|
|
119
|
+
--for <ms> stop after this long, instead of waiting for Ctrl-C
|
|
120
|
+
|
|
121
|
+
replay <file>
|
|
122
|
+
--against <peer> send every call here, instead of to its original addressee
|
|
123
|
+
--speed <n> higher is faster, default 1; 0 sends with no waiting
|
|
124
|
+
--json machine-readable summary
|
|
125
|
+
exits 1 if any answer differed or any call failed
|
|
126
|
+
|
|
127
|
+
broker
|
|
128
|
+
--port <n> default 7843, or 8843 with --cert; on every interface
|
|
129
|
+
--cert <file> --key <file> serve WSS rather than WS; moves the default port to 8843
|
|
130
|
+
--name <peer> how the broker identifies itself, default broker-<three words>
|
|
131
|
+
--upstream <url> join another broker, repeatable; the two become one network
|
|
132
|
+
--auth <file> tokens to accept, and the one to present upstream; without it the
|
|
133
|
+
bus relays for anyone that can reach the port
|
|
134
|
+
--quiet do not log peers arriving and leaving
|
|
135
|
+
|
|
136
|
+
ports
|
|
137
|
+
7843 rpc 7844 console the plaintext pair
|
|
138
|
+
8843 rpc-tls 8844 console-tls the same two with a certificate
|
|
139
|
+
--cert and --key move a server to its encrypted port on their own,
|
|
140
|
+
so the convention holds without anyone remembering the number
|
|
141
|
+
|
|
142
|
+
--auth <file> bearer tokens, for every command above
|
|
143
|
+
{ "token": "…", presented when this command dials a hub that authenticates
|
|
144
|
+
"tokens": { accepted when this command is the bus: token -> the peer it admits
|
|
145
|
+
"…": "plantServer",
|
|
146
|
+
"…": { "name": "hmi", "roles": ["operator"] } } }
|
|
147
|
+
SOURCE_RPC_TOKEN the same "token", for a container
|
|
148
|
+
SOURCE_RPC_TOKENS the same "tokens" as JSON, for a container
|
|
149
|
+
never a flag: ps is readable by everyone on the box
|
|
150
|
+
`;
|
|
151
|
+
const argument = (argv, flag, fallback) => {
|
|
152
|
+
const index = argv.indexOf(flag);
|
|
153
|
+
return index === -1 ? fallback : (argv[index + 1] ?? fallback);
|
|
154
|
+
};
|
|
155
|
+
/** Every occurrence of a repeatable flag, so --upstream can be given more than once. */
|
|
156
|
+
const argumentList = (argv, flag) => argv.map((value, index) => (value === flag ? argv[index + 1] : undefined)).filter((value) => !!value);
|
|
157
|
+
const DIAGNOSTIC_LIMIT = 25;
|
|
158
|
+
const reportDiagnostics = (diagnostics) => {
|
|
159
|
+
for (const diagnostic of diagnostics.slice(0, DIAGNOSTIC_LIMIT)) {
|
|
160
|
+
const at = diagnostic.file ? ` (${diagnostic.file}:${diagnostic.line})` : '';
|
|
161
|
+
process.stderr.write(` ${diagnostic.where} ${diagnostic.reason}${at}\n`);
|
|
162
|
+
}
|
|
163
|
+
// Named rather than silently dropped, so nobody reads a truncated list as the whole story.
|
|
164
|
+
if (diagnostics.length > DIAGNOSTIC_LIMIT)
|
|
165
|
+
process.stderr.write(` … and ${diagnostics.length - DIAGNOSTIC_LIMIT} more\n`);
|
|
166
|
+
};
|
|
167
|
+
const readSchema = (path) => JSON.parse(readFileSync(path, 'utf8'));
|
|
168
|
+
/** Rolls the stored contract into history, so a later run can tell what changed since. */
|
|
169
|
+
const withHistory = (next, previous) => {
|
|
170
|
+
if (!previous)
|
|
171
|
+
return next;
|
|
172
|
+
for (const [name, namespace] of Object.entries(next.namespaces)) {
|
|
173
|
+
const before = previous.namespaces[name];
|
|
174
|
+
if (!before?.version || before.version === namespace.version)
|
|
175
|
+
continue;
|
|
176
|
+
const { history: _dropped, ...snapshot } = before;
|
|
177
|
+
namespace.history = { ...(before.history ?? {}), ...namespace.history, [before.version]: snapshot };
|
|
178
|
+
}
|
|
179
|
+
return next;
|
|
180
|
+
};
|
|
181
|
+
const readSigningKeys = (path, command) => {
|
|
182
|
+
let keys;
|
|
183
|
+
try {
|
|
184
|
+
keys = JSON.parse(readFileSync(path, 'utf8'));
|
|
185
|
+
}
|
|
186
|
+
catch (e) {
|
|
187
|
+
process.stderr.write(`source-rpc ${command}: cannot read keys from ${path}: ${e.message}\n`);
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
190
|
+
if (typeof keys.secret !== 'string' || !keys.secret) {
|
|
191
|
+
process.stderr.write(`source-rpc ${command}: ${path} has no "secret"\n`);
|
|
192
|
+
process.exit(1);
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
// Worth saying out loud: this file is the console's identity on the network.
|
|
196
|
+
if (statSync(path).mode & 0o077)
|
|
197
|
+
process.stderr.write(`source-rpc ${command}: ${path} is readable by other users\n`);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
// Not worth failing over if the mode cannot be read.
|
|
201
|
+
}
|
|
202
|
+
const sign = createHmacSigner(keys.secret);
|
|
203
|
+
const verify = keys.peers ? createHmacVerifier((peer) => keys.peers?.[peer]) : undefined;
|
|
204
|
+
return { keys, sign, verify };
|
|
205
|
+
};
|
|
206
|
+
/**
|
|
207
|
+
* Certificate and key for a server this command opens, or undefined for plain HTTP.
|
|
208
|
+
*
|
|
209
|
+
* The material is what asks for TLS - there is no `--tls` switch, because a switch without a
|
|
210
|
+
* certificate opens a port that listens and then fails every handshake, which is the shape the
|
|
211
|
+
* library refused when `{ https: true }` was removed. Paths rather than contents: a PEM on the
|
|
212
|
+
* command line would be in `ps` and in the shell history, and both halves of a key pair belong in
|
|
213
|
+
* the same place as each other.
|
|
214
|
+
*/
|
|
215
|
+
const readTls = (argv, command) => {
|
|
216
|
+
const cert = argument(argv, '--cert', '');
|
|
217
|
+
const key = argument(argv, '--key', '');
|
|
218
|
+
if (!cert && !key)
|
|
219
|
+
return undefined;
|
|
220
|
+
if (!cert || !key) {
|
|
221
|
+
process.stderr.write(`source-rpc ${command}: --cert and --key go together; got only ${cert ? '--cert' : '--key'}\n`);
|
|
222
|
+
process.exit(1);
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
return { cert: readFileSync(cert), key: readFileSync(key) };
|
|
226
|
+
}
|
|
227
|
+
catch (e) {
|
|
228
|
+
process.stderr.write(`source-rpc ${command}: cannot read the certificate or key: ${e.message}\n`);
|
|
229
|
+
process.exit(1);
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
/**
|
|
233
|
+
* The port to listen on: what was asked for, or the default for what is being served.
|
|
234
|
+
*
|
|
235
|
+
* A certificate moves the default from 7843/7844 to 8843/8844, so the convention holds without
|
|
236
|
+
* anyone having to remember it - `--cert`/`--key` is enough to be found where a TLS peer would look.
|
|
237
|
+
* An explicit `--port` always wins, since a plant with its own numbering has the last word.
|
|
238
|
+
*/
|
|
239
|
+
const listeningPort = (argv, secure, plain, encrypted) => Number(argument(argv, '--port', String(secure ? encrypted : plain)));
|
|
240
|
+
const readAuth = (argv, command) => {
|
|
241
|
+
const path = argument(argv, '--auth', '');
|
|
242
|
+
if (!path) {
|
|
243
|
+
const environmentTokens = process.env.SOURCE_RPC_TOKENS;
|
|
244
|
+
let tokens;
|
|
245
|
+
if (environmentTokens) {
|
|
246
|
+
try {
|
|
247
|
+
tokens = JSON.parse(environmentTokens);
|
|
248
|
+
}
|
|
249
|
+
catch (e) {
|
|
250
|
+
process.stderr.write(`source-rpc ${command}: SOURCE_RPC_TOKENS is not JSON: ${e.message}\n`);
|
|
251
|
+
process.exit(1);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
...(process.env.SOURCE_RPC_TOKEN ? { token: process.env.SOURCE_RPC_TOKEN } : {}),
|
|
256
|
+
...(tokens ? { tokens } : {})
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
let auth;
|
|
260
|
+
try {
|
|
261
|
+
auth = JSON.parse(readFileSync(path, 'utf8'));
|
|
262
|
+
}
|
|
263
|
+
catch (e) {
|
|
264
|
+
process.stderr.write(`source-rpc ${command}: cannot read tokens from ${path}: ${e.message}\n`);
|
|
265
|
+
process.exit(1);
|
|
266
|
+
}
|
|
267
|
+
if (!auth.token && !auth.tokens) {
|
|
268
|
+
// An empty file is the failure that looks like success: the command starts, and the bus it
|
|
269
|
+
// meant to gate is open. Better to refuse than to run unauthenticated on request.
|
|
270
|
+
process.stderr.write(`source-rpc ${command}: ${path} has neither "token" nor "tokens"\n`);
|
|
271
|
+
process.exit(1);
|
|
272
|
+
}
|
|
273
|
+
try {
|
|
274
|
+
// Worth saying out loud: whoever can read this file can be these peers.
|
|
275
|
+
if (statSync(path).mode & 0o077)
|
|
276
|
+
process.stderr.write(`source-rpc ${command}: ${path} is readable by other users\n`);
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
// Not worth failing over if the mode cannot be read.
|
|
280
|
+
}
|
|
281
|
+
return auth;
|
|
282
|
+
};
|
|
283
|
+
/**
|
|
284
|
+
* The flags every command that joins a network takes, read once.
|
|
285
|
+
*
|
|
286
|
+
* console, mcp and the one-shot verbs all need the same six, and the two checks that go with them:
|
|
287
|
+
* that there is something to join at all, and that a --name does not contradict the name the key
|
|
288
|
+
* file belongs to. A signed frame is checked against the key held for the name it claims, so a
|
|
289
|
+
* process signing with one peer's key while calling itself another is refused - and refused as a
|
|
290
|
+
* timeout, with nothing to say why. Better to stop here than to let that happen on a plant network.
|
|
291
|
+
*/
|
|
292
|
+
const resolveNetworkFlags = (argv, command, defaultNamePrefix) => {
|
|
293
|
+
const broker = argument(argv, '--broker', '');
|
|
294
|
+
const hub = argument(argv, '--hub', '');
|
|
295
|
+
if (!broker && !hub) {
|
|
296
|
+
process.stderr.write(`source-rpc ${command}: give it --broker, --hub, or both\n`);
|
|
297
|
+
process.exit(1);
|
|
298
|
+
}
|
|
299
|
+
const prefix = argument(argv, '--prefix', '');
|
|
300
|
+
const keyFile = argument(argv, '--sign', '');
|
|
301
|
+
const signing = keyFile ? readSigningKeys(keyFile, command) : undefined;
|
|
302
|
+
const requestedName = argument(argv, '--name', '');
|
|
303
|
+
if (signing?.keys.name && requestedName && signing.keys.name !== requestedName) {
|
|
304
|
+
process.stderr.write(`source-rpc ${command}: --name ${requestedName} does not match "${signing.keys.name}" in ${keyFile}\n`);
|
|
305
|
+
process.exit(1);
|
|
306
|
+
}
|
|
307
|
+
// A token is presented to a hub, never to a broker: MQTT authenticates at the broker, with
|
|
308
|
+
// credentials the broker was configured with, and this has no say in it.
|
|
309
|
+
const { token } = readAuth(argv, command);
|
|
310
|
+
return {
|
|
311
|
+
...(broker ? { broker } : {}),
|
|
312
|
+
...(hub ? { hub } : {}),
|
|
313
|
+
...(prefix ? { prefix } : {}),
|
|
314
|
+
name: requestedName || signing?.keys.name || readableNameFor(defaultNamePrefix),
|
|
315
|
+
callTimeout: Number(argument(argv, '--timeout', '10000')),
|
|
316
|
+
...(argv.includes('--insecure-tls') ? { insecureTls: true } : {}),
|
|
317
|
+
...(signing ? { sign: signing.sign, ...(signing.verify ? { verify: signing.verify } : {}) } : {}),
|
|
318
|
+
...(token ? { hubCredentials: { token } } : {}),
|
|
319
|
+
signing
|
|
320
|
+
};
|
|
321
|
+
};
|
|
322
|
+
/**
|
|
323
|
+
* The words a command was given, with the flags and their values taken out.
|
|
324
|
+
*
|
|
325
|
+
* `source-rpc call plant plant.setpoint 1200 --hub http://bus --json` has to yield exactly
|
|
326
|
+
* ['plant', 'plant.setpoint', '1200'], which means knowing which flags consume the word after them.
|
|
327
|
+
*/
|
|
328
|
+
const VALUE_FLAGS = new Set([
|
|
329
|
+
'--broker',
|
|
330
|
+
'--hub',
|
|
331
|
+
'--prefix',
|
|
332
|
+
'--timeout',
|
|
333
|
+
'--wait',
|
|
334
|
+
'--name',
|
|
335
|
+
'--sign',
|
|
336
|
+
'--auth',
|
|
337
|
+
'--base-path',
|
|
338
|
+
'--args',
|
|
339
|
+
'--project',
|
|
340
|
+
'--out',
|
|
341
|
+
'--against',
|
|
342
|
+
'--port',
|
|
343
|
+
'--host',
|
|
344
|
+
'--upstream',
|
|
345
|
+
'--contract',
|
|
346
|
+
'--script',
|
|
347
|
+
'--fail',
|
|
348
|
+
'--out',
|
|
349
|
+
'--peer',
|
|
350
|
+
'--namespace',
|
|
351
|
+
'--for',
|
|
352
|
+
'--against',
|
|
353
|
+
'--speed',
|
|
354
|
+
'--contracts',
|
|
355
|
+
'--rate',
|
|
356
|
+
'--concurrency',
|
|
357
|
+
'--idempotency-key',
|
|
358
|
+
'--cert',
|
|
359
|
+
'--key'
|
|
360
|
+
]);
|
|
361
|
+
const positionals = (argv) => {
|
|
362
|
+
const words = [];
|
|
363
|
+
for (let index = 0; index < argv.length; index++) {
|
|
364
|
+
const word = argv[index];
|
|
365
|
+
if (word.startsWith('--')) {
|
|
366
|
+
if (VALUE_FLAGS.has(word))
|
|
367
|
+
index++;
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
words.push(word);
|
|
371
|
+
}
|
|
372
|
+
return words;
|
|
373
|
+
};
|
|
374
|
+
/**
|
|
375
|
+
* peers, describe, call and watch: the console's verbs for a shell rather than a browser.
|
|
376
|
+
*
|
|
377
|
+
* The exit code is the product. `source-rpc call` returning 1 when a device refuses is what lets a
|
|
378
|
+
* smoke test be a line in a CI file rather than a program that parses output.
|
|
379
|
+
*/
|
|
380
|
+
const runVerb = async (command, argv) => {
|
|
381
|
+
const flags = resolveNetworkFlags(argv, command, 'cli');
|
|
382
|
+
const options = {
|
|
383
|
+
...flags,
|
|
384
|
+
json: argv.includes('--json'),
|
|
385
|
+
wait: Number(argument(argv, '--wait', '5000')),
|
|
386
|
+
...(argument(argv, '--idempotency-key', '') ? { idempotencyKey: argument(argv, '--idempotency-key', '') } : {})
|
|
387
|
+
};
|
|
388
|
+
// The command itself is the first word, and every verb takes at least a peer after it.
|
|
389
|
+
const [, peer, target] = positionals(argv);
|
|
390
|
+
if (command === 'peers')
|
|
391
|
+
return await runPeers(options);
|
|
392
|
+
if (!peer) {
|
|
393
|
+
process.stderr.write(`source-rpc ${command}: which peer? Run 'source-rpc peers' to see who is there.\n`);
|
|
394
|
+
return 1;
|
|
395
|
+
}
|
|
396
|
+
if (command === 'describe')
|
|
397
|
+
return await runDescribe(peer, options);
|
|
398
|
+
if (!target) {
|
|
399
|
+
process.stderr.write(`source-rpc ${command}: give it <namespace>.<${command === 'watch' ? 'event' : 'method'}>, e.g. plant.${command === 'watch' ? 'alarm' : 'writeSetpoint'}\n`);
|
|
400
|
+
return 1;
|
|
401
|
+
}
|
|
402
|
+
if (command === 'watch') {
|
|
403
|
+
// Ctrl-C is how this one ends, and it has to end tidily: the subscription on the far side
|
|
404
|
+
// outlives this process otherwise.
|
|
405
|
+
const stopped = new Promise((resolve) => {
|
|
406
|
+
process.on('SIGINT', () => resolve());
|
|
407
|
+
process.on('SIGTERM', () => resolve());
|
|
408
|
+
});
|
|
409
|
+
return await runWatch(peer, target, options, processOutput, stopped);
|
|
410
|
+
}
|
|
411
|
+
const rawArgs = argv.includes('--args') ? argument(argv, '--args', '[]') : undefined;
|
|
412
|
+
return await runCall(peer, target, positionals(argv).slice(3), { ...options, ...(rawArgs !== undefined ? { rawArgs } : {}) });
|
|
413
|
+
};
|
|
414
|
+
/**
|
|
415
|
+
* A stand-in built from a contract, so an HMI has something to talk to and a test has a device
|
|
416
|
+
* willing to fail on request - which a real one is not.
|
|
417
|
+
*/
|
|
418
|
+
const runFake = async (argv) => {
|
|
419
|
+
const contractPath = argument(argv, '--contract', '');
|
|
420
|
+
if (!contractPath) {
|
|
421
|
+
process.stderr.write('source-rpc serve: give it --contract <file>\n');
|
|
422
|
+
process.exit(1);
|
|
423
|
+
}
|
|
424
|
+
let schema;
|
|
425
|
+
try {
|
|
426
|
+
schema = readSchema(resolve(contractPath));
|
|
427
|
+
}
|
|
428
|
+
catch (e) {
|
|
429
|
+
process.stderr.write(`source-rpc serve: cannot read ${contractPath}: ${e.message}\n`);
|
|
430
|
+
process.exit(1);
|
|
431
|
+
}
|
|
432
|
+
const scriptPath = argument(argv, '--script', '');
|
|
433
|
+
let script = {};
|
|
434
|
+
if (scriptPath) {
|
|
435
|
+
try {
|
|
436
|
+
script = JSON.parse(readFileSync(resolve(scriptPath), 'utf8'));
|
|
437
|
+
}
|
|
438
|
+
catch (e) {
|
|
439
|
+
process.stderr.write(`source-rpc serve: cannot read ${scriptPath}: ${e.message}\n`);
|
|
440
|
+
process.exit(1);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
// The shorthand for the same thing, since staging one failure is the common case and does not
|
|
444
|
+
// deserve a file.
|
|
445
|
+
for (const pair of argumentList(argv, '--fail')) {
|
|
446
|
+
const equals = pair.indexOf('=');
|
|
447
|
+
if (equals <= 0) {
|
|
448
|
+
process.stderr.write(`source-rpc serve: --fail wants <namespace>.<method>=<Code>, got '${pair}'\n`);
|
|
449
|
+
process.exit(1);
|
|
450
|
+
}
|
|
451
|
+
script = { ...script, fails: { ...script.fails, [pair.slice(0, equals)]: pair.slice(equals + 1) } };
|
|
452
|
+
}
|
|
453
|
+
const { signing: _keys, ...network } = resolveNetworkFlags(argv, 'serve', 'fake');
|
|
454
|
+
const running = await startFake({ ...network, schema, ...(Object.keys(script).length ? { script } : {}) });
|
|
455
|
+
process.stdout.write(`source-rpc serve: ${network.name} answering ${running.namespaces.join(', ')} from ${contractPath}\n`);
|
|
456
|
+
// Anything calling this is talking to a stand-in. Worth one line, since a fake that is mistaken
|
|
457
|
+
// for the device is worse than no fake at all.
|
|
458
|
+
process.stderr.write('source-rpc serve: this is a fake. It answers from the contract, not from a device.\n');
|
|
459
|
+
const stop = () => void running
|
|
460
|
+
.close()
|
|
461
|
+
.then(() => process.exit(0))
|
|
462
|
+
.catch(() => process.exit(1));
|
|
463
|
+
process.on('SIGINT', stop);
|
|
464
|
+
process.on('SIGTERM', stop);
|
|
465
|
+
await new Promise(() => { });
|
|
466
|
+
};
|
|
467
|
+
/** Writes what the network is carrying to a file, so it can be replayed at something else later. */
|
|
468
|
+
const runRecord = async (argv) => {
|
|
469
|
+
const out = argument(argv, '--out', '');
|
|
470
|
+
if (!out) {
|
|
471
|
+
process.stderr.write('source-rpc record: give it --out <file>\n');
|
|
472
|
+
process.exit(1);
|
|
473
|
+
}
|
|
474
|
+
const { signing: _keys, ...network } = resolveNetworkFlags(argv, 'record', 'recorder');
|
|
475
|
+
const peerFilter = argument(argv, '--peer', '');
|
|
476
|
+
const namespaceFilter = argument(argv, '--namespace', '');
|
|
477
|
+
// On by default here, where the tap has them off: a recording without arguments and results
|
|
478
|
+
// cannot be replayed, which is the only reason to make one.
|
|
479
|
+
const payloads = !argv.includes('--no-payloads');
|
|
480
|
+
const running = await startRecording({
|
|
481
|
+
...network,
|
|
482
|
+
out: resolve(out),
|
|
483
|
+
filter: { payloads, ...(peerFilter ? { peer: peerFilter } : {}), ...(namespaceFilter ? { namespace: namespaceFilter } : {}), ttl: 3600 }
|
|
484
|
+
});
|
|
485
|
+
if (!running.sources.length) {
|
|
486
|
+
process.stderr.write('source-rpc record: nothing here can watch traffic - no broker exposing a bus, and no --broker link.\n');
|
|
487
|
+
await running.close();
|
|
488
|
+
process.exit(1);
|
|
489
|
+
}
|
|
490
|
+
process.stdout.write(`source-rpc record: writing ${out}, watching via ${running.sources.join(', ')}\n`);
|
|
491
|
+
if (payloads)
|
|
492
|
+
process.stderr.write('source-rpc record: arguments and results are being written to the file. Use --no-payloads to leave them out.\n');
|
|
493
|
+
const stop = () => void running
|
|
494
|
+
.close()
|
|
495
|
+
.then(() => {
|
|
496
|
+
process.stderr.write(`source-rpc record: ${running.frames()} frames\n`);
|
|
497
|
+
process.exit(0);
|
|
498
|
+
})
|
|
499
|
+
.catch(() => process.exit(1));
|
|
500
|
+
process.on('SIGINT', stop);
|
|
501
|
+
process.on('SIGTERM', stop);
|
|
502
|
+
const forMs = Number(argument(argv, '--for', '0'));
|
|
503
|
+
if (forMs > 0)
|
|
504
|
+
setTimeout(stop, forMs);
|
|
505
|
+
await new Promise(() => { });
|
|
506
|
+
};
|
|
507
|
+
/** Sends a recording's calls at a peer and compares the answers with the ones that were recorded. */
|
|
508
|
+
const runReplay = async (argv) => {
|
|
509
|
+
const file = positionals(argv)[1];
|
|
510
|
+
if (!file) {
|
|
511
|
+
process.stderr.write('source-rpc replay: which recording?\n');
|
|
512
|
+
return 1;
|
|
513
|
+
}
|
|
514
|
+
const { signing: _keys, ...network } = resolveNetworkFlags(argv, 'replay', 'replayer');
|
|
515
|
+
const json = argv.includes('--json');
|
|
516
|
+
const against = argument(argv, '--against', '');
|
|
517
|
+
let summary;
|
|
518
|
+
try {
|
|
519
|
+
summary = await replaySession({ ...network, file: resolve(file), speed: Number(argument(argv, '--speed', '1')), ...(against ? { against } : {}) }, json
|
|
520
|
+
? undefined
|
|
521
|
+
: (call) => {
|
|
522
|
+
if (call.outcome === 'matched')
|
|
523
|
+
return;
|
|
524
|
+
const where = `${call.target} ${call.namespace}.${call.method}`;
|
|
525
|
+
if (call.outcome === 'failed')
|
|
526
|
+
process.stdout.write(` ✗ ${where}: ${call.error}\n`);
|
|
527
|
+
else if (call.outcome === 'sent')
|
|
528
|
+
process.stdout.write(` · ${where}: sent, nothing recorded to compare\n`);
|
|
529
|
+
else
|
|
530
|
+
process.stdout.write(` ≠ ${where}: expected ${JSON.stringify(call.expected)}, got ${JSON.stringify(call.got)}\n`);
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
catch (e) {
|
|
534
|
+
process.stderr.write(`source-rpc replay: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
535
|
+
return 1;
|
|
536
|
+
}
|
|
537
|
+
if (json)
|
|
538
|
+
process.stdout.write(JSON.stringify(summary, null, 2) + '\n');
|
|
539
|
+
else
|
|
540
|
+
process.stdout.write(`source-rpc replay: ${summary.calls.length} call${summary.calls.length === 1 ? '' : 's'}, ` +
|
|
541
|
+
`${summary.matched} matched, ${summary.differed} differed, ${summary.failed} failed, ${summary.sent} uncompared\n`);
|
|
542
|
+
// An answer that differed is the finding this exists to produce, so it fails the command.
|
|
543
|
+
return summary.differed || summary.failed ? 1 : 0;
|
|
544
|
+
};
|
|
545
|
+
/**
|
|
546
|
+
* The build-time check pointed at a device: is the box on the wall running the contract its callers
|
|
547
|
+
* were built against?
|
|
548
|
+
*/
|
|
549
|
+
const runCheckPeer = async (argv, peer) => {
|
|
550
|
+
const against = resolve(argument(argv, '--against', 'msgrpc.types.json'));
|
|
551
|
+
let stored;
|
|
552
|
+
try {
|
|
553
|
+
stored = readSchema(against);
|
|
554
|
+
}
|
|
555
|
+
catch {
|
|
556
|
+
process.stderr.write(`source-rpc check: cannot read ${against}\n`);
|
|
557
|
+
return 1;
|
|
558
|
+
}
|
|
559
|
+
const { signing: _keys, ...network } = resolveNetworkFlags(argv, 'check', 'cli');
|
|
560
|
+
let report;
|
|
561
|
+
try {
|
|
562
|
+
report = await checkPeer({ ...network, peer, stored, wait: Number(argument(argv, '--wait', '5000')) });
|
|
563
|
+
}
|
|
564
|
+
catch (e) {
|
|
565
|
+
process.stderr.write(`source-rpc check: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
566
|
+
return 1;
|
|
567
|
+
}
|
|
568
|
+
if (argv.includes('--json')) {
|
|
569
|
+
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
570
|
+
return report.problems.length || report.missing.length ? 1 : 0;
|
|
571
|
+
}
|
|
572
|
+
for (const name of report.missing)
|
|
573
|
+
process.stderr.write(` ${name} is not served by ${peer}\n`);
|
|
574
|
+
for (const problem of report.problems)
|
|
575
|
+
process.stderr.write(` ${problem.namespace}.${problem.where} ${problem.reason}\n`);
|
|
576
|
+
// Said, and not counted as a pass: a peer running without a schema cannot be checked, and
|
|
577
|
+
// reporting "no breaking changes" about one would be a lie of the most useful-sounding kind.
|
|
578
|
+
for (const name of report.undescribed)
|
|
579
|
+
process.stderr.write(` ${name} is served without a contract, so nothing about it was checked\n`);
|
|
580
|
+
const count = report.problems.length + report.missing.length;
|
|
581
|
+
if (count) {
|
|
582
|
+
process.stderr.write(`source-rpc: ${count} breaking change${count === 1 ? '' : 's'} between ${against} and ${peer}\n`);
|
|
583
|
+
return 1;
|
|
584
|
+
}
|
|
585
|
+
process.stdout.write(`source-rpc: ${peer} serves ${report.checked.length ? report.checked.join(', ') : 'nothing'} compatibly with ${against}\n`);
|
|
586
|
+
return 0;
|
|
587
|
+
};
|
|
588
|
+
/** What two live peers offer differently, for when one cell behaves unlike the next. */
|
|
589
|
+
const runDiff = async (argv) => {
|
|
590
|
+
const [, left, right] = positionals(argv);
|
|
591
|
+
if (!left || !right) {
|
|
592
|
+
process.stderr.write('source-rpc diff: give it two peers\n');
|
|
593
|
+
return 1;
|
|
594
|
+
}
|
|
595
|
+
const { signing: _keys, ...network } = resolveNetworkFlags(argv, 'diff', 'cli');
|
|
596
|
+
let report;
|
|
597
|
+
try {
|
|
598
|
+
report = await diffPeers({ ...network, left, right, wait: Number(argument(argv, '--wait', '5000')) });
|
|
599
|
+
}
|
|
600
|
+
catch (e) {
|
|
601
|
+
process.stderr.write(`source-rpc diff: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
602
|
+
return 1;
|
|
603
|
+
}
|
|
604
|
+
if (argv.includes('--json')) {
|
|
605
|
+
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
606
|
+
return report.differences.length ? 1 : 0;
|
|
607
|
+
}
|
|
608
|
+
if (!report.differences.length) {
|
|
609
|
+
process.stdout.write(`source-rpc diff: ${left} and ${right} expose the same thing\n`);
|
|
610
|
+
return 0;
|
|
611
|
+
}
|
|
612
|
+
process.stdout.write(`${left} vs ${right}\n`);
|
|
613
|
+
for (const difference of report.differences) {
|
|
614
|
+
// Dotted for a method, spaced for the rest: `plant.read` is how you would say it, and
|
|
615
|
+
// `plant.contract version` is not.
|
|
616
|
+
const identifier = difference.member && /^[A-Za-z_$][\w$]*$/.test(difference.member);
|
|
617
|
+
const what = difference.member ? `${difference.namespace}${identifier ? '.' : ' '}${difference.member}` : difference.namespace;
|
|
618
|
+
process.stdout.write(`\n ${what}\n ${left}: ${difference.left ?? '—'}\n ${right}: ${difference.right ?? '—'}\n`);
|
|
619
|
+
}
|
|
620
|
+
// A difference is the finding, not a failure of the command - but an exit code lets a script
|
|
621
|
+
// assert that two cells match.
|
|
622
|
+
return 1;
|
|
623
|
+
};
|
|
624
|
+
/** One method, over and over, with percentiles - the script everybody writes, written once. */
|
|
625
|
+
const runBench = async (argv) => {
|
|
626
|
+
const words = positionals(argv);
|
|
627
|
+
const peer = words[1];
|
|
628
|
+
const target = words[2];
|
|
629
|
+
if (!peer || !target) {
|
|
630
|
+
process.stderr.write('source-rpc bench: give it a peer and <namespace>.<method>\n');
|
|
631
|
+
return 1;
|
|
632
|
+
}
|
|
633
|
+
const dot = target.lastIndexOf('.');
|
|
634
|
+
if (dot <= 0 || dot === target.length - 1) {
|
|
635
|
+
process.stderr.write(`source-rpc bench: '${target}' should be <namespace>.<method>\n`);
|
|
636
|
+
return 1;
|
|
637
|
+
}
|
|
638
|
+
const namespace = target.slice(0, dot);
|
|
639
|
+
const method = target.slice(dot + 1);
|
|
640
|
+
const { signing: _keys, ...network } = resolveNetworkFlags(argv, 'bench', 'bench');
|
|
641
|
+
let args;
|
|
642
|
+
try {
|
|
643
|
+
args = await benchArguments({ ...network, peer, namespace, method, texts: words.slice(3) });
|
|
644
|
+
}
|
|
645
|
+
catch (e) {
|
|
646
|
+
process.stderr.write(`source-rpc bench: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
647
|
+
return 1;
|
|
648
|
+
}
|
|
649
|
+
let report;
|
|
650
|
+
try {
|
|
651
|
+
report = await bench({
|
|
652
|
+
...network,
|
|
653
|
+
peer,
|
|
654
|
+
namespace,
|
|
655
|
+
method,
|
|
656
|
+
args,
|
|
657
|
+
rate: Number(argument(argv, '--rate', '10')),
|
|
658
|
+
forMs: Number(argument(argv, '--for', '10000')),
|
|
659
|
+
concurrency: Number(argument(argv, '--concurrency', '50')),
|
|
660
|
+
wait: Number(argument(argv, '--wait', '5000'))
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
catch (e) {
|
|
664
|
+
process.stderr.write(`source-rpc bench: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
665
|
+
return 1;
|
|
666
|
+
}
|
|
667
|
+
if (argv.includes('--json'))
|
|
668
|
+
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
669
|
+
else {
|
|
670
|
+
process.stdout.write(`${report.peer} ${report.method} ${report.calls} calls in ${(report.ranForMs / 1000).toFixed(1)}s at ${report.rate.achieved}/s\n`);
|
|
671
|
+
process.stdout.write(` ms min ${report.ms.min} p50 ${report.ms.p50} p90 ${report.ms.p90} p95 ${report.ms.p95} p99 ${report.ms.p99} max ${report.ms.max}\n`);
|
|
672
|
+
process.stdout.write(` ok ${report.ok} failed ${report.failed}${report.behind ? ` fell behind ${report.behind}` : ''}\n`);
|
|
673
|
+
for (const [code, count] of Object.entries(report.codes))
|
|
674
|
+
process.stdout.write(` ${code}: ${count}\n`);
|
|
675
|
+
}
|
|
676
|
+
// Errors under load are the finding, so they fail the command.
|
|
677
|
+
return report.failed ? 1 : 0;
|
|
678
|
+
};
|
|
679
|
+
const runBroker = async (argv) => {
|
|
680
|
+
const tls = readTls(argv, 'broker');
|
|
681
|
+
const port = listeningPort(argv, !!tls, defaultWebSocketPort, defaultSecureWebSocketPort);
|
|
682
|
+
const upstream = argumentList(argv, '--upstream');
|
|
683
|
+
const quiet = argv.includes('--quiet');
|
|
684
|
+
const name = argument(argv, '--name', readableNameFor('broker'));
|
|
685
|
+
const auth = readAuth(argv, 'broker');
|
|
686
|
+
let authenticate;
|
|
687
|
+
try {
|
|
688
|
+
authenticate = auth.tokens ? createTokenAuthenticator(auth.tokens) : undefined;
|
|
689
|
+
}
|
|
690
|
+
catch (e) {
|
|
691
|
+
// Every way of getting this wrong - a blank token, a grant with no name, an empty map -
|
|
692
|
+
// would otherwise start a bus that admits more than the operator meant it to.
|
|
693
|
+
process.stderr.write(`source-rpc broker: ${e.message}\n`);
|
|
694
|
+
process.exit(1);
|
|
695
|
+
}
|
|
696
|
+
const running = await startBroker({
|
|
697
|
+
port,
|
|
698
|
+
name,
|
|
699
|
+
...(tls ? { tls } : {}),
|
|
700
|
+
...(upstream.length ? { upstream } : {}),
|
|
701
|
+
...(authenticate ? { authenticate } : {}),
|
|
702
|
+
...(auth.token ? { upstreamCredentials: { token: auth.token } } : {}),
|
|
703
|
+
...(quiet ? {} : { onPeer: (peer, state, where) => process.stdout.write(` ${state === 'online' ? '+' : '-'} ${peer} (${where})\n`) })
|
|
704
|
+
}).catch((e) => {
|
|
705
|
+
// A port already taken is the ordinary way this fails, and it deserves a sentence.
|
|
706
|
+
process.stderr.write(`source-rpc broker: cannot start on port ${port}: ${e.message}\n`);
|
|
707
|
+
process.exit(1);
|
|
708
|
+
});
|
|
709
|
+
process.stdout.write(`source-rpc broker ${name} on ${tls ? 'wss' : 'ws'} port ${port}${authenticate ? ', authenticating' : ''}${upstream.length ? `, joined to ${upstream.join(', ')}` : ''}\n`);
|
|
710
|
+
if (!authenticate) {
|
|
711
|
+
// It listens on every interface and forwards for whoever connects, without checking who they
|
|
712
|
+
// are. Worth saying plainly rather than leaving to be discovered.
|
|
713
|
+
process.stderr.write('source-rpc broker: relaying for any peer that connects, on every interface. Put it behind a network you trust, or give it --auth.\n');
|
|
714
|
+
// And now it will also show them everything it relays, if they ask. They could always have read
|
|
715
|
+
// it by impersonating a peer; this is merely one call. Said out loud for the same reason.
|
|
716
|
+
process.stderr.write('source-rpc broker: bus.tap() mirrors every frame crossing this broker to whoever calls it. --auth is what gates that.\n');
|
|
717
|
+
}
|
|
718
|
+
// Catching matters most here: a shutdown that fails would otherwise reject unhandled, and the
|
|
719
|
+
// process would die on that instead of exiting cleanly - and print nothing about why.
|
|
720
|
+
const stop = () => void running
|
|
721
|
+
.close()
|
|
722
|
+
.then(() => process.exit(0))
|
|
723
|
+
.catch((e) => {
|
|
724
|
+
process.stderr.write(`source-rpc: shutdown failed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
725
|
+
process.exit(1);
|
|
726
|
+
});
|
|
727
|
+
process.on('SIGINT', stop);
|
|
728
|
+
process.on('SIGTERM', stop);
|
|
729
|
+
// Nothing else keeps this process alive; the listener does.
|
|
730
|
+
await new Promise(() => { });
|
|
731
|
+
};
|
|
732
|
+
const runMcp = async (argv) => {
|
|
733
|
+
const { signing: _keys, ...network } = resolveNetworkFlags(argv, 'mcp', 'mcp');
|
|
734
|
+
const contracts = argument(argv, '--contracts', '');
|
|
735
|
+
const running = await startMcp({ ...network, ...(contracts ? { contracts: resolve(contracts) } : {}) });
|
|
736
|
+
// Nothing is written to stdout here: it carries the protocol. See mcp.ts.
|
|
737
|
+
const stop = () => void running
|
|
738
|
+
.close()
|
|
739
|
+
.then(() => process.exit(0))
|
|
740
|
+
.catch(() => process.exit(1));
|
|
741
|
+
process.on('SIGINT', stop);
|
|
742
|
+
process.on('SIGTERM', stop);
|
|
743
|
+
// The client closing the pipe is the ordinary way this ends.
|
|
744
|
+
process.stdin.on('end', stop);
|
|
745
|
+
};
|
|
746
|
+
const runConsole = async (argv) => {
|
|
747
|
+
const { signing, ...network } = resolveNetworkFlags(argv, 'console', 'console');
|
|
748
|
+
const host = argument(argv, '--host', '127.0.0.1');
|
|
749
|
+
const basePath = argument(argv, '--base-path', '');
|
|
750
|
+
const tls = readTls(argv, 'console');
|
|
751
|
+
const running = await startConsole({
|
|
752
|
+
...network,
|
|
753
|
+
port: listeningPort(argv, !!tls, defaultWebPort, defaultSecureWebPort),
|
|
754
|
+
host,
|
|
755
|
+
...(tls ? { tls } : {}),
|
|
756
|
+
...(basePath ? { basePath } : {})
|
|
757
|
+
});
|
|
758
|
+
const watching = [network.broker, network.hub].filter(Boolean).join(' and ');
|
|
759
|
+
process.stdout.write(`source-rpc console on ${running.url}, watching ${watching} as ${network.name}${signing ? ', signing frames' : ''}\n`);
|
|
760
|
+
if (host !== '127.0.0.1' && host !== 'localhost')
|
|
761
|
+
// Anyone who can reach it can invoke anything the console's own credentials permit.
|
|
762
|
+
process.stderr.write(`source-rpc console: bound to ${host}, so it is reachable from the network. It can call any method it is allowed to.\n`);
|
|
763
|
+
// Catching matters most here: a shutdown that fails would otherwise reject unhandled, and the
|
|
764
|
+
// process would die on that instead of exiting cleanly - and print nothing about why.
|
|
765
|
+
const stop = () => void running
|
|
766
|
+
.close()
|
|
767
|
+
.then(() => process.exit(0))
|
|
768
|
+
.catch((e) => {
|
|
769
|
+
process.stderr.write(`source-rpc: shutdown failed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
770
|
+
process.exit(1);
|
|
771
|
+
});
|
|
772
|
+
process.on('SIGINT', stop);
|
|
773
|
+
process.on('SIGTERM', stop);
|
|
774
|
+
};
|
|
775
|
+
const main = () => {
|
|
776
|
+
// `source-rpc describe plantServer | head -4` closes stdout while there is still output to
|
|
777
|
+
// write, and Node turns that into an unhandled 'error' event: a stack trace where a command
|
|
778
|
+
// should simply stop. Every verb here writes to stdout, and half the documented examples are
|
|
779
|
+
// pipelines, so this belongs at the entry point rather than around each write.
|
|
780
|
+
for (const stream of [process.stdout, process.stderr])
|
|
781
|
+
stream.on('error', (e) => {
|
|
782
|
+
if (e.code === 'EPIPE')
|
|
783
|
+
process.exit(0);
|
|
784
|
+
throw e;
|
|
785
|
+
});
|
|
786
|
+
const argv = process.argv.slice(2);
|
|
787
|
+
const command = argv[0];
|
|
788
|
+
const project = resolve(argument(argv, '--project', 'tsconfig.json'));
|
|
789
|
+
// Both are long-running and async, so their rejections were unhandled: the process died on the
|
|
790
|
+
// rejection itself, with a stack trace where a sentence belonged.
|
|
791
|
+
const fail = (e) => {
|
|
792
|
+
process.stderr.write(`source-rpc ${command}: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
793
|
+
process.exit(1);
|
|
794
|
+
};
|
|
795
|
+
if (command === 'broker') {
|
|
796
|
+
void runBroker(argv).catch(fail);
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
if (command === 'console') {
|
|
800
|
+
void runConsole(argv).catch(fail);
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
if (command === 'mcp') {
|
|
804
|
+
void runMcp(argv).catch(fail);
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
if (command === 'serve') {
|
|
808
|
+
void runFake(argv).catch(fail);
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
if (command === 'bench') {
|
|
812
|
+
void runBench(argv)
|
|
813
|
+
.then((code) => process.exit(code))
|
|
814
|
+
.catch(fail);
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
if (command === 'diff') {
|
|
818
|
+
void runDiff(argv)
|
|
819
|
+
.then((code) => process.exit(code))
|
|
820
|
+
.catch(fail);
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
if (command === 'check' && argument(argv, '--peer', '')) {
|
|
824
|
+
void runCheckPeer(argv, argument(argv, '--peer', ''))
|
|
825
|
+
.then((code) => process.exit(code))
|
|
826
|
+
.catch(fail);
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
if (command === 'record') {
|
|
830
|
+
void runRecord(argv).catch(fail);
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
if (command === 'replay') {
|
|
834
|
+
void runReplay(argv)
|
|
835
|
+
.then((code) => process.exit(code))
|
|
836
|
+
.catch(fail);
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
if (command === 'peers' || command === 'describe' || command === 'call' || command === 'watch') {
|
|
840
|
+
// These end, and their exit code is the answer, so the process waits for one rather than
|
|
841
|
+
// being kept alive by a listener the way console and broker are.
|
|
842
|
+
void runVerb(command, argv)
|
|
843
|
+
.then((code) => process.exit(code))
|
|
844
|
+
.catch(fail);
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
if (command !== 'extract' && command !== 'check') {
|
|
848
|
+
process.stderr.write(usage);
|
|
849
|
+
process.exit(command ? 1 : 0);
|
|
850
|
+
}
|
|
851
|
+
const { schema, diagnostics } = extractSchema(project);
|
|
852
|
+
if (diagnostics.length) {
|
|
853
|
+
// Refused rather than written with holes in it: a schema that degrades to `any` on the
|
|
854
|
+
// parts it could not read still looks like protection while checking nothing.
|
|
855
|
+
process.stderr.write(`source-rpc: ${diagnostics.length} type${diagnostics.length === 1 ? '' : 's'} could not be described\n`);
|
|
856
|
+
reportDiagnostics(diagnostics);
|
|
857
|
+
process.exit(1);
|
|
858
|
+
}
|
|
859
|
+
if (command === 'extract') {
|
|
860
|
+
const out = resolve(argument(argv, '--out', 'msgrpc.types.json'));
|
|
861
|
+
let previous;
|
|
862
|
+
try {
|
|
863
|
+
previous = readSchema(out);
|
|
864
|
+
}
|
|
865
|
+
catch {
|
|
866
|
+
previous = undefined;
|
|
867
|
+
}
|
|
868
|
+
const written = argv.includes('--keep-history') ? withHistory(schema, previous) : schema;
|
|
869
|
+
writeFileSync(out, JSON.stringify(written, null, 2) + '\n');
|
|
870
|
+
const count = Object.keys(schema.namespaces).length;
|
|
871
|
+
process.stdout.write(`source-rpc: wrote ${count} namespace${count === 1 ? '' : 's'} to ${out}\n`);
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
const against = resolve(argument(argv, '--against', 'msgrpc.types.json'));
|
|
875
|
+
let stored;
|
|
876
|
+
try {
|
|
877
|
+
stored = readSchema(against);
|
|
878
|
+
}
|
|
879
|
+
catch {
|
|
880
|
+
process.stderr.write(`source-rpc: cannot read ${against}\n`);
|
|
881
|
+
process.exit(1);
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
let breaking = 0;
|
|
885
|
+
for (const [name, before] of Object.entries(stored.namespaces)) {
|
|
886
|
+
const now = schema.namespaces[name];
|
|
887
|
+
if (!now) {
|
|
888
|
+
process.stderr.write(` ${name} is no longer served\n`);
|
|
889
|
+
breaking++;
|
|
890
|
+
continue;
|
|
891
|
+
}
|
|
892
|
+
// The same comparison the server applies to a caller declaring an older version.
|
|
893
|
+
const problems = namespaceProblems(before, now, { ...stored.types, ...schema.types });
|
|
894
|
+
for (const problem of problems)
|
|
895
|
+
process.stderr.write(` ${name}.${problem.where} ${problem.reason}\n`);
|
|
896
|
+
breaking += problems.length;
|
|
897
|
+
}
|
|
898
|
+
if (breaking) {
|
|
899
|
+
process.stderr.write(`source-rpc: ${breaking} breaking change${breaking === 1 ? '' : 's'} against ${against}\n`);
|
|
900
|
+
process.exit(1);
|
|
901
|
+
}
|
|
902
|
+
process.stdout.write(`source-rpc: no breaking changes against ${against}\n`);
|
|
903
|
+
};
|
|
904
|
+
main();
|
|
905
|
+
//# sourceMappingURL=index.js.map
|