@swipt/cli 0.1.0-nightly.32 → 0.1.0-nightly.34

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/client.mjs ADDED
@@ -0,0 +1,298 @@
1
+ import dns from "node:dns";
2
+
3
+ import { create, fromBinary, toBinary } from "@bufbuild/protobuf";
4
+
5
+ import { LogService } from "./proto/logs_pb.js";
6
+ import { REPLService } from "./proto/repl_pb.js";
7
+ import { SwipletService } from "./proto/swiplets_pb.js";
8
+ import { SystemService } from "./proto/system_pb.js";
9
+
10
+ const largestMessage = 128 << 20;
11
+ const contentType = "application/grpc-web+proto";
12
+ const webSocketSubprotocol = "grpc-websockets";
13
+
14
+ dns.setDefaultResultOrder("ipv4first");
15
+
16
+ export const Code = {
17
+ Canceled: 1,
18
+ Unknown: 2,
19
+ DeadlineExceeded: 4,
20
+ NotFound: 5,
21
+ Unimplemented: 12,
22
+ Internal: 13,
23
+ Unavailable: 14,
24
+ Unauthenticated: 16,
25
+ };
26
+
27
+ export class UsageError extends Error {}
28
+
29
+ export class RpcError extends Error {
30
+ constructor(code, message) {
31
+ super(message);
32
+ this.code = code;
33
+ }
34
+ }
35
+
36
+ const httpStatusCodes = { 400: Code.Internal, 401: Code.Unauthenticated, 403: Code.Unauthenticated, 404: Code.Unimplemented, 429: Code.Unavailable, 502: Code.Unavailable, 503: Code.Unavailable, 504: Code.Unavailable };
37
+
38
+ function statusMessage(text) {
39
+ if (text === undefined || text === null) {
40
+ return "";
41
+ }
42
+ try {
43
+ return decodeURIComponent(text);
44
+ } catch {
45
+ return text;
46
+ }
47
+ }
48
+
49
+ const transportError = error => {
50
+ if (error instanceof RpcError) {
51
+ return error;
52
+ }
53
+ if (error?.name === "AbortError") {
54
+ return new RpcError(Code.Canceled, "the call was canceled");
55
+ }
56
+ const cause = error?.cause;
57
+ return new RpcError(Code.Unavailable, cause?.message || cause?.code || error?.message || String(error));
58
+ };
59
+
60
+ function frame(schema, init) {
61
+ const body = toBinary(schema, create(schema, init));
62
+ const out = Buffer.allocUnsafe(5 + body.length);
63
+ out[0] = 0;
64
+ out.writeUInt32BE(body.length, 1);
65
+ out.set(body, 5);
66
+ return out;
67
+ }
68
+
69
+ function parseMetadata(body) {
70
+ const headers = {};
71
+ for (const line of Buffer.from(body).toString().split("\r\n")) {
72
+ const separator = line.indexOf(":");
73
+ if (separator > 0) {
74
+ headers[line.slice(0, separator).trim().toLowerCase()] = line.slice(separator + 1).trim();
75
+ }
76
+ }
77
+ return headers;
78
+ }
79
+
80
+ class FrameReader {
81
+ pending = Buffer.alloc(0);
82
+
83
+ constructor(schema) {
84
+ this.schema = schema;
85
+ }
86
+
87
+ push(chunk) {
88
+ this.pending = this.pending.length === 0 ? Buffer.from(chunk) : Buffer.concat([this.pending, Buffer.from(chunk)]);
89
+ const frames = [];
90
+ while (this.pending.length >= 5) {
91
+ const length = this.pending.readUInt32BE(1);
92
+ if (length > largestMessage) {
93
+ throw new RpcError(Code.Internal, `the server sent a message of ${length} bytes`);
94
+ }
95
+ if (this.pending.length < 5 + length) {
96
+ break;
97
+ }
98
+ const body = this.pending.subarray(5, 5 + length);
99
+ frames.push((this.pending[0] & 0x80) !== 0 ? { trailers: parseMetadata(body) } : { message: fromBinary(this.schema, body) });
100
+ this.pending = this.pending.subarray(5 + length);
101
+ }
102
+ return frames;
103
+ }
104
+ }
105
+
106
+ function checkStatus(headers) {
107
+ const status = headers["grpc-status"];
108
+ if (status === undefined || status === null) {
109
+ return false;
110
+ }
111
+ const code = Number(status);
112
+ if (code !== 0) {
113
+ throw new RpcError(code, statusMessage(headers["grpc-message"]));
114
+ }
115
+ return true;
116
+ }
117
+
118
+ function requestHeaders(connection) {
119
+ const headers = { "content-type": contentType, "x-grpc-web": "1" };
120
+ if (connection.token !== "") {
121
+ headers.authorization = `Bearer ${connection.token}`;
122
+ }
123
+ return headers;
124
+ }
125
+
126
+ async function* invokeHTTP(connection, method, input, { signal }) {
127
+ let response;
128
+ try {
129
+ response = await fetch(`${connection.base}/${method.parent.typeName}/${method.name}`, {
130
+ method: "POST",
131
+ headers: requestHeaders(connection),
132
+ body: frame(method.input, input),
133
+ signal,
134
+ });
135
+ } catch (error) {
136
+ throw transportError(error);
137
+ }
138
+ const headers = Object.fromEntries(response.headers);
139
+ if (response.status !== 200) {
140
+ await response.body?.cancel();
141
+ throw new RpcError(httpStatusCodes[response.status] ?? Code.Unknown, `HTTP ${response.status}`);
142
+ }
143
+ if (checkStatus(headers)) {
144
+ await response.body?.cancel();
145
+ return;
146
+ }
147
+ const reader = new FrameReader(method.output);
148
+ let ended = false;
149
+ try {
150
+ for await (const chunk of response.body) {
151
+ for (const found of reader.push(chunk)) {
152
+ if (found.trailers) {
153
+ checkStatus(found.trailers);
154
+ ended = true;
155
+ } else if (!ended) {
156
+ yield found.message;
157
+ }
158
+ }
159
+ }
160
+ } catch (error) {
161
+ throw transportError(error);
162
+ }
163
+ if (!ended) {
164
+ throw new RpcError(Code.Unavailable, "the response ended without a status");
165
+ }
166
+ }
167
+
168
+ async function* invokeWebSocket(connection, method, input, { signal }) {
169
+ const socket = new WebSocket(`${connection.webSocketBase}/${method.parent.typeName}/${method.name}`, [webSocketSubprotocol]);
170
+ socket.binaryType = "arraybuffer";
171
+ const reader = new FrameReader(method.output);
172
+ const messages = [];
173
+ let failure;
174
+ let closed = false;
175
+ let headersSeen = false;
176
+ let wake = () => {};
177
+ const notify = () => {
178
+ const current = wake;
179
+ wake = () => {};
180
+ current();
181
+ };
182
+ const fail = error => {
183
+ failure ??= error;
184
+ socket.close();
185
+ notify();
186
+ };
187
+ const abort = () => fail(new RpcError(Code.Canceled, "the call was canceled"));
188
+ const opened = new Promise((resolve, reject) => {
189
+ socket.addEventListener("open", resolve, { once: true });
190
+ socket.addEventListener("error", () => reject(new RpcError(Code.Unavailable, `cannot connect to ${connection.webSocketBase}`)), { once: true });
191
+ });
192
+ socket.addEventListener("message", event => {
193
+ try {
194
+ for (const found of reader.push(new Uint8Array(event.data))) {
195
+ if (found.message) {
196
+ messages.push(found.message);
197
+ } else if (headersSeen || "grpc-status" in found.trailers) {
198
+ checkStatus(found.trailers);
199
+ closed = true;
200
+ socket.close();
201
+ } else {
202
+ headersSeen = true;
203
+ }
204
+ }
205
+ } catch (error) {
206
+ fail(error);
207
+ }
208
+ notify();
209
+ });
210
+ socket.addEventListener("close", event => {
211
+ if (!closed) {
212
+ failure ??= new RpcError(Code.Unavailable, event.reason || "the connection closed before the response");
213
+ }
214
+ closed = true;
215
+ notify();
216
+ });
217
+ socket.addEventListener("error", () => {
218
+ failure ??= new RpcError(Code.Unavailable, `cannot connect to ${connection.webSocketBase}`);
219
+ });
220
+ signal?.addEventListener("abort", abort, { once: true });
221
+ try {
222
+ await opened;
223
+ const headers = requestHeaders(connection);
224
+ socket.send(Buffer.from(`${Object.entries(headers).map(([key, value]) => `${key}: ${value}\r\n`).join("")}`));
225
+ const sender = (async () => {
226
+ try {
227
+ const body = method.methodKind === "client_streaming" ? input : [input];
228
+ for await (const message of body) {
229
+ if (closed) {
230
+ return;
231
+ }
232
+ socket.send(Buffer.concat([Buffer.from([0]), frame(method.input, message)]));
233
+ }
234
+ if (!closed) {
235
+ socket.send(Buffer.from([1]));
236
+ }
237
+ } catch (error) {
238
+ fail(transportError(error));
239
+ }
240
+ })();
241
+ while (true) {
242
+ if (messages.length > 0) {
243
+ yield messages.shift();
244
+ continue;
245
+ }
246
+ if (closed) {
247
+ break;
248
+ }
249
+ await new Promise(resolve => {
250
+ wake = resolve;
251
+ });
252
+ }
253
+ await sender;
254
+ if (failure) {
255
+ throw failure;
256
+ }
257
+ } finally {
258
+ signal?.removeEventListener("abort", abort);
259
+ if (socket.readyState !== WebSocket.CLOSED) {
260
+ socket.close();
261
+ }
262
+ }
263
+ }
264
+
265
+ async function single(iterable) {
266
+ let found;
267
+ for await (const message of iterable) {
268
+ found ??= message;
269
+ }
270
+ if (found === undefined) {
271
+ throw new RpcError(Code.Internal, "the server sent no response");
272
+ }
273
+ return found;
274
+ }
275
+
276
+ function bind(service, connection) {
277
+ const client = {};
278
+ for (const method of service.methods) {
279
+ const streamsInput = method.methodKind === "client_streaming" || method.methodKind === "bidi_streaming";
280
+ client[method.localName] = (input, options = {}) => {
281
+ const call = (streamsInput ? invokeWebSocket : invokeHTTP)(connection, method, input, { signal: options.signal });
282
+ return method.methodKind === "unary" || method.methodKind === "client_streaming" ? single(call) : call;
283
+ };
284
+ }
285
+ return client;
286
+ }
287
+
288
+ export function open({ url, token }) {
289
+ const connection = { token, base: url, webSocketBase: url.replace(/^http/, "ws") };
290
+ return {
291
+ system: bind(SystemService, connection),
292
+ logs: bind(LogService, connection),
293
+ swiplets: bind(SwipletService, connection),
294
+ repl: bind(REPLService, connection),
295
+ };
296
+ }
297
+
298
+ export const unauthenticated = error => error instanceof RpcError && error.code === Code.Unauthenticated;
package/connection.mjs ADDED
@@ -0,0 +1,227 @@
1
+ import dgram from "node:dgram";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { setTimeout as sleep } from "node:timers/promises";
6
+
7
+ import Bonjour from "bonjour-service";
8
+
9
+ import { Code, RpcError, UsageError, open } from "./client.mjs";
10
+ import { columns } from "./output.mjs";
11
+
12
+ const browseLimit = 1500;
13
+ const browseRequery = 300;
14
+
15
+ const storeFile = path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "swipt", "devices.json");
16
+
17
+ function load() {
18
+ let text;
19
+ try {
20
+ text = fs.readFileSync(storeFile, "utf8");
21
+ } catch (error) {
22
+ if (error.code === "ENOENT") {
23
+ return {};
24
+ }
25
+ throw error;
26
+ }
27
+ return JSON.parse(text);
28
+ }
29
+
30
+ function save(devices) {
31
+ fs.mkdirSync(path.dirname(storeFile), { recursive: true, mode: 0o700 });
32
+ fs.writeFileSync(storeFile, `${JSON.stringify(devices, null, 2)}\n`, { mode: 0o600 });
33
+ }
34
+
35
+ function label(name) {
36
+ const slug = [...name.replaceAll(/['’]/g, "")]
37
+ .map(character => (/[A-Za-z0-9]/.test(character) ? character : "-"))
38
+ .join("")
39
+ .split("-")
40
+ .filter(part => part !== "")
41
+ .join("-");
42
+ return slug === "" ? name : slug;
43
+ }
44
+
45
+ const sameName = (a, b) => a.toLowerCase() === b.toLowerCase();
46
+
47
+ const storedName = (stored, name) => Object.keys(stored).find(it => sameName(it, name));
48
+
49
+ function parseURL(text) {
50
+ let parsed;
51
+ try {
52
+ parsed = new URL(text.includes("://") ? text : `http://${text}`);
53
+ } catch {
54
+ throw new UsageError(`${text} is not a URL`);
55
+ }
56
+ if (parsed.hostname === "" || (parsed.protocol !== "http:" && parsed.protocol !== "https:")) {
57
+ throw new UsageError(`${text} is not an http or https URL`);
58
+ }
59
+ let token;
60
+ try {
61
+ token = decodeURIComponent(parsed.username);
62
+ } catch {
63
+ token = parsed.username;
64
+ }
65
+ return { url: `${parsed.protocol}//${parsed.host}${parsed.pathname.replace(/\/+$/, "")}`, token };
66
+ }
67
+
68
+ export const withToken = url => url.replace("://", "://TOKEN@");
69
+
70
+ async function browse(signal, until) {
71
+ const bonjour = new Bonjour({ socket: dgram.createSocket("udp4"), bind: false });
72
+ const found = new Map();
73
+ const enough = new AbortController();
74
+ try {
75
+ const browser = bonjour.find({ type: "swipt" }, service => {
76
+ if (!service.host || found.has(service.name)) {
77
+ return;
78
+ }
79
+ const host = service.host.replace(/\.$/, "").toLowerCase();
80
+ found.set(service.name, {
81
+ name: label(service.name),
82
+ url: `http://${host.includes(":") ? `[${host}]` : host}:${service.port}`,
83
+ authenticates: service.txt?.auth === "1",
84
+ });
85
+ if (until?.([...found.values()])) {
86
+ enough.abort();
87
+ }
88
+ });
89
+ const requery = setInterval(() => browser.update(), browseRequery);
90
+ try {
91
+ await sleep(browseLimit, undefined, { signal: AbortSignal.any([signal, enough.signal]) });
92
+ } catch (error) {
93
+ if (signal.aborted) {
94
+ throw error;
95
+ }
96
+ } finally {
97
+ clearInterval(requery);
98
+ }
99
+ browser.stop();
100
+ } finally {
101
+ bonjour.destroy();
102
+ }
103
+ return [...found.values()];
104
+ }
105
+
106
+ async function discover(signal, stored, until) {
107
+ const found = await browse(signal, until);
108
+ let changed = false;
109
+ for (const it of found) {
110
+ if (it.authenticates) {
111
+ continue;
112
+ }
113
+ const name = storedName(stored, it.name);
114
+ if (name === undefined) {
115
+ stored[it.name] = it.url;
116
+ changed = true;
117
+ } else {
118
+ const current = parseURL(stored[name]);
119
+ if (current.token === "" && current.url !== it.url) {
120
+ stored[name] = it.url;
121
+ changed = true;
122
+ }
123
+ }
124
+ }
125
+ if (changed) {
126
+ save(stored);
127
+ }
128
+ return found;
129
+ }
130
+
131
+ const requiresToken = found => `${found.name} requires a token: swipt connect ${withToken(found.url)}`;
132
+
133
+ export async function attach(context) {
134
+ const given = context.options.device ?? process.env.SWIPT_DEVICE;
135
+ const stored = load();
136
+ let device;
137
+ if (given && /[:/]/.test(given)) {
138
+ device = parseURL(given);
139
+ } else if (given) {
140
+ const name = label(given);
141
+ let known = storedName(stored, name);
142
+ if (known === undefined) {
143
+ const found = (await discover(context.signal, stored, services => services.some(it => sameName(it.name, name)))).find(it => sameName(it.name, name));
144
+ if (!found) {
145
+ throw new Error(`there is no device ${name}; "swipt devices" lists them`);
146
+ }
147
+ if (found.authenticates) {
148
+ throw new Error(requiresToken(found));
149
+ }
150
+ known = found.name;
151
+ }
152
+ device = parseURL(stored[known]);
153
+ } else {
154
+ let found = [];
155
+ if (Object.keys(stored).length === 0) {
156
+ found = await discover(context.signal, stored);
157
+ }
158
+ const names = Object.keys(stored);
159
+ if (names.length === 0) {
160
+ const locked = found.filter(it => it.authenticates);
161
+ if (locked.length > 0) {
162
+ throw new Error(locked.map(requiresToken).join("\n"));
163
+ }
164
+ throw new Error('there is no device; turn on Swipt › Developer › Local Network, or connect one with "swipt connect URL"');
165
+ }
166
+ if (names.length > 1) {
167
+ throw new Error(`there is more than one device: ${names.join(", ")}; choose one with -d or SWIPT_DEVICE`);
168
+ }
169
+ device = parseURL(stored[names[0]]);
170
+ }
171
+ context.target.url = device.url;
172
+ return open(device);
173
+ }
174
+
175
+ async function reachable(device, signal) {
176
+ try {
177
+ await open(device).system.getInfo({}, { signal });
178
+ return true;
179
+ } catch (error) {
180
+ return error instanceof RpcError && error.code === Code.Unauthenticated;
181
+ }
182
+ }
183
+
184
+ export async function devices({ signal }) {
185
+ const stored = load();
186
+ const deadline = AbortSignal.any([signal, AbortSignal.timeout(browseLimit)]);
187
+ const probes = new Map(Object.entries(stored).map(([name, url]) => [name, reachable(parseURL(url), deadline)]));
188
+ const found = await discover(signal, stored);
189
+ const names = Object.keys(stored).sort((a, b) => a.localeCompare(b));
190
+ if (names.length === 0) {
191
+ console.error('no device publishes Swipt on the local network; turn on Swipt › Developer › Local Network, or connect one with "swipt connect URL"');
192
+ return;
193
+ }
194
+ const rows = [];
195
+ for (const name of names) {
196
+ const { url } = parseURL(stored[name]);
197
+ const online = found.some(it => sameName(it.name, name) && it.url === url) || (probes.has(name) && (await probes.get(name)));
198
+ rows.push([name, url, online ? "" : "offline"]);
199
+ }
200
+ console.log(columns(rows));
201
+ }
202
+
203
+ export async function connect(context) {
204
+ const device = parseURL(context.args[0]);
205
+ context.target.url = device.url;
206
+ const info = await open(device).system.getInfo({}, { signal: context.signal });
207
+ const name = label(info.deviceName) || new URL(device.url).hostname;
208
+ const stored = load();
209
+ const previous = storedName(stored, name);
210
+ if (previous !== undefined) {
211
+ delete stored[previous];
212
+ }
213
+ stored[name] = context.args[0];
214
+ save(stored);
215
+ console.log("connected", name);
216
+ }
217
+
218
+ export function disconnect({ args }) {
219
+ const stored = load();
220
+ const name = storedName(stored, label(args[0]));
221
+ if (name === undefined) {
222
+ throw new Error(`there is no device ${args[0]}`);
223
+ }
224
+ delete stored[name];
225
+ save(stored);
226
+ console.log("disconnected", name);
227
+ }