@netnodeag/kraftwerk 0.70.0 → 0.71.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.
Files changed (36) hide show
  1. package/README.md +39 -0
  2. package/dist/cli/daemon.js +6 -0
  3. package/dist/cli/doctor.js +3 -0
  4. package/dist/cli/kraftwerk.js +2 -0
  5. package/dist/cli/remote.d.ts +2 -0
  6. package/dist/cli/remote.js +89 -0
  7. package/dist/client/relay.d.ts +149 -0
  8. package/dist/client/relay.js +317 -0
  9. package/dist/client/routes.d.ts +2 -0
  10. package/dist/client/routes.js +2 -0
  11. package/dist/server/api/devices.d.ts +28 -2
  12. package/dist/server/api/devices.js +39 -5
  13. package/dist/server/api/index.d.ts +28 -2
  14. package/dist/server/relay.d.ts +75 -0
  15. package/dist/server/relay.js +301 -0
  16. package/package.json +7 -2
  17. package/web/dist/assets/{dist-DPXNFIR0.js → dist-4mt46bwa.js} +1 -1
  18. package/web/dist/assets/{dist-BeHtCIB0.js → dist-5bpaJOp6.js} +1 -1
  19. package/web/dist/assets/{dist-5v0UmV-F.js → dist-8As2tNSv.js} +1 -1
  20. package/web/dist/assets/{dist-B5BBuAK8.js → dist-BFetH-PC.js} +1 -1
  21. package/web/dist/assets/{dist-DGfHUaeU.js → dist-BcEOC-ps.js} +1 -1
  22. package/web/dist/assets/{dist-CxMl8Xeq.js → dist-BmsKU6ix.js} +1 -1
  23. package/web/dist/assets/{dist-DOUkQhjz.js → dist-CANEcq7F.js} +1 -1
  24. package/web/dist/assets/dist-CWrRm3RK.js +1 -0
  25. package/web/dist/assets/{dist-CkKJVLbX.js → dist-Coej_0FF.js} +1 -1
  26. package/web/dist/assets/{dist-D4wB4wkJ.js → dist-Du3myOTO.js} +1 -1
  27. package/web/dist/assets/{dist-B-eaMFbi.js → dist-FeMkWwZl.js} +1 -1
  28. package/web/dist/assets/{editor-oLm1tYCp.js → editor-B3am-j9U.js} +3 -3
  29. package/web/dist/assets/index-C8DF24q1.css +2 -0
  30. package/web/dist/assets/{index-V2VT7ZQP.js → index-CrBIiOwc.js} +26 -24
  31. package/web/dist/index.html +2 -2
  32. package/web/dist-remote/assets/index-2nAI1HTV.js +13 -0
  33. package/web/dist-remote/index.html +16 -0
  34. package/web/dist-remote/sw.js +1 -0
  35. package/web/dist/assets/dist-1EXSVa5k.js +0 -1
  36. package/web/dist/assets/index-CrD3grQE.css +0 -2
