@hardkas/node-runner 0.2.2-alpha → 0.3.0-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/dist/index.d.ts CHANGED
@@ -12,6 +12,7 @@ interface DockerKaspadOptions {
12
12
  readonly ports?: Partial<KaspadPorts>;
13
13
  readonly dataDir?: string;
14
14
  readonly detach?: boolean;
15
+ readonly allowFloatingImage?: boolean;
15
16
  }
16
17
  interface KaspadNodeStatus {
17
18
  readonly containerName: string;
@@ -21,9 +22,27 @@ interface KaspadNodeStatus {
21
22
  readonly statusText?: string;
22
23
  readonly ports: KaspadPorts;
23
24
  readonly dataDir: string;
25
+ readonly rpcUrl: string;
26
+ readonly rpcReady: boolean;
27
+ readonly transports: {
28
+ readonly grpc: {
29
+ readonly port: number;
30
+ readonly ready: boolean;
31
+ };
32
+ readonly borsh: {
33
+ readonly port: number;
34
+ readonly ready: boolean;
35
+ };
36
+ readonly json: {
37
+ readonly port: number;
38
+ readonly ready: boolean;
39
+ readonly url: string;
40
+ };
41
+ };
42
+ readonly lastError?: string | null;
24
43
  }
25
44
 
26
- declare const DEFAULT_IMAGE = "kaspanet/rusty-kaspad:latest";
45
+ declare const DEFAULT_IMAGE = "kaspanet/rusty-kaspad:v1.1.0";
27
46
  declare const DEFAULT_CONTAINER_NAME = "hardkas-kaspad-simnet";
28
47
  declare const DEFAULT_NETWORK = "simnet";
29
48
  declare const DEFAULT_PORTS: KaspadPorts;
@@ -31,11 +50,19 @@ declare class DockerKaspadRunner {
31
50
  private readonly options;
32
51
  constructor(options?: DockerKaspadOptions);
33
52
  start(): Promise<KaspadNodeStatus>;
53
+ private ensurePortsAvailable;
54
+ private isPortAvailable;
34
55
  stop(): Promise<KaspadNodeStatus>;
56
+ private checkTransportReady;
35
57
  status(): Promise<KaspadNodeStatus>;
58
+ restart(): Promise<KaspadNodeStatus>;
59
+ reset(options?: {
60
+ removeData?: boolean;
61
+ }): Promise<KaspadNodeStatus>;
36
62
  logs(options?: {
37
63
  tail?: number;
38
- }): Promise<string>;
64
+ follow?: boolean;
65
+ }): Promise<string | void>;
39
66
  }
40
67
 
41
68
  export { DEFAULT_CONTAINER_NAME, DEFAULT_IMAGE, DEFAULT_NETWORK, DEFAULT_PORTS, type DockerKaspadOptions, DockerKaspadRunner, type KaspadNetwork, type KaspadNodeStatus, type KaspadPorts };
package/dist/index.js CHANGED
@@ -3,7 +3,12 @@ import { execa } from "execa";
3
3
  import fs from "fs/promises";
4
4
  import path from "path";
5
5
  import { existsSync } from "fs";
6
- var DEFAULT_IMAGE = "kaspanet/rusty-kaspad:latest";
6
+ import net from "net";
7
+ import {
8
+ checkKaspaRpcHealth,
9
+ waitForKaspaRpcReady
10
+ } from "@hardkas/kaspa-rpc";
11
+ var DEFAULT_IMAGE = "kaspanet/rusty-kaspad:v1.1.0";
7
12
  var DEFAULT_CONTAINER_NAME = "hardkas-kaspad-simnet";
8
13
  var DEFAULT_NETWORK = "simnet";
9
14
  var DEFAULT_PORTS = {
@@ -25,7 +30,8 @@ var DockerKaspadRunner = class {
25
30
  ...DEFAULT_PORTS,
26
31
  ...options?.ports || {}
27
32
  },
28
- detach: options?.detach ?? true
33
+ detach: options?.detach ?? true,
34
+ allowFloatingImage: options?.allowFloatingImage ?? false
29
35
  };
30
36
  }
