@veilway/sdk 0.0.0-stage → 0.2.6-alpha

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/README.md CHANGED
@@ -1,3 +1,48 @@
1
- # Temporary Holding Version
1
+ # @veilway/sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A typed client for the control API of a Veilway node. The node serves the API on a unix socket, so the client takes a `fetch` function and a base URL and leaves the choice of how to reach the socket to you. It has no runtime dependencies.
4
+
5
+ ## Use
6
+
7
+ ```ts
8
+ import {Agent, fetch as undiciFetch} from "undici";
9
+ import {Client, VeilwayError, type Fetch} from "@veilway/sdk";
10
+
11
+ const dispatcher = new Agent({connect: {socketPath: "/run/veilway/control.sock"}});
12
+ const fetch: Fetch = (url, init) => undiciFetch(url, {...init, dispatcher}) as unknown as ReturnType<Fetch>;
13
+ const client = new Client(fetch, "http://veilway");
14
+ console.log(await client.status());
15
+ const running = await client.getConfig();
16
+ try {
17
+ await client.applyConfig({...running.document, log: {level: "debug"}}, running.revision);
18
+ } catch (e) {
19
+ if (e instanceof VeilwayError) console.log(e.status, e.code, e.message, e.details);
20
+ }
21
+ const stop = new AbortController();
22
+ for await (const event of client.events(stop.signal)) {
23
+ if (event.event === "user_cut_off") console.log(event.data.user);
24
+ }
25
+ ```
26
+
27
+ Every failure from the node is thrown as `VeilwayError` with `status`, `code`, `message` and `details`. A stale revision on `applyConfig` has the code `stale_revision`, and its first detail holds the revision that runs now.
28
+
29
+ `events` yields `{id, event, data}`, ends when the node closes the stream or the signal aborts, and tells a slow reader how many events it missed with a `lagged` event (which has no id). Event names the client does not know are skipped.
30
+
31
+ The config document in `getConfig` has every secret replaced by `"<redacted>"`. Pass it back as it is and the node keeps the real values.
32
+
33
+ ## Commands
34
+
35
+ Everything runs in Docker with the image `node:22`.
36
+
37
+ ```
38
+ docker run --rm -v "$PWD/../..:/w" -v veilway-sdk-nm:/w/packages/sdk/node_modules -w /w/packages/sdk node:22 sh -c "npm ci && npm run generate"
39
+ docker run --rm -v "$PWD/../..:/w" -v veilway-sdk-nm:/w/packages/sdk/node_modules -w /w/packages/sdk node:22 sh -c "npm ci && npm run check"
40
+ ```
41
+
42
+ - `npm run generate` writes `src/schema.ts` from `crates/veilway-api/openapi.json`. Run it after the Rust tests rewrite that file.
43
+ - `npm run check` generates into a temporary file, fails if it differs from `src/schema.ts`, then type-checks.
44
+ - `npm test` compiles and runs the tests. The ones that talk to a node need `VEILWAY_API_SOCKET` and are skipped without it. The parser test needs no node.
45
+
46
+ ## Test against a running node
47
+
48
+ The test starts nothing. `test/live.sh` starts a node from a release binary with a throwaway config and keys, runs `npm test` against its socket and stops the node. Run it in a container that has both node 22 and a `veilway` binary that matches its libc, with `VEILWAY_BIN` set to the binary. Without the script, start a node with `veilway run --api /path/to.sock` and set `VEILWAY_API_SOCKET` to that path.
@@ -0,0 +1,93 @@
1
+ import type { components } from "./schema.js";
2
+ type Schemas = components["schemas"];
3
+ export type { components, paths, operations } from "./schema.js";
4
+ export type Status = Schemas["Status"];
5
+ export type Applied = Schemas["Applied"];
6
+ export type Difference = Schemas["Difference"];
7
+ export type Validation = Schemas["Validation"];
8
+ export type User = Schemas["User"];
9
+ export type Stats = Schemas["Stats"];
10
+ export type Replay = Schemas["Replay"];
11
+ export type Session = Schemas["Session"];
12
+ export type Exit = Schemas["Exit"];
13
+ export type Link = Schemas["Link"];
14
+ export type InboundInfo = Schemas["InboundInfo"];
15
+ export type Problem = Schemas["Problem"];
16
+ /** A config v1 document. The generated schema calls it an object nobody can fill in, so the client takes any object and the node does the checking. */
17
+ export type ConfigDocument = Record<string, unknown>;
18
+ export type RunningConfig = Omit<Schemas["Running"], "document"> & {
19
+ document: ConfigDocument;
20
+ };
21
+ /** The data of each event on the stream, by the name of the event. */
22
+ export interface EventData {
23
+ config_applied: Schemas["ConfigApplied"];
24
+ config_refused: Schemas["ConfigRefused"];
25
+ user_cut_off: Schemas["UserCutOff"];
26
+ user_restored: Schemas["UserRestored"];
27
+ usage_reset: Schemas["UsageReset"];
28
+ exit_udp_changed: Schemas["ExitUdpChanged"];
29
+ stats: Schemas["Stats"];
30
+ lagged: Schemas["Lagged"];
31
+ }
32
+ /** One event of the stream. `id` is empty for `lagged`, which has none. */
33
+ export type NodeEvent = {
34
+ [K in keyof EventData]: {
35
+ id: string | undefined;
36
+ event: K;
37
+ data: EventData[K];
38
+ };
39
+ }[keyof EventData];
40
+ /** The part of a fetch response the client uses, so the fetch of undici, the global one and a test double all fit. */
41
+ export interface FetchResponse {
42
+ status: number;
43
+ headers: {
44
+ get(name: string): string | null;
45
+ };
46
+ text(): Promise<string>;
47
+ body: ReadableStream<Uint8Array> | null;
48
+ }
49
+ export type Fetch = (url: string, init: {
50
+ method: string;
51
+ headers: Record<string, string>;
52
+ body?: string;
53
+ signal?: AbortSignal;
54
+ }) => Promise<FetchResponse>;
55
+ /** Every answer that is not a success, and an answer the client could not read. */
56
+ export declare class VeilwayError extends Error {
57
+ readonly status: number;
58
+ readonly code: string;
59
+ readonly details: Problem[];
60
+ constructor(status: number, code: string, message: string, details?: Problem[]);
61
+ }
62
+ export declare class Client {
63
+ private readonly fetch;
64
+ private readonly base;
65
+ /** `base` is only the start of every URL, for example "http://veilway": over a unix socket the host is never looked up, the `fetch` decides where the bytes go. */
66
+ constructor(fetch: Fetch, base: string);
67
+ private send;
68
+ private json;
69
+ status(): Promise<Status>;
70
+ /** The running config with every secret replaced by "<redacted>", and the revision to give `applyConfig`. */
71
+ getConfig(): Promise<RunningConfig>;
72
+ /** Puts a document in force. `revision` is the one of the config the change was made on. A stale one throws a `stale_revision` error whose details hold the revision that runs. */
73
+ applyConfig(document: ConfigDocument, revision: string): Promise<Applied>;
74
+ validateConfig(document: ConfigDocument): Promise<Validation>;
75
+ diffConfig(document: ConfigDocument): Promise<Difference>;
76
+ listUsers(): Promise<User[]>;
77
+ getUser(name: string): Promise<User>;
78
+ /** Starts the user's quota count over, which lets a user who was out of quota connect again at once. With `totals` the user's `traffic` goes back to zero as well. The answer is the user as `getUser` gives them, and the same call again changes nothing more. */
79
+ resetUsage(name: string, options?: {
80
+ totals?: boolean;
81
+ }): Promise<User>;
82
+ /** Empties the replay memory of the obfuscated transports and sets its floor and limit to the node's clock plus 60 seconds. Every client is refused for those 60 seconds, and a proof that was accepted with a timestamp above the new floor can be used once more. A node that has run no obfuscated transport throws `replay_absent`. The answer is the replay memory after the reset. */
83
+ resetReplay(): Promise<Replay>;
84
+ stats(): Promise<Stats>;
85
+ listSessions(): Promise<Session[]>;
86
+ listExits(): Promise<Exit[]>;
87
+ /** Every inbound with its WireGuard public key, the public half of its node transport key, and the rating of each transport. No private key. */
88
+ listInbounds(): Promise<InboundInfo[]>;
89
+ /** Every enabled node link with its state and its counters. */
90
+ listLinks(): Promise<Link[]>;
91
+ /** The event stream. It ends when the node closes it or when `signal` aborts, and throws when the node refuses it or sends something that is not an event. Event names this client does not know are skipped. */
92
+ events(signal?: AbortSignal): AsyncGenerator<NodeEvent, void, undefined>;
93
+ }
@@ -0,0 +1,136 @@
1
+ /** The most one line of the event stream may hold before the client gives up on it. */
2
+ const MAX_LINE = 1 << 20;
3
+ const EVENTS = new Set(["config_applied", "config_refused", "user_cut_off", "user_restored", "usage_reset", "exit_udp_changed", "stats", "lagged"]);
4
+ /** Every answer that is not a success, and an answer the client could not read. */
5
+ export class VeilwayError extends Error {
6
+ status;
7
+ code;
8
+ details;
9
+ constructor(status, code, message, details = []) {
10
+ super(message);
11
+ this.status = status;
12
+ this.code = code;
13
+ this.details = details;
14
+ this.name = "VeilwayError";
15
+ }
16
+ }
17
+ export class Client {
18
+ fetch;
19
+ base;
20
+ /** `base` is only the start of every URL, for example "http://veilway": over a unix socket the host is never looked up, the `fetch` decides where the bytes go. */
21
+ constructor(fetch, base) {
22
+ this.fetch = fetch;
23
+ this.base = base;
24
+ }
25
+ async send(method, path, headers = {}, body, signal) {
26
+ const response = await this.fetch(this.base.replace(/\/+$/, "") + path, { method, headers: { accept: "application/json", ...headers }, body, signal });
27
+ if (response.status >= 200 && response.status < 300)
28
+ return response;
29
+ const text = await response.text().catch(() => "");
30
+ let failure;
31
+ try {
32
+ failure = JSON.parse(text).error;
33
+ }
34
+ catch { }
35
+ if (failure && typeof failure.code === "string" && typeof failure.message === "string")
36
+ throw new VeilwayError(response.status, failure.code, failure.message, Array.isArray(failure.details) ? failure.details : []);
37
+ throw new VeilwayError(response.status, "bad_response", "The node answered " + response.status + " with a body that is not an error document.");
38
+ }
39
+ async json(method, path, headers = {}, body) {
40
+ const response = await this.send(method, path, body ? { "content-type": "application/json", ...headers } : headers, body && JSON.stringify(body));
41
+ try {
42
+ return JSON.parse(await response.text());
43
+ }
44
+ catch {
45
+ throw new VeilwayError(response.status, "bad_response", "The node answered with a body that is not JSON.");
46
+ }
47
+ }
48
+ status() { return this.json("GET", "/v1/status"); }
49
+ /** The running config with every secret replaced by "<redacted>", and the revision to give `applyConfig`. */
50
+ getConfig() { return this.json("GET", "/v1/config"); }
51
+ /** Puts a document in force. `revision` is the one of the config the change was made on. A stale one throws a `stale_revision` error whose details hold the revision that runs. */
52
+ applyConfig(document, revision) { return this.json("PUT", "/v1/config", { "if-match": "\"" + revision.replace(/"/g, "") + "\"" }, document); }
53
+ validateConfig(document) { return this.json("POST", "/v1/config/validate", {}, document); }
54
+ diffConfig(document) { return this.json("POST", "/v1/config/diff", {}, document); }
55
+ async listUsers() { return (await this.json("GET", "/v1/users")).users; }
56
+ getUser(name) { return this.json("GET", "/v1/users/" + encodeURIComponent(name)); }
57
+ /** Starts the user's quota count over, which lets a user who was out of quota connect again at once. With `totals` the user's `traffic` goes back to zero as well. The answer is the user as `getUser` gives them, and the same call again changes nothing more. */
58
+ resetUsage(name, options = {}) { return this.json("POST", "/v1/users/" + encodeURIComponent(name) + "/usage/reset", {}, { totals: options.totals === true }); }
59
+ /** Empties the replay memory of the obfuscated transports and sets its floor and limit to the node's clock plus 60 seconds. Every client is refused for those 60 seconds, and a proof that was accepted with a timestamp above the new floor can be used once more. A node that has run no obfuscated transport throws `replay_absent`. The answer is the replay memory after the reset. */
60
+ resetReplay() { return this.json("POST", "/v1/replay/reset"); }
61
+ stats() { return this.json("GET", "/v1/stats"); }
62
+ async listSessions() { return (await this.json("GET", "/v1/sessions")).sessions; }
63
+ async listExits() { return (await this.json("GET", "/v1/exits")).exits; }
64
+ /** Every inbound with its WireGuard public key, the public half of its node transport key, and the rating of each transport. No private key. */
65
+ async listInbounds() { return (await this.json("GET", "/v1/inbounds")).inbounds; }
66
+ /** Every enabled node link with its state and its counters. */
67
+ async listLinks() { return (await this.json("GET", "/v1/links")).links; }
68
+ /** The event stream. It ends when the node closes it or when `signal` aborts, and throws when the node refuses it or sends something that is not an event. Event names this client does not know are skipped. */
69
+ async *events(signal) {
70
+ let reader;
71
+ try {
72
+ const response = await this.send("GET", "/v1/events", { accept: "text/event-stream" }, undefined, signal);
73
+ if (!response.body)
74
+ throw new VeilwayError(response.status, "bad_response", "The node answered the event stream without a body.");
75
+ reader = response.body.getReader();
76
+ const decoder = new TextDecoder();
77
+ let pending = "", id, event = "message", data = [], skipLf = false;
78
+ while (true) {
79
+ const { done, value } = await reader.read();
80
+ if (done)
81
+ return;
82
+ pending += decoder.decode(value, { stream: true });
83
+ if (skipLf && pending.startsWith("\n"))
84
+ pending = pending.slice(1);
85
+ skipLf = false;
86
+ let cut;
87
+ while ((cut = /\r\n|\n|\r/.exec(pending))) {
88
+ const line = pending.slice(0, cut.index);
89
+ // A CR at the very end may be the first half of a CRLF whose LF has not arrived yet.
90
+ if (cut[0] === "\r" && cut.index + 1 === pending.length)
91
+ skipLf = true;
92
+ pending = pending.slice(cut.index + cut[0].length);
93
+ if (line === "") {
94
+ const name = event, text = data.join("\n"), last = name === "lagged" ? undefined : id;
95
+ event = "message";
96
+ data = [];
97
+ if (!EVENTS.has(name))
98
+ continue;
99
+ let parsed;
100
+ try {
101
+ parsed = JSON.parse(text);
102
+ }
103
+ catch {
104
+ throw new VeilwayError(response.status, "bad_event", "An event of the stream holds no JSON.");
105
+ }
106
+ yield { id: last, event: name, data: parsed };
107
+ continue;
108
+ }
109
+ if (line.startsWith(":"))
110
+ continue;
111
+ const colon = line.indexOf(":");
112
+ const field = colon < 0 ? line : line.slice(0, colon);
113
+ let value = colon < 0 ? "" : line.slice(colon + 1);
114
+ if (value.startsWith(" "))
115
+ value = value.slice(1);
116
+ if (field === "id" && !value.includes("\0"))
117
+ id = value;
118
+ else if (field === "event")
119
+ event = value;
120
+ else if (field === "data")
121
+ data.push(value);
122
+ }
123
+ if (pending.length > MAX_LINE)
124
+ throw new VeilwayError(response.status, "bad_event", "A line of the event stream is longer than " + MAX_LINE + " bytes.");
125
+ }
126
+ }
127
+ catch (e) {
128
+ if (signal?.aborted)
129
+ return;
130
+ throw e;
131
+ }
132
+ finally {
133
+ await reader?.cancel().catch(() => { });
134
+ }
135
+ }
136
+ }