@@ -46,8 +46,10 @@ export declare const ROUTES: {
46
46
  readonly "devices.code": readonly ["POST", "/api/devices/code"];
47
47
  readonly "devices.list": readonly ["GET", "/api/devices"];
48
48
  readonly "devices.pair": readonly ["POST", "/api/pair"];
49
+ readonly "devices.relay": readonly ["GET", "/api/devices/relay"];
49
50
  readonly "devices.revoke": readonly ["DELETE", "/api/devices/:id"];
50
51
  readonly "devices.self": readonly ["GET", "/api/devices/self"];
52
+ readonly "devices.setRelay": readonly ["POST", "/api/devices/relay"];
51
53
  readonly "files.delete": readonly ["DELETE", "/api/files"];
52
54
  readonly "files.list": readonly ["GET", "/api/files/list"];
53
55
  readonly "files.mkdir": readonly ["POST", "/api/files/folder"];
@@ -47,8 +47,10 @@ export const ROUTES = {
47
47
  "devices.code": ["POST", "/api/devices/code"],
48
48
  "devices.list": ["GET", "/api/devices"],
49
49
  "devices.pair": ["POST", "/api/pair"],
50
+ "devices.relay": ["GET", "/api/devices/relay"],
50
51
  "devices.revoke": ["DELETE", "/api/devices/:id"],
51
52
  "devices.self": ["GET", "/api/devices/self"],
53
+ "devices.setRelay": ["POST", "/api/devices/relay"],
52
54
  "files.delete": ["DELETE", "/api/files"],
53
55
  "files.list": ["GET", "/api/files/list"],
54
56
  "files.mkdir": ["POST", "/api/files/folder"],
@@ -4,6 +4,7 @@ export declare const deviceRoutes: (import("./router.js").Route<"devices.code",
4
4
  code: string;
5
5
  expiresAt: string;
6
6
  urls: string[];
7
+ remote?: string | undefined;
7
8
  }, "/api/devices/code", undefined> | import("./router.js").Route<"devices.list", {
8
9
  devices: import("../../core/devices.js").Device[];
9
10
  }, "/api/devices", undefined> | import("./router.js").Route<"devices.pair", import("./router.js").Reply<{
@@ -12,9 +13,34 @@ export declare const deviceRoutes: (import("./router.js").Route<"devices.code",
12
13
  }>, "/api/pair", z.ZodObject<{
13
14
  code: z.ZodString;
14
15
  name: z.ZodDefault<z.ZodString>;
15
- }, z.core.$strip>> | import("./router.js").Route<"devices.revoke", {
16
+ }, z.core.$strip>> | import("./router.js").Route<"devices.relay", {
17
+ state: import("../relay.js").RelayState;
18
+ id?: string;
19
+ key?: string;
20
+ error?: string;
21
+ clients: number;
22
+ since?: string;
23
+ enabled: boolean;
24
+ url: string | null;
25
+ fingerprint: string | null;
26
+ daemon: boolean;
27
+ }, "/api/devices/relay", undefined> | import("./router.js").Route<"devices.revoke", {
16
28
  ok: boolean;
17
29
  }, "/api/devices/:id", undefined> | import("./router.js").Route<"devices.self", {
18
30
  trust: "device" | "local" | "none";
19
31
  device?: import("../../core/devices.js").Device | undefined;
20
- }, "/api/devices/self", undefined>)[];
32
+ }, "/api/devices/self", undefined> | import("./router.js").Route<"devices.setRelay", {
33
+ state: import("../relay.js").RelayState;
34
+ id?: string;
35
+ key?: string;
36
+ error?: string;
37
+ clients: number;
38
+ since?: string;
39
+ enabled: boolean;
40
+ url: string;
41
+ fingerprint: string;
42
+ daemon: boolean;
43
+ }, "/api/devices/relay", z.ZodObject<{
44
+ enabled: z.ZodBoolean;
45
+ url: z.ZodOptional<z.ZodString>;
46
+ }, z.core.$strip>>)[];
@@ -1,6 +1,9 @@
1
1
  import * as z from "zod";
2
2
  import { createPairCode, listDevices, redeemPairCode, revokeDevice } from "../../core/devices.js";
3
3
  import { networkUrls } from "../bind.js";
4
+ import { currentHub } from "../hub-context.js";
5
+ import { applyRelay, keyFingerprint, readRelaySettings, relayStatus, remoteLink, setRelay } from "../relay.js";
6
+ import { getPkgVersion } from "../version.js";
4
7
  import { DEVICE_COOKIE } from "../trust.js";
5
8
  import { ApiError, fail, reply, route } from "./router.js";
6
9
  /** Failed pairing attempts per peer, to make guessing a code pointless: a code is 8 characters from 31 and lives 10 minutes. */
@@ -46,10 +49,41 @@ export const deviceRoutes = [
46
49
  ...(c.trust.kind === "device" ? { device: c.trust.device } : {}),
47
50
  })),
48
51
  route({ name: "devices.list", method: "GET", path: "/api/devices", summary: "every paired device", access: "local" }, async () => ({ devices: await listDevices() })),