31
37
  async start() {
@@ -38,6 +44,25 @@ var DockerKaspadRunner = class {
38
44
  } catch (e) {
39
45
  throw new Error("Docker is not available. Please install Docker to run a real Kaspa node.");
40
46
  }
47
+ if (this.options.image.endsWith(":latest") && !this.options.allowFloatingImage) {
48
+ console.warn("\n \u26A0\uFE0F WARNING: Using a floating Docker tag (:latest) reduces reproducibility.\n");
49
+ }
50
+ const network = this.options.network;
51
+ let networkFlag = "";
52
+ if (network === "simnet") {
53
+ networkFlag = "--simnet";
54
+ } else if (network.startsWith("testnet")) {
55
+ networkFlag = "--testnet";
56
+ } else if (network === "devnet") {
57
+ networkFlag = "--devnet";
58
+ } else if (network === "mainnet") {
59
+ throw new Error(
60
+ "Local Docker node for 'mainnet' is currently unsupported by HardKAS.\nPlease use a remote RPC provider or a manual kaspad setup for mainnet operations."
61
+ );
62
+ } else {
63
+ throw new Error(`Unsupported network for Docker runner: ${network}`);
64
+ }
65
+ await this.ensurePortsAvailable();
41
66
  const absoluteDataDir = path.isAbsolute(this.options.dataDir) ? this.options.dataDir : path.resolve(this.options.cwd, this.options.dataDir);
42
67
  if (!existsSync(absoluteDataDir)) {
43
68
  await fs.mkdir(absoluteDataDir, { recursive: true });
@@ -52,11 +77,11 @@ var DockerKaspadRunner = class {
52
77
  "--name",
53
78
  this.options.containerName,
54
79
  "-p",
55
- `${this.options.ports.rpc}:${this.options.ports.rpc}`,
80
+ `127.0.0.1:${this.options.ports.rpc}:${this.options.ports.rpc}`,
56
81
  "-p",
57
- `${this.options.ports.borshRpc}:${this.options.ports.borshRpc}`,
82
+ `127.0.0.1:${this.options.ports.borshRpc}:${this.options.ports.borshRpc}`,
58
83
  "-p",
59
- `${this.options.ports.jsonRpc}:${this.options.ports.jsonRpc}`,
84
+ `127.0.0.1:${this.options.ports.jsonRpc}:${this.options.ports.jsonRpc}`,
60
85
  "-v",
61
86
  `${absoluteDataDir}:/app/data`,
62
87
  this.options.image,
@@ -65,14 +90,56 @@ var DockerKaspadRunner = class {
65
90
  "--nologfiles",
66
91
  "--disable-upnp",
67
92
  "--utxoindex",
68
- "--simnet",
93
+ ...networkFlag ? [networkFlag] : [],
69
94
  `--rpclisten=0.0.0.0:${this.options.ports.rpc}`,
70
95
  `--rpclisten-borsh=0.0.0.0:${this.options.ports.borshRpc}`,
71
96
  `--rpclisten-json=0.0.0.0:${this.options.ports.jsonRpc}`
72
97
  ];
73
98
  await execa("docker", args);
99
+ const rpcUrl = `http://127.0.0.1:${this.options.ports.jsonRpc}`;
100
+ const health = await waitForKaspaRpcReady({
101
+ url: rpcUrl,
102
+ maxWaitMs: 6e4,
103
+ intervalMs: 1e3
104
+ });
105
+ if (!health.ready) {
106
+ throw new Error(
107
+ `Kaspad RPC failed to become ready within 60s.
108
+ Container: ${this.options.containerName}
109
+ Image: ${this.options.image}
110
+ RPC: ${rpcUrl}
111
+ Last Error: ${health.lastError || "Timeout"}
112
+
113
+ Try checking logs: hardkas node logs --tail 200`
114
+ );
115
+ }
74
116
  return this.status();
75
117
  }
118
+ async ensurePortsAvailable() {
119
+ const ports = [this.options.ports.rpc, this.options.ports.borshRpc, this.options.ports.jsonRpc];
120
+ for (const port of ports) {
121
+ const available = await this.isPortAvailable(port);
122
+ if (!available) {
123
+ throw new Error(
124
+ `Port ${port} is already in use on the host. Cannot start node.
125
+ - Stop any existing process using this port.
126
+ - Or change the port in hardkas.config.ts.
127
+ - Or run 'hardkas node reset --yes' if it's a stale container.`
128
+ );
129
+ }
130
+ }
131
+ }
132
+ isPortAvailable(port) {
133
+ return new Promise((resolve) => {
134
+ const server = net.createServer();
135
+ server.once("error", () => resolve(false));
136
+ server.once("listening", () => {
137
+ server.close();
138
+ resolve(true);
139
+ });
140
+ server.listen(port, "127.0.0.1");
141
+ });
142
+ }
76
143
  async stop() {
77
144
  try {
78
145
  await execa("docker", ["stop", this.options.containerName]);
@@ -81,7 +148,27 @@ var DockerKaspadRunner = class {
81
148
  }
82
149
  return this.status();
83
150
  }
151
+ async checkTransportReady(port) {
152
+ return new Promise((resolve) => {
153
+ const socket = new net.Socket();
154
+ socket.setTimeout(1e3);
155
+ socket.once("connect", () => {
156
+ socket.destroy();
157
+ resolve(true);
158
+ });
159
+ socket.once("timeout", () => {
160
+ socket.destroy();
161
+ resolve(false);
162
+ });
163
+ socket.once("error", () => {
164
+ socket.destroy();
165
+ resolve(false);
166
+ });
167
+ socket.connect(port, "127.0.0.1");
168
+ });
169
+ }
84
170
  async status() {
171
+ const rpcUrl = `http://127.0.0.1:${this.options.ports.jsonRpc}`;
85
172
  try {
86
173
  const { stdout } = await execa("docker", [
87
174
  "inspect",
@@ -90,6 +177,19 @@ var DockerKaspadRunner = class {
90
177
  this.options.containerName
91
178
  ]);
92
179
  const running = stdout.trim() === "running";
180
+ let jsonReady = false;
181
+ let grpcReady = false;
182
+ let borshReady = false;
183
+ let lastError = null;
184
+ if (running) {
185
+ const health = await checkKaspaRpcHealth({ url: rpcUrl, timeoutMs: 2e3 });
186
+ jsonReady = health.ready;
187
+ lastError = health.lastError || null;
188
+ [grpcReady, borshReady] = await Promise.all([
189
+ this.checkTransportReady(this.options.ports.rpc),
190
+ this.checkTransportReady(this.options.ports.borshRpc)
191
+ ]);
192
+ }
93
193
  return {
94
194
  containerName: this.options.containerName,
95
195
  image: this.options.image,
@@ -97,7 +197,16 @@ var DockerKaspadRunner = class {
97
197
  running,
98
198
  statusText: stdout.trim(),
99
199
  ports: this.options.ports,
100
- dataDir: this.options.dataDir
200
+ dataDir: this.options.dataDir,
201
+ rpcUrl,
202
+ rpcReady: jsonReady,
203
+ // Unified readiness
204
+ transports: {
205
+ grpc: { port: this.options.ports.rpc, ready: grpcReady },
206
+ borsh: { port: this.options.ports.borshRpc, ready: borshReady },
207
+ json: { port: this.options.ports.jsonRpc, ready: jsonReady, url: rpcUrl }
208
+ },
209
+ lastError
101
210
  };
102
211
  } catch (e) {
103
212
  return {
@@ -107,19 +216,45 @@ var DockerKaspadRunner = class {
107
216
  running: false,
108
217
  statusText: "not-found",
109
218
  ports: this.options.ports,
110
- dataDir: this.options.dataDir
219
+ dataDir: this.options.dataDir,
220
+ rpcUrl,
221
+ rpcReady: false,
222
+ transports: {
223
+ grpc: { port: this.options.ports.rpc, ready: false },
224
+ borsh: { port: this.options.ports.borshRpc, ready: false },
225
+ json: { port: this.options.ports.jsonRpc, ready: false, url: rpcUrl }
226
+ },
227
+ lastError: "Container not found"
111
228
  };
112
229
  }
113
230
  }
231
+ async restart() {
232
+ await this.stop();
233
+ return this.start();
234
+ }
235
+ async reset(options = { removeData: true }) {
236
+ await this.stop();
237
+ if (options.removeData) {
238
+ const absoluteDataDir = path.isAbsolute(this.options.dataDir) ? this.options.dataDir : path.resolve(this.options.cwd, this.options.dataDir);
239
+ if (existsSync(absoluteDataDir)) {
240
+ await fs.rm(absoluteDataDir, { recursive: true, force: true });
241
+ }
242
+ }
243
+ return this.status();
244
+ }
114
245
  async logs(options) {
115
246
  try {
116
247
  const tail = options?.tail || 100;
117
- const { stdout } = await execa("docker", [
118
- "logs",
119
- "--tail",
120
- tail.toString(),
121
- this.options.containerName
122
- ]);
248
+ const args = ["logs", "--tail", tail.toString()];
249
+ if (options?.follow) {
250
+ args.push("-f");
251
+ await execa("docker", [...args, this.options.containerName], {
252
+ stdout: "inherit",
253
+ stderr: "inherit"
254
+ });
255
+ return;
256
+ }
257
+ const { stdout } = await execa("docker", [...args, this.options.containerName]);
123
258
  return stdout;
124
259
  } catch (e) {
125
260
  throw new Error(`Could not get logs for container ${this.options.containerName}. Is it running?`);
package/package.json CHANGED
@@ -1,17 +1,19 @@
1
1
  {
2
2
  "name": "@hardkas/node-runner",
3
- "version": "0.2.2-alpha",
3
+ "version": "0.3.0-alpha",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
7
7
  "dependencies": {
8
- "execa": "^9.5.2"
8
+ "execa": "^9.5.2",
9
+ "@hardkas/core": "0.3.0-alpha",
10
+ "@hardkas/kaspa-rpc": "0.3.0-alpha"
9
11
  },
10
12
  "devDependencies": {
11
13
  "tsup": "^8.3.5",
12
14
  "typescript": "^5.7.2",
13
15
  "vitest": "^2.1.8",
14
- "@hardkas/config": "0.2.2-alpha"
16
+ "@hardkas/config": "0.3.0-alpha"
15
17
  },
16
18
  "license": "MIT",
17
19
  "author": "Javier Rodriguez",