@netnodeag/kraftwerk 0.64.0 → 0.66.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/README.md +126 -138
- package/dist/acp.js +3 -1
- package/dist/cli/daemon.d.ts +14 -0
- package/dist/cli/daemon.js +60 -0
- package/dist/cli/devices.d.ts +14 -0
- package/dist/cli/devices.js +53 -0
- package/dist/cli/doctor-legacy.js +9 -1
- package/dist/cli/doctor.js +17 -30
- package/dist/cli/init.js +1 -6
- package/dist/cli/kraftwerk.js +8 -3
- package/dist/cli/ui.d.ts +9 -0
- package/dist/cli/ui.js +47 -23
- package/dist/cli/workspaces.js +5 -3
- package/dist/client/index.d.ts +17 -0
- package/dist/client/index.js +10 -2
- package/dist/client/live.d.ts +1 -2
- package/dist/client/live.js +4 -2
- package/dist/client/routes.d.ts +8 -0
- package/dist/client/routes.js +8 -0
- package/dist/config.d.ts +18 -55
- package/dist/config.js +39 -92
- package/dist/core/chat/context.js +1 -1
- package/dist/core/chat/pi.js +2 -1
- package/dist/core/chat/sessions.d.ts +1 -1
- package/dist/core/chat/sessions.js +1 -1
- package/dist/core/chat/state.d.ts +2 -0
- package/dist/core/chat/state.js +5 -0
- package/dist/core/daemon.d.ts +27 -0
- package/dist/core/daemon.js +61 -0
- package/dist/core/devices.d.ts +35 -0
- package/dist/core/devices.js +156 -0
- package/dist/core/env.d.ts +16 -0
- package/dist/core/env.js +45 -0
- package/dist/core/git.d.ts +2 -0
- package/dist/core/git.js +20 -4
- package/dist/core/instances.d.ts +32 -12
- package/dist/core/instances.js +136 -64
- package/dist/core/routines.d.ts +6 -0
- package/dist/core/routines.js +11 -0
- package/dist/core/runner.js +7 -3
- package/dist/core/vibeables.d.ts +2 -0
- package/dist/core/vibeables.js +17 -5
- package/dist/core/workspace.d.ts +13 -0
- package/dist/core/workspace.js +25 -0
- package/dist/dotenv.d.ts +2 -2
- package/dist/dotenv.js +2 -2
- package/dist/server/api/devices.d.ts +20 -0
- package/dist/server/api/devices.js +55 -0
- package/dist/server/api/hub.d.ts +25 -0
- package/dist/server/api/hub.js +32 -0
- package/dist/server/api/index.d.ts +42 -2
- package/dist/server/api/index.js +5 -1
- package/dist/server/api/router.d.ts +21 -6
- package/dist/server/api/router.js +37 -9
- package/dist/server/api/socket.d.ts +15 -1
- package/dist/server/api/socket.js +59 -8
- package/dist/server/api/workspace.d.ts +2 -0
- package/dist/server/api/workspace.js +4 -2
- package/dist/server/bind.d.ts +16 -0
- package/dist/server/bind.js +25 -0
- package/dist/server/cloud.d.ts +5 -1
- package/dist/server/cloud.js +89 -56
- package/dist/server/hub-context.d.ts +7 -0
- package/dist/server/hub-context.js +18 -0
- package/dist/server/live.d.ts +2 -0
- package/dist/server/live.js +9 -0
- package/dist/server/preview.js +2 -1
- package/dist/server/server.d.ts +58 -1
- package/dist/server/server.js +267 -103
- package/dist/server/target.d.ts +20 -0
- package/dist/server/target.js +18 -0
- package/dist/server/trust.d.ts +41 -0
- package/dist/server/trust.js +42 -0
- package/package.json +1 -1
- package/schema/kraftwerk.schema.json +7 -46
- package/web/dist/assets/{dist-Cgcwvf4j.js → dist-1bNkaWWA.js} +1 -1
- package/web/dist/assets/dist-BU1SOB5S.js +1 -0
- package/web/dist/assets/{dist-CboP0-T1.js → dist-Bk3f-J5U.js} +1 -1
- package/web/dist/assets/{dist-2kswkIH2.js → dist-Bx99Puvv.js} +1 -1
- package/web/dist/assets/{dist-D2PvEzo7.js → dist-C0M2aITo.js} +1 -1
- package/web/dist/assets/{dist-D_BcTzz-.js → dist-C0z19Zge.js} +1 -1
- package/web/dist/assets/{dist-36MjITWa.js → dist-COEgBKG6.js} +1 -1
- package/web/dist/assets/{dist-Jdx6DRrm.js → dist-CYRrdlae.js} +1 -1
- package/web/dist/assets/{dist-DDkd_zrP.js → dist-CbEFnSCD.js} +1 -1
- package/web/dist/assets/{dist-BsuA2pgO.js → dist-DCYVKU-H.js} +1 -1
- package/web/dist/assets/{dist-B8gPjMPP.js → dist-XyTHWtoN.js} +1 -1
- package/web/dist/assets/{editor-ByRUOfrX.js → editor-DPcGvSPy.js} +3 -3
- package/web/dist/assets/index-BAYs-2WC.css +2 -0
- package/web/dist/assets/index-DKLJdwYE.js +229 -0
- package/web/dist/index.html +2 -2
- package/dist/cli/tunnel.d.ts +0 -10
- package/dist/cli/tunnel.js +0 -280
- package/dist/server/access.d.ts +0 -32
- package/dist/server/access.js +0 -121
- package/web/dist/assets/dist-CVqmJrYd.js +0 -1
- package/web/dist/assets/index-BR7HvDzD.js +0 -229
- package/web/dist/assets/index-Bra74Nxe.css +0 -2
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { subscribeChat } from "../../core/chat/sessions.js";
|
|
2
2
|
import { watch } from "../live.js";
|
|
3
3
|
import { resolveVibeable, subscribeVibeable } from "../../core/vibeables.js";
|
|
4
|
+
import { MACHINE_ROUTES, runInHub } from "../hub-context.js";
|
|
4
5
|
import { apiRouter, PROTOCOL_VERSION, routes } from "./index.js";
|
|
5
6
|
/**
|
|
6
7
|
* The control plane over one WebSocket (`/api/ws`): every JSON route by
|
|
@@ -23,7 +24,22 @@ import { apiRouter, PROTOCOL_VERSION, routes } from "./index.js";
|
|
|
23
24
|
*/
|
|
24
25
|
const DEFAULT_INTERVAL = 5000;
|
|
25
26
|
const GET_ROUTES = new Set(routes.filter((r) => r.method === "GET" && !r.raw).map((r) => r.name));
|
|
26
|
-
|
|
27
|
+
/** A device's standing is re-checked this often while its socket is open (and before each of its messages). */
|
|
28
|
+
const RECHECK_MS = 5_000;
|
|
29
|
+
/**
|
|
30
|
+
* `recheck` re-derives the connection's trust (a device's token may have
|
|
31
|
+
* been revoked since it connected): a device whose answer is no longer
|
|
32
|
+
* `device` is disconnected at once (close code 4401).
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* `workspaceOf` finds the workspace a message is for: its `ws` (a slug), or
|
|
36
|
+
* the connection's own (the one the socket was opened for) when it names
|
|
37
|
+
* none. A daemon's socket opened at its root has no workspace of its own:
|
|
38
|
+
* there, a message without `ws` reaches only the machine's routes (hub,
|
|
39
|
+
* devices, protocol).
|
|
40
|
+
*/
|
|
41
|
+
export function serveSocket(conn, workspaceOf, version, trust, recheck, hub) {
|
|
42
|
+
const trustKey = trust.kind === "device" ? `device:${trust.device.id}` : trust.kind;
|
|
27
43
|
const stops = new Map();
|
|
28
44
|
const send = (msg) => conn.send(JSON.stringify(msg));
|
|
29
45
|
const stop = (key) => {
|
|
@@ -64,9 +80,11 @@ export function serveSocket(conn, workspace, version) {
|
|
|
64
80
|
return unsubscribe();
|
|
65
81
|
stops.set(key, unsubscribe);
|
|
66
82
|
}
|
|
83
|
+
/** A refusal answered the way the message expects one (its id, watch or sub key). */
|
|
84
|
+
const refuse = (msg, status, error) => send({ id: msg.id, ...(typeof msg.watch === "string" ? { watch: msg.watch } : {}), ...(typeof msg.sub === "string" ? { sub: msg.sub } : {}), status, data: { error }, error });
|
|
67
85
|
async function handle(msg) {
|
|
68
86
|
if (typeof msg.call === "string") {
|
|
69
|
-
const r = await apiRouter.invoke(msg.call, objectOf(msg.input));
|
|
87
|
+
const r = await apiRouter.invoke(msg.call, objectOf(msg.input), trust);
|
|
70
88
|
return send({ id: msg.id, status: r.status, data: r.data });
|
|
71
89
|
}
|
|
72
90
|
if (typeof msg.watch === "string") {
|
|
@@ -74,11 +92,16 @@ export function serveSocket(conn, workspace, version) {
|
|
|
74
92
|
const name = String(msg.name ?? "");
|
|
75
93
|
if (!GET_ROUTES.has(name))
|
|
76
94
|
return send({ watch: key, error: `${name} cannot be watched` });
|
|
95
|
+
const refused = apiRouter.allowed(name, trust);
|
|
96
|
+
if (refused)
|
|
97
|
+
return send({ watch: key, error: refused.error });
|
|
77
98
|
const input = objectOf(msg.input);
|
|
78
99
|
const interval = typeof msg.interval === "number" && msg.interval > 0 ? msg.interval : DEFAULT_INTERVAL;
|
|
79
100
|
stop(key);
|
|
80
|
-
stops.set(key, watch(
|
|
81
|
-
|
|
101
|
+
stops.set(key, watch(
|
|
102
|
+
// Shared by callers of the same standing only: a result may depend on who asks (devices.self).
|
|
103
|
+
`${trustKey} ${name} ${JSON.stringify(input)}`, async () => {
|
|
104
|
+
const r = await apiRouter.invoke(name, input, trust);
|
|
82
105
|
return { ok: r.status < 300, data: r.data };
|
|
83
106
|
}, interval, (data) => send({ watch: key, data })));
|
|
84
107
|
return;
|
|
@@ -95,6 +118,18 @@ export function serveSocket(conn, workspace, version) {
|
|
|
95
118
|
return stop(msg.unsub);
|
|
96
119
|
send({ id: msg.id, error: "unknown message" });
|
|
97
120
|
}
|
|
121
|
+
/** Still the device that connected? Otherwise unpaired: drop the connection. */
|
|
122
|
+
const stillPaired = async () => {
|
|
123
|
+
if (trust.kind !== "device")
|
|
124
|
+
return true;
|
|
125
|
+
const now = await recheck().catch(() => ({ kind: "none" }));
|
|
126
|
+
if (now.kind === "device" && now.device.id === trust.device.id)
|
|
127
|
+
return true;
|
|
128
|
+
conn.close(4401, "unpaired");
|
|
129
|
+
return false;
|
|
130
|
+
};
|
|
131
|
+
const watchdog = trust.kind === "device" ? setInterval(() => void stillPaired(), RECHECK_MS) : undefined;
|
|
132
|
+
watchdog?.unref?.();
|
|
98
133
|
conn.onMessage((text) => {
|
|
99
134
|
let msg;
|
|
100
135
|
try {
|
|
@@ -103,12 +138,28 @@ export function serveSocket(conn, workspace, version) {
|
|
|
103
138
|
catch {
|
|
104
139
|
return send({ error: "invalid JSON" });
|
|
105
140
|
}
|
|
106
|
-
// Every message runs in the connection's workspace, whatever started the read.
|
|
107
|
-
|
|
141
|
+
// Every message runs in the connection's workspace, whatever started the read; a revoked device's message runs nowhere.
|
|
142
|
+
const slug = typeof msg.ws === "string" ? msg.ws : undefined;
|
|
143
|
+
const ws = workspaceOf(slug);
|
|
144
|
+
if (msg.ws !== undefined && !ws)
|
|
145
|
+
return refuse(msg, 404, `no workspace "${String(msg.ws)}" here`);
|
|
146
|
+
if (!ws && !(typeof msg.call === "string" && MACHINE_ROUTES.test(msg.call)))
|
|
147
|
+
return refuse(msg, 404, "name a workspace: `ws` on the message");
|
|
148
|
+
const run = (fn) => runInHub(hub, () => (ws ? ws.run(fn) : fn()));
|
|
149
|
+
void run(async () => {
|
|
150
|
+
if (await stillPaired())
|
|
151
|
+
await handle(msg);
|
|
152
|
+
}).catch((err) => send({ id: msg.id, error: err.message }));
|
|
108
153
|
});
|
|
109
154
|
conn.onClose(() => {
|
|
110
|
-
|
|
111
|
-
|
|
155
|
+
clearInterval(watchdog);
|
|
156
|
+
// A subscription of a workspace that closed meanwhile has nothing left to let go of.
|
|
157
|
+
for (const fn of stops.values()) {
|
|
158
|
+
try {
|
|
159
|
+
fn();
|
|
160
|
+
}
|
|
161
|
+
catch { }
|
|
162
|
+
}
|
|
112
163
|
stops.clear();
|
|
113
164
|
});
|
|
114
165
|
send({ hello: { protocol: PROTOCOL_VERSION, version } });
|
|
@@ -2,6 +2,7 @@ import * as z from "zod";
|
|
|
2
2
|
/** The workspace itself: identity and features, settings, the other workspaces, updates. */
|
|
3
3
|
export declare const workspaceRoutes: (import("./router.js").Route<"meta.get", {
|
|
4
4
|
workspaceName: string;
|
|
5
|
+
workspaceSlug: string;
|
|
5
6
|
workspaceIcon: string;
|
|
6
7
|
workspaceColor: string;
|
|
7
8
|
workspaceNamed: boolean;
|
|
@@ -21,6 +22,7 @@ export declare const workspaceRoutes: (import("./router.js").Route<"meta.get", {
|
|
|
21
22
|
repos: boolean;
|
|
22
23
|
vibeables: boolean;
|
|
23
24
|
projects: boolean;
|
|
25
|
+
/** @deprecated Always "": the Cloudflare Tunnel that set it was removed in 0.65. Removed with protocol 2. */
|
|
24
26
|
publicUrl: string;
|
|
25
27
|
cloud: import("../cloud.js").CloudStatus;
|
|
26
28
|
switcher: import("../../config.js").SwitcherEntry[];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import * as z from "zod";
|
|
3
|
-
import {
|
|
3
|
+
import { resolveWorkspace, slugFromFolder, workspaceSlug } from "../../config.js";
|
|
4
4
|
import { disposeAllBackends } from "../../core/chat/sessions.js";
|
|
5
5
|
import { cloudStatus } from "../cloud.js";
|
|
6
6
|
import { workspaceRoot } from "../../core/context.js";
|
|
@@ -56,6 +56,7 @@ export const workspaceRoutes = [
|
|
|
56
56
|
];
|
|
57
57
|
const identity = {
|
|
58
58
|
workspaceName: name,
|
|
59
|
+
workspaceSlug: ws ? workspaceSlug(ws) : slugFromFolder(path.basename(root)),
|
|
59
60
|
workspaceIcon: ws?.config.icon ?? "",
|
|
60
61
|
workspaceColor: ws?.config.color ?? "",
|
|
61
62
|
workspaceNamed: !!ws?.config.name,
|
|
@@ -80,7 +81,8 @@ export const workspaceRoutes = [
|
|
|
80
81
|
repos: (ws?.config.repos && ws.config.repos.enabled !== false) === true,
|
|
81
82
|
vibeables: (ws?.config.vibeables && ws.config.vibeables.enabled !== false) === true,
|
|
82
83
|
projects: (ws?.config.projects && ws.config.projects.enabled !== false) === true,
|
|
83
|
-
|
|
84
|
+
/** @deprecated Always "": the Cloudflare Tunnel that set it was removed in 0.65. Removed with protocol 2. */
|
|
85
|
+
publicUrl: "",
|
|
84
86
|
cloud: cloudStatus(),
|
|
85
87
|
switcher,
|
|
86
88
|
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interface to bind. Loopback by default: the UI's chat runs coding agents
|
|
3
|
+
* against the repo, so it does not appear on the LAN because someone started
|
|
4
|
+
* it on a laptop in a café. Bound to the network (`kraftwerk ui --lan`, or
|
|
5
|
+
* KRAFTWERK_UI_HOST), only paired devices get in (see trust.ts). Inside a container the
|
|
6
|
+
* default flips to all interfaces — loopback there would make the published
|
|
7
|
+
* port unreachable while the in-container health check keeps passing, and
|
|
8
|
+
* the port mapping (compose publishes to 127.0.0.1) is the boundary anyway.
|
|
9
|
+
* KRAFTWERK_UI_HOST overrides either way.
|
|
10
|
+
*/
|
|
11
|
+
export declare const IN_CONTAINER: boolean;
|
|
12
|
+
export declare const INSPECTOR_HOST: string;
|
|
13
|
+
export declare const LOOPBACK_NAMES: Set<string>;
|
|
14
|
+
export declare const LOOPBACK_BIND: boolean;
|
|
15
|
+
/** Where a device on the network reaches this server: one http URL per non-internal IPv4 address. Empty while bound to loopback. */
|
|
16
|
+
export declare function networkUrls(port: number): string[];
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
/**
|
|
4
|
+
* Interface to bind. Loopback by default: the UI's chat runs coding agents
|
|
5
|
+
* against the repo, so it does not appear on the LAN because someone started
|
|
6
|
+
* it on a laptop in a café. Bound to the network (`kraftwerk ui --lan`, or
|
|
7
|
+
* KRAFTWERK_UI_HOST), only paired devices get in (see trust.ts). Inside a container the
|
|
8
|
+
* default flips to all interfaces — loopback there would make the published
|
|
9
|
+
* port unreachable while the in-container health check keeps passing, and
|
|
10
|
+
* the port mapping (compose publishes to 127.0.0.1) is the boundary anyway.
|
|
11
|
+
* KRAFTWERK_UI_HOST overrides either way.
|
|
12
|
+
*/
|
|
13
|
+
export const IN_CONTAINER = existsSync("/.dockerenv") || existsSync("/run/.containerenv");
|
|
14
|
+
export const INSPECTOR_HOST = process.env.KRAFTWERK_UI_HOST || (IN_CONTAINER ? "0.0.0.0" : "127.0.0.1");
|
|
15
|
+
export const LOOPBACK_NAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
|
|
16
|
+
export const LOOPBACK_BIND = LOOPBACK_NAMES.has(INSPECTOR_HOST);
|
|
17
|
+
/** Where a device on the network reaches this server: one http URL per non-internal IPv4 address. Empty while bound to loopback. */
|
|
18
|
+
export function networkUrls(port) {
|
|
19
|
+
if (LOOPBACK_BIND)
|
|
20
|
+
return [];
|
|
21
|
+
return Object.values(os.networkInterfaces())
|
|
22
|
+
.flat()
|
|
23
|
+
.filter((a) => !!a && a.family === "IPv4" && !a.internal)
|
|
24
|
+
.map((a) => `http://${a.address}:${port}`);
|
|
25
|
+
}
|
package/dist/server/cloud.d.ts
CHANGED
|
@@ -38,7 +38,7 @@ export declare const cloudStatus: () => CloudStatus;
|
|
|
38
38
|
* registration attempt (success or not); the heartbeat loop runs on its
|
|
39
39
|
* own afterwards.
|
|
40
40
|
*/
|
|
41
|
-
export declare function startCloudSync(
|
|
41
|
+
export declare function startCloudSync(url: string, version: string): Promise<void>;
|
|
42
42
|
/** Stop the heartbeat loop (server close, tests). */
|
|
43
43
|
export declare function stopCloudSync(): void;
|
|
44
44
|
/**
|
|
@@ -47,3 +47,7 @@ export declare function stopCloudSync(): void;
|
|
|
47
47
|
* this, and a slow cloud must not hold the process.
|
|
48
48
|
*/
|
|
49
49
|
export declare function cloudGoodbye(): Promise<void>;
|
|
50
|
+
/** Every served workspace: stop its heartbeat (the server closes). */
|
|
51
|
+
export declare function stopAllCloudSync(): void;
|
|
52
|
+
/** Every served workspace says goodbye, in parallel (still bounded per workspace). */
|
|
53
|
+
export declare function cloudGoodbyeAll(): Promise<void>;
|
package/dist/server/cloud.js
CHANGED
|
@@ -2,23 +2,28 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { promises as fs } from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { cloudFor,
|
|
5
|
+
import { cloudFor, resolveWorkspace } from "../config.js";
|
|
6
6
|
import { workspaceRoot } from "../core/context.js";
|
|
7
|
+
import { perWorkspace, workspaceOpen } from "../core/workspace.js";
|
|
7
8
|
import { listAgents, toSummary } from "../core/agents.js";
|
|
8
9
|
import { listChannels, toChannelSummary } from "../core/channels.js";
|
|
9
10
|
import { tildify } from "../core/instances.js";
|
|
10
11
|
const claimUrlFor = (url, code) => (code ? `${url}/claim/${encodeURIComponent(code)}` : null);
|
|
11
12
|
const REQUEST_TIMEOUT_MS = 8_000;
|
|
12
13
|
const GOODBYE_TIMEOUT_MS = 800;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
14
|
+
/** The client's state, one per workspace: each served workspace registers and heartbeats on its own. */
|
|
15
|
+
const client = perWorkspace(() => ({
|
|
16
|
+
status: { url: "", state: "off" },
|
|
17
|
+
identity: null,
|
|
18
|
+
timer: null,
|
|
19
|
+
stopped: false,
|
|
20
|
+
lastRoster: "",
|
|
21
|
+
/** Where this workspace is reached on this machine, as the cloud lists it. */
|
|
22
|
+
selfUrl: "",
|
|
23
|
+
selfVersion: "",
|
|
24
|
+
startedAt: new Date().toISOString(),
|
|
25
|
+
}));
|
|
26
|
+
export const cloudStatus = () => client().status;
|
|
22
27
|
const cloudDir = () => path.join(os.homedir(), ".kraftwerk", "cloud");
|
|
23
28
|
const identityFile = (url, root) => path.join(cloudDir(), `${createHash("sha1").update(`${url}\n${root}`).digest("hex").slice(0, 16)}.json`);
|
|
24
29
|
async function readIdentity(file) {
|
|
@@ -46,9 +51,10 @@ async function roster() {
|
|
|
46
51
|
return { agents, channels };
|
|
47
52
|
}
|
|
48
53
|
async function post(url, body, timeout = REQUEST_TIMEOUT_MS) {
|
|
54
|
+
const c = client();
|
|
49
55
|
const r = await fetch(url, {
|
|
50
56
|
method: "POST",
|
|
51
|
-
headers: { "content-type": "application/json", "user-agent": `kraftwerk/${selfVersion}` },
|
|
57
|
+
headers: { "content-type": "application/json", "user-agent": `kraftwerk/${c.selfVersion}` },
|
|
52
58
|
body: JSON.stringify(body),
|
|
53
59
|
signal: AbortSignal.timeout(timeout),
|
|
54
60
|
});
|
|
@@ -66,16 +72,17 @@ async function post(url, body, timeout = REQUEST_TIMEOUT_MS) {
|
|
|
66
72
|
}
|
|
67
73
|
/** Register (or re-register) this instance. Returns false when the cloud refused or is unreachable. */
|
|
68
74
|
async function register() {
|
|
75
|
+
const c = client();
|
|
69
76
|
const project = await resolveWorkspace(workspaceRoot()).catch(() => null);
|
|
70
77
|
const cloud = project ? cloudFor(project) : undefined;
|
|
71
78
|
if (!project || !cloud)
|
|
72
79
|
return false;
|
|
73
80
|
const file = identityFile(cloud.url, project.root);
|
|
74
|
-
identity ??= await readIdentity(file);
|
|
81
|
+
c.identity ??= await readIdentity(file);
|
|
75
82
|
const { agents, channels } = await roster();
|
|
76
|
-
lastRoster = JSON.stringify({ agents, channels });
|
|
83
|
+
c.lastRoster = JSON.stringify({ agents, channels });
|
|
77
84
|
const body = {
|
|
78
|
-
...(identity ?? {}),
|
|
85
|
+
...(c.identity ?? {}),
|
|
79
86
|
name: project.config.name ?? path.basename(project.root),
|
|
80
87
|
icon: project.config.icon ?? "",
|
|
81
88
|
color: project.config.color ?? "",
|
|
@@ -83,105 +90,121 @@ async function register() {
|
|
|
83
90
|
rootLabel: tildify(project.root),
|
|
84
91
|
hostname: os.hostname(),
|
|
85
92
|
platform: `${process.platform}-${process.arch}`,
|
|
86
|
-
version: selfVersion,
|
|
87
|
-
url:
|
|
88
|
-
|
|
93
|
+
version: c.selfVersion,
|
|
94
|
+
url: c.selfUrl,
|
|
95
|
+
// Part of the cloud's register contract; always "" since the Cloudflare Tunnel was removed (0.65).
|
|
96
|
+
publicUrl: "",
|
|
89
97
|
agents,
|
|
90
98
|
channels,
|
|
91
99
|
interval: cloud.interval,
|
|
92
|
-
startedAt,
|
|
100
|
+
startedAt: c.startedAt,
|
|
93
101
|
...(cloud.token ? { accountToken: cloud.token } : {}),
|
|
94
102
|
};
|
|
95
103
|
try {
|
|
96
104
|
const r = await post(`${cloud.url}/api/instances/register`, body);
|
|
97
|
-
if (!identity || identity.id !== r.id || identity.secret !== r.secret) {
|
|
98
|
-
identity = { id: r.id, secret: r.secret };
|
|
99
|
-
await writeIdentity(file, cloud.url, project.root, identity);
|
|
105
|
+
if (!c.identity || c.identity.id !== r.id || c.identity.secret !== r.secret) {
|
|
106
|
+
c.identity = { id: r.id, secret: r.secret };
|
|
107
|
+
await writeIdentity(file, cloud.url, project.root, c.identity);
|
|
100
108
|
}
|
|
101
|
-
status = {
|
|
109
|
+
c.status = {
|
|
102
110
|
url: cloud.url, state: "connected", id: r.id, account: r.account, interval: r.interval, lastSeen: new Date().toISOString(),
|
|
103
111
|
claimCode: r.claimCode ?? null, claimUrl: claimUrlFor(cloud.url, r.claimCode),
|
|
104
112
|
};
|
|
105
113
|
return true;
|
|
106
114
|
}
|
|
107
115
|
catch (err) {
|
|
108
|
-
status = { url: cloud.url, state: "error", error: err.message, ...(identity ? { id: identity.id } : {}) };
|
|
116
|
+
c.status = { url: cloud.url, state: "error", error: err.message, ...(c.identity ? { id: c.identity.id } : {}) };
|
|
109
117
|
return false;
|
|
110
118
|
}
|
|
111
119
|
}
|
|
112
120
|
async function heartbeat() {
|
|
113
|
-
|
|
121
|
+
const c = client();
|
|
122
|
+
if (!c.identity || c.status.state === "off")
|
|
114
123
|
return;
|
|
115
124
|
const { agents, channels } = await roster();
|
|
116
125
|
const rosterNow = JSON.stringify({ agents, channels });
|
|
117
126
|
const body = {
|
|
118
|
-
...identity,
|
|
119
|
-
version: selfVersion,
|
|
120
|
-
...(rosterNow !== lastRoster ? { agents, channels } : {}),
|
|
127
|
+
...c.identity,
|
|
128
|
+
version: c.selfVersion,
|
|
129
|
+
...(rosterNow !== c.lastRoster ? { agents, channels } : {}),
|
|
121
130
|
};
|
|
122
131
|
try {
|
|
123
|
-
const r = await post(`${status.url}/api/instances/heartbeat`, body);
|
|
124
|
-
lastRoster = rosterNow;
|
|
125
|
-
status = {
|
|
126
|
-
...status, state: "connected", error: undefined, interval: r.interval, lastSeen: new Date().toISOString(),
|
|
132
|
+
const r = await post(`${c.status.url}/api/instances/heartbeat`, body);
|
|
133
|
+
c.lastRoster = rosterNow;
|
|
134
|
+
c.status = {
|
|
135
|
+
...c.status, state: "connected", error: undefined, interval: r.interval, lastSeen: new Date().toISOString(),
|
|
127
136
|
...(r.account !== undefined ? { account: r.account } : {}),
|
|
128
|
-
...(r.claimCode !== undefined ? { claimCode: r.claimCode, claimUrl: claimUrlFor(status.url, r.claimCode) } : {}),
|
|
137
|
+
...(r.claimCode !== undefined ? { claimCode: r.claimCode, claimUrl: claimUrlFor(c.status.url, r.claimCode) } : {}),
|
|
129
138
|
};
|
|
130
139
|
}
|
|
131
140
|
catch (err) {
|
|
132
141
|
const message = err.message;
|
|
133
|
-
status = { ...status, state: "error", error: message };
|
|
142
|
+
c.status = { ...c.status, state: "error", error: message };
|
|
134
143
|
// An unknown identity (the cloud lost it) is fixed by registering again, which hands out a new one.
|
|
135
144
|
if (/unknown instance|wrong instance secret|id and secret required/.test(message)) {
|
|
136
|
-
identity = null;
|
|
137
|
-
|
|
145
|
+
c.identity = null;
|
|
146
|
+
// Not after a goodbye: a stopped client registers nothing again.
|
|
147
|
+
if (!c.stopped && workspaceOpen())
|
|
148
|
+
await register();
|
|
138
149
|
}
|
|
139
150
|
}
|
|
140
151
|
}
|
|
141
152
|
/** Schedule the next tick after the effective interval; a failed cloud is retried at the same cadence. */
|
|
142
153
|
function schedule() {
|
|
143
|
-
|
|
154
|
+
const c = client();
|
|
155
|
+
if (c.stopped)
|
|
144
156
|
return;
|
|
145
|
-
const seconds = status.interval && status.interval > 0 ? status.interval : 60;
|
|
146
|
-
timer = setTimeout(async () => {
|
|
147
|
-
|
|
148
|
-
|
|
157
|
+
const seconds = c.status.interval && c.status.interval > 0 ? c.status.interval : 60;
|
|
158
|
+
c.timer = setTimeout(async () => {
|
|
159
|
+
// Stopped (or the workspace closed) while this timer waited, or while the call below ran: no next round.
|
|
160
|
+
if (c.stopped || !workspaceOpen())
|
|
161
|
+
return;
|
|
162
|
+
if (c.status.state === "connected" || c.status.state === "error") {
|
|
163
|
+
if (c.identity)
|
|
149
164
|
await heartbeat();
|
|
150
165
|
else
|
|
151
166
|
await register();
|
|
152
167
|
}
|
|
153
|
-
|
|
168
|
+
if (!c.stopped && workspaceOpen())
|
|
169
|
+
schedule();
|
|
154
170
|
}, seconds * 1000);
|
|
155
|
-
timer.unref();
|
|
171
|
+
c.timer.unref();
|
|
156
172
|
}
|
|
157
173
|
/**
|
|
158
174
|
* Start the client once the inspector listens. Resolves after the first
|
|
159
175
|
* registration attempt (success or not); the heartbeat loop runs on its
|
|
160
176
|
* own afterwards.
|
|
161
177
|
*/
|
|
162
|
-
export async function startCloudSync(
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
178
|
+
export async function startCloudSync(url, version) {
|
|
179
|
+
const c = client();
|
|
180
|
+
c.selfUrl = url;
|
|
181
|
+
c.selfVersion = version;
|
|
182
|
+
c.stopped = false;
|
|
166
183
|
const project = await resolveWorkspace(workspaceRoot()).catch(() => null);
|
|
167
184
|
const cloud = project ? cloudFor(project) : undefined;
|
|
168
185
|
if (!cloud) {
|
|
169
|
-
status = { url: "", state: "off" };
|
|
186
|
+
c.status = { url: "", state: "off" };
|
|
170
187
|
return;
|
|
171
188
|
}
|
|
172
|
-
|
|
189
|
+
// The workspace may close (or the client stop) while this waits: then it does not register or schedule at all.
|
|
190
|
+
if (c.stopped || !workspaceOpen())
|
|
191
|
+
return;
|
|
192
|
+
c.status = { url: cloud.url, state: "connecting", interval: cloud.interval };
|
|
173
193
|
await register();
|
|
194
|
+
if (c.stopped || !workspaceOpen())
|
|
195
|
+
return;
|
|
174
196
|
// Until the cloud answers, retry at the configured cadence; once it does, at what it asked for.
|
|
175
|
-
if (status.state !== "connected")
|
|
176
|
-
status.interval = cloud.interval;
|
|
197
|
+
if (c.status.state !== "connected")
|
|
198
|
+
c.status.interval = cloud.interval;
|
|
177
199
|
schedule();
|
|
178
200
|
}
|
|
179
201
|
/** Stop the heartbeat loop (server close, tests). */
|
|
180
202
|
export function stopCloudSync() {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
203
|
+
const c = client();
|
|
204
|
+
c.stopped = true;
|
|
205
|
+
if (c.timer)
|
|
206
|
+
clearTimeout(c.timer);
|
|
207
|
+
c.timer = null;
|
|
185
208
|
}
|
|
186
209
|
/**
|
|
187
210
|
* Tell the cloud we are going away, so the record flips to "stopped" now
|
|
@@ -189,11 +212,21 @@ export function stopCloudSync() {
|
|
|
189
212
|
* this, and a slow cloud must not hold the process.
|
|
190
213
|
*/
|
|
191
214
|
export async function cloudGoodbye() {
|
|
215
|
+
const c = client();
|
|
192
216
|
stopCloudSync();
|
|
193
|
-
if (!identity || status.state === "off" || !status.url)
|
|
217
|
+
if (!c.identity || c.status.state === "off" || !c.status.url)
|
|
194
218
|
return;
|
|
195
219
|
try {
|
|
196
|
-
await post(`${status.url}/api/instances/goodbye`, identity, GOODBYE_TIMEOUT_MS);
|
|
220
|
+
await post(`${c.status.url}/api/instances/goodbye`, c.identity, GOODBYE_TIMEOUT_MS);
|
|
197
221
|
}
|
|
198
222
|
catch { }
|
|
199
223
|
}
|
|
224
|
+
/** Every served workspace: stop its heartbeat (the server closes). */
|
|
225
|
+
export function stopAllCloudSync() {
|
|
226
|
+
for (const [ws] of client.entries())
|
|
227
|
+
ws.run(stopCloudSync);
|
|
228
|
+
}
|
|
229
|
+
/** Every served workspace says goodbye, in parallel (still bounded per workspace). */
|
|
230
|
+
export async function cloudGoodbyeAll() {
|
|
231
|
+
await Promise.all(client.entries().map(([ws]) => ws.run(cloudGoodbye)));
|
|
232
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Hub } from "./server.js";
|
|
2
|
+
export declare const runInHub: <T>(hub: Hub, fn: () => T) => T;
|
|
3
|
+
export declare function currentHub(): Hub;
|
|
4
|
+
/** API paths that need no workspace: the machine's own (the hub, devices and pairing, the protocol, the socket). */
|
|
5
|
+
export declare const MACHINE_PATHS: RegExp;
|
|
6
|
+
/** Route names callable over the socket without a workspace. */
|
|
7
|
+
export declare const MACHINE_ROUTES: RegExp;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
/**
|
|
3
|
+
* The hub a request runs in, like the workspace (core/workspace.ts): the
|
|
4
|
+
* machine-level routes (hub.*) find theirs here, so several hubs in one
|
|
5
|
+
* process (tests) never act on each other's workspaces.
|
|
6
|
+
*/
|
|
7
|
+
const storage = new AsyncLocalStorage();
|
|
8
|
+
export const runInHub = (hub, fn) => storage.run(hub, fn);
|
|
9
|
+
export function currentHub() {
|
|
10
|
+
const hub = storage.getStore();
|
|
11
|
+
if (!hub)
|
|
12
|
+
throw new Error("no hub here");
|
|
13
|
+
return hub;
|
|
14
|
+
}
|
|
15
|
+
/** API paths that need no workspace: the machine's own (the hub, devices and pairing, the protocol, the socket). */
|
|
16
|
+
export const MACHINE_PATHS = /^\/api\/(hub(\/|$)|pair$|devices(\/|$)|protocol$|ws$)/;
|
|
17
|
+
/** Route names callable over the socket without a workspace. */
|
|
18
|
+
export const MACHINE_ROUTES = /^(hub|devices|protocol)\./;
|
package/dist/server/live.d.ts
CHANGED
|
@@ -37,6 +37,8 @@ interface Watch {
|
|
|
37
37
|
* every change, and at least every `interval` ms. Returns the unwatch.
|
|
38
38
|
*/
|
|
39
39
|
export declare function watch(key: string, evaluate: Watch["evaluate"], interval: number, push: (data: unknown) => void): () => void;
|
|
40
|
+
/** The current workspace closes: its watches stop re-evaluating (their sockets close with it). */
|
|
41
|
+
export declare function closeWatches(): void;
|
|
40
42
|
/** Something changed: re-evaluate every watch of this workspace soon (debounced). */
|
|
41
43
|
export declare function touch(): void;
|
|
42
44
|
export {};
|
package/dist/server/live.js
CHANGED
|
@@ -71,6 +71,15 @@ export function watch(key, evaluate, interval, push) {
|
|
|
71
71
|
schedule(current);
|
|
72
72
|
};
|
|
73
73
|
}
|
|
74
|
+
/** The current workspace closes: its watches stop re-evaluating (their sockets close with it). */
|
|
75
|
+
export function closeWatches() {
|
|
76
|
+
const s = state();
|
|
77
|
+
for (const w of s.watches.values())
|
|
78
|
+
clearTimeout(w.timer);
|
|
79
|
+
s.watches.clear();
|
|
80
|
+
clearTimeout(s.touchTimer);
|
|
81
|
+
s.touchTimer = undefined;
|
|
82
|
+
}
|
|
74
83
|
/** Something changed: re-evaluate every watch of this workspace soon (debounced). */
|
|
75
84
|
export function touch() {
|
|
76
85
|
// Outside any workspace (a unit test, a CLI command) nobody can be watching.
|
package/dist/server/preview.js
CHANGED
|
@@ -108,7 +108,8 @@ export async function serveVibeable(req, res, url) {
|
|
|
108
108
|
let st = await fs.stat(abs).catch(() => null);
|
|
109
109
|
if (st?.isDirectory()) {
|
|
110
110
|
if (!url.pathname.endsWith("/")) {
|
|
111
|
-
|
|
111
|
+
// Relative, so it holds under a /w/<slug> prefix too.
|
|
112
|
+
res.writeHead(302, { location: `${url.pathname.split("/").pop()}/${url.search}`, "cache-control": "no-store" });
|
|
112
113
|
return void res.end();
|
|
113
114
|
}
|
|
114
115
|
abs = path.join(abs, "index.html");
|
package/dist/server/server.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
|
+
import { Workspace } from "../core/workspace.js";
|
|
2
3
|
import { RESTART_EXIT_CODE } from "./version.js";
|
|
3
4
|
export { RESTART_EXIT_CODE };
|
|
4
5
|
export interface InspectorOptions {
|
|
@@ -7,6 +8,62 @@ export interface InspectorOptions {
|
|
|
7
8
|
port: number;
|
|
8
9
|
/** The workspace root (where kraftwerk.yml lives); defaults to the parent of outputDir. */
|
|
9
10
|
root?: string;
|
|
11
|
+
/**
|
|
12
|
+
* A loopback peer is this machine and needs no token (default true).
|
|
13
|
+
* False makes every request pair first — tests, or a hardened setup
|
|
14
|
+
* behind a local reverse proxy that serves the network.
|
|
15
|
+
*/
|
|
16
|
+
trustLoopback?: boolean;
|
|
10
17
|
}
|
|
11
|
-
/**
|
|
18
|
+
/** A workspace a hub serves. */
|
|
19
|
+
export interface OpenWorkspace {
|
|
20
|
+
slug: string;
|
|
21
|
+
root: string;
|
|
22
|
+
name: string;
|
|
23
|
+
/** Where it is reached: <slug>.localhost:<port> under a daemon, localhost:<port> standalone. */
|
|
24
|
+
url: string;
|
|
25
|
+
/** Its own port too (kraftwerk.yml `port`), when the hub listens there for it — old links keep working. */
|
|
26
|
+
aliasPort?: number;
|
|
27
|
+
ws: Workspace;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* One HTTP server serving workspaces by slug (see target.ts): a daemon
|
|
31
|
+
* (`kraftwerk daemon`) serves every workspace that is open on the machine,
|
|
32
|
+
* a standalone `kraftwerk ui` exactly one — its default, also reached
|
|
33
|
+
* without a slug, as before. Each open workspace runs in its own Workspace
|
|
34
|
+
* (core/workspace.ts) with its own .env, background jobs (routines, git
|
|
35
|
+
* sync, cloud), registry entry and live state; closing one ends all of that
|
|
36
|
+
* and leaves the others running.
|
|
37
|
+
*/
|
|
38
|
+
export interface Hub {
|
|
39
|
+
readonly server: http.Server;
|
|
40
|
+
/** The port it listens on. */
|
|
41
|
+
readonly port: number;
|
|
42
|
+
/** Whether it is the machine's daemon (workspaces addressed by slug, none by default). */
|
|
43
|
+
readonly daemon: boolean;
|
|
44
|
+
/** Serve a workspace (idempotent: an open one is returned as it is). */
|
|
45
|
+
open(root: string, opts?: {
|
|
46
|
+
outputDir?: string;
|
|
47
|
+
}): Promise<OpenWorkspace>;
|
|
48
|
+
/** Stop serving one; false when it was not open. */
|
|
49
|
+
close(root: string): Promise<boolean>;
|
|
50
|
+
list(): OpenWorkspace[];
|
|
51
|
+
/** Close every workspace and the server. */
|
|
52
|
+
stop(): Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
export interface HubOptions {
|
|
55
|
+
port: number;
|
|
56
|
+
staticDir: string;
|
|
57
|
+
trustLoopback?: boolean;
|
|
58
|
+
/** The machine's daemon: workspaces get `hub` instance files, their own ports as aliases, and no default. */
|
|
59
|
+
daemon?: boolean;
|
|
60
|
+
/** After a workspace opened or closed (the daemon notes what to reopen after a restart). */
|
|
61
|
+
onChange?: (hub: Hub) => void | Promise<void>;
|
|
62
|
+
}
|
|
63
|
+
export declare function startHub(opts: HubOptions): Promise<Hub>;
|
|
64
|
+
/**
|
|
65
|
+
* A standalone server for one workspace (`kraftwerk ui`, tests): a hub
|
|
66
|
+
* serving it as its default, reached with or without its slug. Resolves
|
|
67
|
+
* once it listens.
|
|
68
|
+
*/
|
|
12
69
|
export declare function startInspector(opts: InspectorOptions): Promise<http.Server>;
|