49
- // The code is shown once; the addresses are where a device on the network opens kraftwerk (none while bound to this machine only).
50
- route({ name: "devices.code", method: "POST", path: "/api/devices/code", summary: "a one-time pairing code, valid 10 minutes", access: "local" }, async (c) => ({
51
- ...(await createPairCode()),
52
- urls: networkUrls(c.req?.socket.localPort ?? 0),
53
- })),
52
+ // The code is shown once; the addresses are where a device on the network opens kraftwerk (none while bound to this machine only),
53
+ // and with the relay on, `remote` is the link that pairs a device from anywhere (code and machine key in its fragment).
54
+ route({ name: "devices.code", method: "POST", path: "/api/devices/code", summary: "a one-time pairing code, valid 10 minutes", access: "local" }, async (c) => {
55
+ const pair = await createPairCode();
56
+ const relay = await readRelaySettings();
57
+ return {
58
+ ...pair,
59
+ urls: networkUrls(c.req?.socket.localPort ?? 0),
60
+ ...(relay?.enabled ? { remote: remoteLink(relay.url, relay.publicKey, pair.code) } : {}),
61
+ };
62
+ }),
63
+ route({ name: "devices.relay", method: "GET", path: "/api/devices/relay", summary: "the relay: whether devices reach this machine from anywhere, and the link's state", access: "local" }, async () => {
64
+ const s = await readRelaySettings();
65
+ return {
66
+ enabled: s?.enabled === true,
67
+ url: s?.url ?? null,
68
+ fingerprint: s ? keyFingerprint(s.publicKey) : null,
69
+ daemon: currentHub().daemon === true,
70
+ ...relayStatus(),
71
+ };
72
+ }),
73
+ // Only the daemon holds the link; a standalone `kraftwerk ui` saves the setting for the daemon's next start.
74
+ route({
75
+ name: "devices.setRelay",
76
+ method: "POST",
77
+ path: "/api/devices/relay",
78
+ summary: "turn the relay on or off {enabled, url?}",
79
+ access: "local",
80
+ body: z.object({ enabled: z.boolean(), url: z.string().url().optional() }),
81
+ }, async (c) => {
82
+ const { enabled, url } = await c.body();
83
+ const s = await setRelay(enabled, url);
84
+ const hub = currentHub();
85
+ const status = hub.daemon ? await applyRelay(hub.port, await getPkgVersion(), (l) => console.log(l)) : relayStatus();
86
+ return { enabled: s.enabled, url: s.url, fingerprint: keyFingerprint(s.publicKey), daemon: hub.daemon === true, ...status };
87
+ }),
54
88
  route({ name: "devices.revoke", method: "DELETE", path: "/api/devices/:id", summary: "unpair a device: its token stops working at once", access: "local" }, async (c) => (await revokeDevice(c.params.id)) ? { ok: true } : fail(404, "no such device")),
55
89
  ];
@@ -288,6 +288,7 @@ export declare const routes: (Route<"agents.addJournal", {
288
288
  code: string;
289
289
  expiresAt: string;
290
290
  urls: string[];
291
+ remote?: string | undefined;
291
292
  }, "/api/devices/code", undefined> | Route<"devices.list", {
292
293
  devices: import("../../core/devices.js").Device[];
293
294
  }, "/api/devices", undefined> | Route<"devices.pair", import("./router.js").Reply<{
@@ -296,12 +297,37 @@ export declare const routes: (Route<"agents.addJournal", {
296
297
  }>, "/api/pair", z.ZodObject<{
297
298
  code: z.ZodString;
298
299
  name: z.ZodDefault<z.ZodString>;
299
- }, z.core.$strip>> | Route<"devices.revoke", {
300
+ }, z.core.$strip>> | Route<"devices.relay", {
301
+ state: import("../relay.js").RelayState;
302
+ id?: string;
303
+ key?: string;
304
+ error?: string;
305
+ clients: number;
306
+ since?: string;
307
+ enabled: boolean;
308
+ url: string | null;
309
+ fingerprint: string | null;
310
+ daemon: boolean;
311
+ }, "/api/devices/relay", undefined> | Route<"devices.revoke", {
300
312
  ok: boolean;
301
313
  }, "/api/devices/:id", undefined> | Route<"devices.self", {
302
314
  trust: "device" | "local" | "none";
303
315
  device?: import("../../core/devices.js").Device | undefined;
304
- }, "/api/devices/self", undefined> | Route<"files.delete", {
316
+ }, "/api/devices/self", undefined> | Route<"devices.setRelay", {
317
+ state: import("../relay.js").RelayState;
318
+ id?: string;
319
+ key?: string;
320
+ error?: string;
321
+ clients: number;
322
+ since?: string;
323
+ enabled: boolean;
324
+ url: string;
325
+ fingerprint: string;
326
+ daemon: boolean;
327
+ }, "/api/devices/relay", z.ZodObject<{
328
+ enabled: z.ZodBoolean;
329
+ url: z.ZodOptional<z.ZodString>;
330
+ }, z.core.$strip>> | Route<"files.delete", {
305
331
  ok: boolean;
306
332
  }, "/api/files", undefined> | Route<"files.list", import("../../core/files.js").FilesListing, "/api/files/list", undefined> | Route<"files.mkdir", import("./router.js").Reply<import("../../core/files.js").FileEntry>, "/api/files/folder", z.ZodObject<{
307
333
  scope: z.ZodOptional<z.ZodString>;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The daemon's end of the relay (see src/client/relay.ts for the protocol):
3
+ * an outgoing socket to the relay, one encrypted channel per client behind
4
+ * it, and every request that comes through answered by this daemon's own
5
+ * server — as a request from a network device, never as this machine. So a
6
+ * client needs a paired device's token (or pairs with a code) exactly as on
7
+ * the LAN.
8
+ *
9
+ * Machine settings live in ~/.kraftwerk/relay.json (owner-only): whether
10
+ * the relay is on, which relay, the machine's key pair, and the secret the
11
+ * relay knows it by (the first connection claims the machine's id; later
12
+ * ones must present the same secret, so nobody else can take the id over).
13
+ *
14
+ * The relay socket, daemon side:
15
+ *
16
+ * → {t:"auth", id, key, secret, version} first
17
+ * ← {t:"ok"} | close 4401
18
+ * ← {t:"open", c} · {t:"msg", c, d} · {t:"close", c}
19
+ * → {t:"msg", c, d} · {t:"close", c}
20
+ * → {t:"ping"} ← {t:"pong"} every 25 s; no pong in 60 s reconnects
21
+ */
22
+ export interface RelaySettings {
23
+ enabled: boolean;
24
+ /** The cloud whose relay to use; the client endpoint and the pairing page derive from it. */
25
+ url: string;
26
+ publicKey: string;
27
+ privateKey: JsonWebKey;
28
+ secret: string;
29
+ }
30
+ export type RelayState = "off" | "connecting" | "connected" | "error";
31
+ export interface RelayStatus {
32
+ state: RelayState;
33
+ url?: string;
34
+ /** The machine's id at the relay. */
35
+ id?: string;
36
+ /** The machine's public key (base64url) — what a pairing link carries. */
37
+ key?: string;
38
+ error?: string;
39
+ /** Clients connected right now. */
40
+ clients: number;
41
+ since?: string;
42
+ }
43
+ export declare function readRelaySettings(): Promise<RelaySettings | null>;
44
+ /** Turn the relay on or off (creating the machine's key on first use); `url` switches relays. */
45
+ export declare function setRelay(enabled: boolean, url?: string): Promise<RelaySettings>;
46
+ /** wss://host/api/relay/<role> for a cloud URL. */
47
+ export declare const relayEndpoint: (cloudUrl: string, role: "server" | "client") => string;
48
+ /**
49
+ * The pairing page on the cloud's remote host, with what the phone needs in
50
+ * the fragment (never sent to any server): the machine's key, and a pairing
51
+ * code when one is given. `remote.<host>` unless the cloud says otherwise.
52
+ */
53
+ export declare function remoteLink(cloudUrl: string, key: string, code?: string, remoteUrl?: string | undefined): string;
54
+ export interface RelayLink {
55
+ status(): RelayStatus;
56
+ stop(): void;
57
+ }
58
+ export interface RelayOptions {
59
+ /** This daemon's port: tunnelled requests go to 127.0.0.1:<port>. */
60
+ port: number;
61
+ settings: RelaySettings;
62
+ version: string;
63
+ /** Opens the relay socket (default: Node's global WebSocket) — tests pass their own. */
64
+ connect?: (url: string) => WebSocket;
65
+ log?: (line: string) => void;
66
+ }
67
+ /** Hold the daemon's socket to the relay, reconnecting with backoff, and answer every client behind it. */
68
+ export declare function startRelay(opts: RelayOptions): Promise<RelayLink>;
69
+ /** A short fingerprint of the machine key, to compare what a phone shows with what this machine shows. */
70
+ export declare const keyFingerprint: (key: string) => string;
71
+ /** Start, restart or stop this daemon's relay link to match ~/.kraftwerk/relay.json. */
72
+ export declare function applyRelay(port: number, version: string, log?: (line: string) => void): Promise<RelayStatus>;
73
+ /** The link's state; "off" when this process holds none (not the daemon, or the relay is off). */
74
+ export declare const relayStatus: () => RelayStatus;
75
+ export declare function stopRelay(): void;
@@ -0,0 +1,301 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { promises as fs } from "node:fs";
3
+ import http from "node:http";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { daemonHandshake, exportKeyPair, generateKeyPair, importKeyPair, relayId, toBase64, fromBase64, } from "../client/relay.js";
7
+ import { CLOUD_DEFAULT_URL } from "../config.js";
8
+ const settingsFile = () => path.join(os.homedir(), ".kraftwerk", "relay.json");
9
+ export async function readRelaySettings() {
10
+ try {
11
+ const s = JSON.parse(await fs.readFile(settingsFile(), "utf8"));
12
+ if (typeof s.publicKey !== "string" || !s.privateKey || typeof s.secret !== "string")
13
+ return null;
14
+ return { enabled: s.enabled === true, url: typeof s.url === "string" && s.url ? s.url : CLOUD_DEFAULT_URL, publicKey: s.publicKey, privateKey: s.privateKey, secret: s.secret };
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
20
+ async function writeRelaySettings(s) {
21
+ const file = settingsFile();
22
+ await fs.mkdir(path.dirname(file), { recursive: true });
23
+ const tmp = `${file}.${process.pid}.tmp`;
24
+ await fs.writeFile(tmp, JSON.stringify(s, null, 2), { mode: 0o600 });
25
+ await fs.rename(tmp, file);
26
+ }
27
+ /** Turn the relay on or off (creating the machine's key on first use); `url` switches relays. */
28
+ export async function setRelay(enabled, url) {
29
+ let s = await readRelaySettings();
30
+ if (!s) {
31
+ const pair = await exportKeyPair(await generateKeyPair());
32
+ s = { enabled, url: url || CLOUD_DEFAULT_URL, ...pair, secret: randomBytes(32).toString("base64url") };
33
+ }
34
+ s = { ...s, enabled, ...(url ? { url: url.replace(/\/+$/, "") } : {}) };
35
+ await writeRelaySettings(s);
36
+ return s;
37
+ }
38
+ /** wss://host/api/relay/<role> for a cloud URL. */
39
+ export const relayEndpoint = (cloudUrl, role) => `${cloudUrl.replace(/\/+$/, "").replace(/^http/, "ws")}/api/relay/${role}`;
40
+ /**
41
+ * The pairing page on the cloud's remote host, with what the phone needs in
42
+ * the fragment (never sent to any server): the machine's key, and a pairing
43
+ * code when one is given. `remote.<host>` unless the cloud says otherwise.
44
+ */
45
+ export function remoteLink(cloudUrl, key, code, remoteUrl = process.env.KRAFTWERK_REMOTE_URL) {
46
+ const base = remoteUrl?.replace(/\/+$/, "") || (() => {
47
+ const u = new URL(cloudUrl);
48
+ u.hostname = `remote.${u.hostname.replace(/^srv\./, "")}`;
49
+ return u.origin;
50
+ })();
51
+ const params = new URLSearchParams({ k: key, ...(code ? { c: code.replace(/[^A-Za-z0-9]/g, "") } : {}), n: os.hostname().replace(/\.local$/, "") });
52
+ return `${base}/_kw/#${params}`;
53
+ }
54
+ /** Headers a client may not choose: who it is, where it came from, how the hop works. */
55
+ const DROP_REQUEST = new Set([
56
+ "host", "connection", "keep-alive", "upgrade", "te", "trailer", "transfer-encoding", "proxy-authorization",
57
+ "cookie", "forwarded", "x-forwarded-for", "x-forwarded-host", "x-forwarded-proto", "content-length", "accept-encoding",
58
+ ]);
59
+ /** Headers the client's Response cannot use or must not see. */
60
+ const DROP_RESPONSE = new Set(["connection", "keep-alive", "transfer-encoding", "set-cookie", "content-length"]);
61
+ const CHUNK = 192 * 1024;
62
+ const PING_MS = 25_000;
63
+ const STALE_MS = 60_000;
64
+ /** Hold the daemon's socket to the relay, reconnecting with backoff, and answer every client behind it. */
65
+ export async function startRelay(opts) {
66
+ const keys = await importKeyPair(opts.settings);
67
+ const id = await relayId(keys.publicKey);
68
+ const url = relayEndpoint(opts.settings.url, "server");
69
+ const log = opts.log ?? (() => { });
70
+ const status = { state: "connecting", url: opts.settings.url, id, key: opts.settings.publicKey, clients: 0 };
71
+ const conns = new Map();
72
+ let ws = null;
73
+ let stopped = false;
74
+ let attempt = 0;
75
+ let retry = null;
76
+ let pinger = null;
77
+ let lastHeard = 0;
78
+ const send = (msg) => {
79
+ if (ws?.readyState === 1)
80
+ ws.send(JSON.stringify(msg));
81
+ };
82
+ const dropConn = (c) => {
83
+ const conn = conns.get(c);
84
+ if (!conn)
85
+ return;
86
+ for (const r of conn.inflight.values())
87
+ r.destroy();
88
+ conns.delete(c);
89
+ status.clients = conns.size;
90
+ };
91
+ const reply = async (c, conn, msg) => {
92
+ if (!conn.channel || !conns.has(c))
93
+ return;
94
+ send({ t: "msg", c, d: await conn.channel.seal(JSON.stringify(msg)) });
95
+ };
96
+ /** One tunnelled request, answered by this daemon's own server as a network device's request. */
97
+ const forward = (c, conn, req) => {
98
+ const headers = {};
99
+ for (const [k, v] of Object.entries(req.headers ?? {}))
100
+ if (!DROP_REQUEST.has(k.toLowerCase()))
101
+ headers[k.toLowerCase()] = String(v);
102
+ const clientHost = typeof req.headers?.host === "string" && /^[A-Za-z0-9.:[\]-]+$/.test(req.headers.host) ? req.headers.host : "relay.invalid";
103
+ // Marked as forwarded: trust.ts never takes a forwarded request for this machine, whatever its peer address.
104
+ Object.assign(headers, { host: `127.0.0.1:${opts.port}`, "x-forwarded-for": "relay", "x-forwarded-host": clientHost, "x-forwarded-proto": "https", "x-kraftwerk-relay": "1" });
105
+ const body = req.body ? Buffer.from(fromBase64(req.body)) : undefined;
106
+ if (body)
107
+ headers["content-length"] = String(body.length);
108
+ const path = typeof req.url === "string" && req.url.startsWith("/") ? req.url : "/";
109
+ const out = http.request({ host: "127.0.0.1", port: opts.port, method: req.method || "GET", path, headers }, (res) => {
110
+ const h = {};
111
+ for (const [k, v] of Object.entries(res.headers))
112
+ if (v !== undefined && !DROP_RESPONSE.has(k))
113
+ h[k] = Array.isArray(v) ? v.join(", ") : v;
114
+ let chain = reply(c, conn, { t: "res", id: req.id, status: res.statusCode ?? 502, headers: h });
115
+ res.on("data", (chunk) => {
116
+ for (let i = 0; i < chunk.length; i += CHUNK) {
117
+ const part = chunk.subarray(i, i + CHUNK);
118
+ chain = chain.then(() => reply(c, conn, { t: "data", id: req.id, chunk: toBase64(part) }));
119
+ }
120
+ });
121
+ res.on("end", () => {
122
+ conn.inflight.delete(req.id);
123
+ chain = chain.then(() => reply(c, conn, { t: "end", id: req.id }));
124
+ });
125
+ res.on("error", () => {
126
+ conn.inflight.delete(req.id);
127
+ chain = chain.then(() => reply(c, conn, { t: "fail", id: req.id, error: "the response broke off" }));
128
+ });
129
+ });
130
+ out.on("error", (err) => {
131
+ if (!conn.inflight.has(req.id))
132
+ return; // aborted by the client
133
+ conn.inflight.delete(req.id);
134
+ void reply(c, conn, { t: "fail", id: req.id, error: err.message });
135
+ });
136
+ conn.inflight.set(req.id, out);
137
+ out.end(body);
138
+ };
139
+ const onClientMessage = async (c, conn, d) => {
140
+ if (!conn.channel) {
141
+ try {
142
+ const { ready, channel } = await daemonHandshake(keys, JSON.parse(d));
143
+ conn.channel = channel;
144
+ send({ t: "msg", c, d: JSON.stringify(ready) });
145
+ }
146
+ catch (err) {
147
+ log(`relay: handshake refused — ${err.message}`);
148
+ send({ t: "close", c, reason: "handshake failed" });
149
+ dropConn(c);
150
+ }
151
+ return;
152
+ }
153
+ let msg;
154
+ try {
155
+ msg = JSON.parse(await conn.channel.open(d));
156
+ }
157
+ catch {
158
+ // A message that does not open is tampering or loss: this channel is done.
159
+ send({ t: "close", c, reason: "bad message" });
160
+ dropConn(c);
161
+ return;
162
+ }
163
+ if (msg.t === "req" && typeof msg.id === "number")
164
+ forward(c, conn, msg);
165
+ else if (msg.t === "abort") {
166
+ const r = conn.inflight.get(msg.id);
167
+ conn.inflight.delete(msg.id);
168
+ r?.destroy();
169
+ }
170
+ };
171
+ const schedule = () => {
172
+ if (stopped)
173
+ return;
174
+ const delay = Math.min(30_000, 1000 * 2 ** Math.min(attempt++, 5)) * (0.75 + Math.random() / 2);
175
+ retry = setTimeout(connect, delay);
176
+ retry.unref?.();
177
+ };
178
+ function connect() {
179
+ if (stopped)
180
+ return;
181
+ status.state = "connecting";
182
+ let socket;
183
+ try {
184
+ socket = opts.connect ? opts.connect(url) : new WebSocket(url);
185
+ }
186
+ catch (err) {
187
+ status.state = "error";
188
+ status.error = err.message;
189
+ return schedule();
190
+ }
191
+ ws = socket;
192
+ socket.addEventListener("open", () => {
193
+ lastHeard = Date.now();
194
+ socket.send(JSON.stringify({ t: "auth", id, key: opts.settings.publicKey, secret: opts.settings.secret, version: opts.version }));
195
+ });
196
+ socket.addEventListener("message", (ev) => {
197
+ lastHeard = Date.now();
198
+ let msg;
199
+ try {
200
+ msg = JSON.parse(String(ev.data));
201
+ }
202
+ catch {
203
+ return;
204
+ }
205
+ if (msg.t === "ok") {
206
+ attempt = 0;
207
+ status.state = "connected";
208
+ status.error = undefined;
209
+ status.since = new Date().toISOString();
210
+ log(`relay: connected to ${opts.settings.url} as ${id}`);
211
+ }
212
+ else if (msg.t === "open" && msg.c) {
213
+ dropConn(msg.c);
214
+ conns.set(msg.c, { channel: null, inflight: new Map(), queue: Promise.resolve() });
215
+ status.clients = conns.size;
216
+ }
217
+ else if (msg.t === "msg" && msg.c && typeof msg.d === "string") {
218
+ const conn = conns.get(msg.c);
219
+ if (!conn)
220
+ return;
221
+ const c = msg.c;
222
+ const d = msg.d;
223
+ conn.queue = conn.queue.then(() => onClientMessage(c, conn, d)).catch(() => { });
224
+ }
225
+ else if (msg.t === "close" && msg.c)
226
+ dropConn(msg.c);
227
+ });
228
+ socket.addEventListener("close", (ev) => {
229
+ if (ws !== socket)
230
+ return;
231
+ ws = null;
232
+ for (const c of [...conns.keys()])
233
+ dropConn(c);
234
+ if (stopped)
235
+ return;
236
+ status.state = "error";
237
+ status.error = ev.code === 4401 ? `the relay refused this machine: ${ev.reason || "wrong secret"}` : ev.reason || "the relay connection closed";
238
+ if (ev.code === 4401)
239
+ log(`relay: ${status.error}`);
240
+ schedule();
241
+ });
242
+ socket.addEventListener("error", () => { });
243
+ }
244
+ pinger = setInterval(() => {
245
+ if (!ws || ws.readyState !== 1)
246
+ return;
247
+ if (Date.now() - lastHeard > STALE_MS) {
248
+ // Silent for too long: the socket is dead without knowing it (a sleeping Mac, a lost route).
249
+ const dead = ws;
250
+ ws = null;
251
+ for (const c of [...conns.keys()])
252
+ dropConn(c);
253
+ try {
254
+ dead.close();
255
+ }
256
+ catch { }
257
+ status.state = "connecting";
258
+ return schedule();
259
+ }
260
+ send({ t: "ping" });
261
+ }, PING_MS);
262
+ pinger.unref?.();
263
+ connect();
264
+ return {
265
+ status: () => ({ ...status }),
266
+ stop() {
267
+ stopped = true;
268
+ if (retry)
269
+ clearTimeout(retry);
270
+ if (pinger)
271
+ clearInterval(pinger);
272
+ for (const c of [...conns.keys()])
273
+ dropConn(c);
274
+ try {
275
+ ws?.close(1000, "bye");
276
+ }
277
+ catch { }
278
+ ws = null;
279
+ status.state = "off";
280
+ },
281
+ };
282
+ }
283
+ /** A short fingerprint of the machine key, to compare what a phone shows with what this machine shows. */
284
+ export const keyFingerprint = (key) => createHash("sha256").update(key).digest("hex").slice(0, 8).replace(/(.{4})/, "$1-");
285
+ /* ---------- the daemon's one link ---------- */
286
+ let link = null;
287
+ /** Start, restart or stop this daemon's relay link to match ~/.kraftwerk/relay.json. */
288
+ export async function applyRelay(port, version, log) {
289
+ link?.stop();
290
+ link = null;
291
+ const settings = await readRelaySettings();
292
+ if (settings?.enabled)
293
+ link = await startRelay({ port, settings, version, log });
294
+ return relayStatus();
295
+ }
296
+ /** The link's state; "off" when this process holds none (not the daemon, or the relay is off). */
297
+ export const relayStatus = () => link?.status() ?? { state: "off", clients: 0 };
298
+ export function stopRelay() {
299
+ link?.stop();
300
+ link = null;
301
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@netnodeag/kraftwerk",
3
- "version": "0.70.0",
3
+ "version": "0.71.0",
4
4
  "description": "Open-source agentic workspace for teams — where people and AI agents get work done together: agents (Claude Code, Codex, Pi), skills, shared knowledge, repeatable workflows, and deterministic verification",
5
5
  "keywords": [
6
6
  "agents",
@@ -33,7 +33,8 @@
33
33
  "dist",
34
34
  "runner",
35
35
  "schema",
36
- "web/dist"
36
+ "web/dist",
37
+ "web/dist-remote"
37
38
  ],
38
39
  "exports": {
39
40
  ".": {
@@ -43,6 +44,10 @@
43
44
  "./client": {
44
45
  "types": "./dist/client/index.d.ts",
45
46
  "import": "./dist/client/index.js"
47
+ },
48
+ "./relay": {
49
+ "types": "./dist/client/relay.d.ts",
50
+ "import": "./dist/client/relay.js"
46
51
  }
47
52
  },
48
53
  "scripts": {