smolmachines 1.25.2 → 1.27.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 +35 -7
- package/dist/assets.d.ts +15 -11
- package/dist/assets.js +18 -14
- package/dist/binding.d.ts +5 -0
- package/dist/machine.d.ts +11 -4
- package/dist/machine.js +13 -4
- package/dist/native.d.ts +1 -0
- package/dist/transport.d.ts +8 -0
- package/dist/transport.js +104 -15
- package/dist/types.d.ts +9 -10
- package/package.json +6 -5
package/README.md
CHANGED
|
@@ -87,10 +87,32 @@ try {
|
|
|
87
87
|
}
|
|
88
88
|
```
|
|
89
89
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
90
|
+
Local machines also accept a saved Docker/OCI image archive or an unpacked rootfs
|
|
91
|
+
as `image`. The SDK stages an archive once and reuses its content hash; a
|
|
92
|
+
missing local path fails immediately with the path in the error instead of
|
|
93
|
+
trying to pull it from a registry. Build your image first, then save it:
|
|
94
|
+
|
|
95
|
+
```sh
|
|
96
|
+
docker save my-agent:latest -o my-agent.tar
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
const m = await Machine.create(
|
|
101
|
+
{ image: './my-agent.tar', network: false },
|
|
102
|
+
{ target: 'local' },
|
|
103
|
+
);
|
|
104
|
+
try {
|
|
105
|
+
console.log((await m.exec(['sh', '-c', 'pwd'])).stdout);
|
|
106
|
+
} finally {
|
|
107
|
+
await m.delete();
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
When a published service starts through `exec()` (local or cloud), set
|
|
112
|
+
`waitForPorts: false` on creation. The SDK waits for the guest agent so
|
|
113
|
+
`exec()` can start the listener; call `waitUntilReady()` once the service is
|
|
114
|
+
listening. The default waits for all published ports. Select
|
|
115
|
+
`{ target: 'cloud' }` to use the same flow on Smol Cloud.
|
|
94
116
|
|
|
95
117
|
```ts
|
|
96
118
|
const m = await Machine.create(
|
|
@@ -109,10 +131,13 @@ try {
|
|
|
109
131
|
}
|
|
110
132
|
```
|
|
111
133
|
|
|
112
|
-
When reconnecting to
|
|
134
|
+
When reconnecting to a running local machine whose published service has
|
|
113
135
|
stopped, `Machine.connect(name, { target: 'local', waitForPorts: false })`
|
|
114
|
-
attaches after the agent is ready,
|
|
115
|
-
|
|
136
|
+
attaches after the agent is ready, without waiting for the listener. Cloud
|
|
137
|
+
`connect()` normally attaches without waiting; with `waitForPorts: false`, it
|
|
138
|
+
waits for a successful guest-agent command without requiring the port to be
|
|
139
|
+
open. Connect without that flag to inspect or delete a stopped or paused cloud
|
|
140
|
+
machine.
|
|
116
141
|
|
|
117
142
|
To reach a service **inside** the VM, use the authenticated connect bridge —
|
|
118
143
|
**no Cloudflare/localhost.run tunnel, no public exposure, no egress allow-list.**
|
|
@@ -215,6 +240,9 @@ Existing network connections may need to reconnect.
|
|
|
215
240
|
- `machine.exec(command, opts?)` / `machine.run(image, command, opts?)` → `ExecResult`.
|
|
216
241
|
- `machine.execStream(command, opts?)` → `AsyncGenerator<ExecEvent>`.
|
|
217
242
|
- `machine.readFile(path)` / `machine.writeFile(path, data, mode?)`.
|
|
243
|
+
- `machine.readFileStream(path)` yields `Uint8Array` chunks without buffering
|
|
244
|
+
the full Cloud download; stopping iteration cancels the response. Local
|
|
245
|
+
machines currently yield one buffered chunk.
|
|
218
246
|
- `machine.pullImage(image)` / `machine.listImages()`.
|
|
219
247
|
- `machine.branch(name, options?)` / `machine.branchBatch(options)`.
|
|
220
248
|
- `machine.checkpoint(output, { store? })`, `Machine.restoreCheckpoint(...)`,
|
package/dist/assets.d.ts
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
/** Auto-wiring for bundled native assets.
|
|
2
2
|
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* -
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* -
|
|
9
|
-
* -
|
|
10
|
-
*
|
|
3
|
+
* Resolves the package's bundled, signed boot helper, hypervisor libraries and
|
|
4
|
+
* guest rootfs so the SDK works on a plain `node` with no manual env setup:
|
|
5
|
+
* - bootBinary → bundled `smol-vmm` helper (handles `_boot-vm`; on macOS
|
|
6
|
+
* codesigned with `com.apple.security.hypervisor`, so the user's `node`
|
|
7
|
+
* needs no entitlement).
|
|
8
|
+
* - libDir → the dir holding libkrun/libkrunfw.
|
|
9
|
+
* - agentRootfsTar → bundled guest rootfs tarball (the engine extracts it
|
|
10
|
+
* on first use), so a plain `npm i` is fully self-contained.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* The resolved paths are handed to the engine in-process by
|
|
13
|
+
* `configureRuntimeAssets` and are never written to `process.env`: a `smolvm`
|
|
14
|
+
* CLI this program spawns must not inherit this package's helper (it exits
|
|
15
|
+
* when its parent does) or its libraries. A user-provided SMOLVM_BOOT_BINARY,
|
|
16
|
+
* SMOLVM_LIB_DIR or SMOLVM_AGENT_ROOTFS(_TAR) always wins. Called by
|
|
17
|
+
* `native.ts` right before the addon loads — i.e. on first LOCAL use, never at
|
|
18
|
+
* import.
|
|
15
19
|
*/
|
|
16
20
|
/** The per-platform npm package that carries the native addon, boot helper,
|
|
17
21
|
* hypervisor libraries and guest rootfs for each supported
|
package/dist/assets.js
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/** Auto-wiring for bundled native assets.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* -
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* -
|
|
10
|
-
* -
|
|
11
|
-
*
|
|
4
|
+
* Resolves the package's bundled, signed boot helper, hypervisor libraries and
|
|
5
|
+
* guest rootfs so the SDK works on a plain `node` with no manual env setup:
|
|
6
|
+
* - bootBinary → bundled `smol-vmm` helper (handles `_boot-vm`; on macOS
|
|
7
|
+
* codesigned with `com.apple.security.hypervisor`, so the user's `node`
|
|
8
|
+
* needs no entitlement).
|
|
9
|
+
* - libDir → the dir holding libkrun/libkrunfw.
|
|
10
|
+
* - agentRootfsTar → bundled guest rootfs tarball (the engine extracts it
|
|
11
|
+
* on first use), so a plain `npm i` is fully self-contained.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* The resolved paths are handed to the engine in-process by
|
|
14
|
+
* `configureRuntimeAssets` and are never written to `process.env`: a `smolvm`
|
|
15
|
+
* CLI this program spawns must not inherit this package's helper (it exits
|
|
16
|
+
* when its parent does) or its libraries. A user-provided SMOLVM_BOOT_BINARY,
|
|
17
|
+
* SMOLVM_LIB_DIR or SMOLVM_AGENT_ROOTFS(_TAR) always wins. Called by
|
|
18
|
+
* `native.ts` right before the addon loads — i.e. on first LOCAL use, never at
|
|
19
|
+
* import.
|
|
16
20
|
*/
|
|
17
21
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
22
|
exports.PLATFORM_PACKAGES = void 0;
|
|
@@ -77,18 +81,18 @@ function wireBundledAssets() {
|
|
|
77
81
|
if (!(0, node_fs_1.existsSync)(nativeDir))
|
|
78
82
|
continue;
|
|
79
83
|
const helper = (0, node_path_1.join)(nativeDir, helperName);
|
|
84
|
+
// Resolved paths are handed to the engine by `configureRuntimeAssets`, never
|
|
85
|
+
// written to `process.env`: a `smolvm` CLI this program spawns must not
|
|
86
|
+
// inherit this package's helper, libraries or rootfs.
|
|
80
87
|
if (!assets.bootBinary && (0, node_fs_1.existsSync)(helper)) {
|
|
81
88
|
assets.bootBinary = helper;
|
|
82
|
-
process.env.SMOLVM_BOOT_BINARY = helper;
|
|
83
89
|
}
|
|
84
90
|
if (!assets.libDir) {
|
|
85
91
|
assets.libDir = nativeDir;
|
|
86
|
-
process.env.SMOLVM_LIB_DIR = nativeDir;
|
|
87
92
|
}
|
|
88
93
|
const rootfsTar = (0, node_path_1.join)(nativeDir, 'agent-rootfs.tar');
|
|
89
94
|
if (!assets.agentRootfs && !assets.agentRootfsTar && (0, node_fs_1.existsSync)(rootfsTar)) {
|
|
90
95
|
assets.agentRootfsTar = rootfsTar;
|
|
91
|
-
process.env.SMOLVM_AGENT_ROOTFS_TAR = rootfsTar;
|
|
92
96
|
}
|
|
93
97
|
break;
|
|
94
98
|
}
|
package/dist/binding.d.ts
CHANGED
|
@@ -287,6 +287,11 @@ export declare class NapiMachine {
|
|
|
287
287
|
* for a failed boot. Never throws.
|
|
288
288
|
*/
|
|
289
289
|
static checkHost(): HostAvailability
|
|
290
|
+
/**
|
|
291
|
+
* Stage a local archive or rootfs directory before creating the machine.
|
|
292
|
+
* Large archives are hashed off the JavaScript event loop.
|
|
293
|
+
*/
|
|
294
|
+
static prepareImage(image: string): Promise<string>
|
|
290
295
|
/** Create a new machine. Does not start the VM yet — call `start()`. */
|
|
291
296
|
constructor(config: MachineConfig)
|
|
292
297
|
/**
|
package/dist/machine.d.ts
CHANGED
|
@@ -15,8 +15,8 @@ export declare class Machine {
|
|
|
15
15
|
private constructor();
|
|
16
16
|
/**
|
|
17
17
|
* Create and start a machine. By default this waits for the guest agent and
|
|
18
|
-
* all published ports.
|
|
19
|
-
*
|
|
18
|
+
* all published ports. `waitForPorts: false` returns when the agent is
|
|
19
|
+
* ready so `exec()` can start a service on a published port.
|
|
20
20
|
* A cloud state of `"started"` alone means only that the VM process launched.
|
|
21
21
|
*
|
|
22
22
|
* @param config machine configuration (a name is generated if omitted; `image`
|
|
@@ -29,11 +29,14 @@ export declare class Machine {
|
|
|
29
29
|
* machine made elsewhere (another process, the console, the REST API).
|
|
30
30
|
* - local (default): re-opens a persisted machine by NAME, starting it if
|
|
31
31
|
* stopped — pairs with `Machine.create({ name, … }, …)` + `persistent`.
|
|
32
|
+
* A paused machine stays paused; call `resume()` to restore its saved
|
|
33
|
+
* execution before running commands.
|
|
32
34
|
* Set `conn.waitForPorts: false` to attach after agent readiness when a
|
|
33
35
|
* published service is not listening (for example, to delete it).
|
|
34
36
|
* - cloud: looks up the machine by id; throws if it doesn't exist.
|
|
35
|
-
* This does not wait for readiness
|
|
36
|
-
*
|
|
37
|
+
* This does not wait for readiness by default. With `waitForPorts: false`,
|
|
38
|
+
* it waits for the guest agent so `exec()` can start a published service.
|
|
39
|
+
* Call `waitUntilReady()` before expecting the published service to respond.
|
|
37
40
|
*
|
|
38
41
|
* @param id local machine name, or cloud machine id (`mach-…`)
|
|
39
42
|
* @param conn backend selection (local by default; cloud via `{ target: 'cloud', apiKey }` or `SMOL_CLOUD_TOKEN`)
|
|
@@ -136,6 +139,10 @@ export declare class Machine {
|
|
|
136
139
|
execStream(command: string[], opts?: ExecOptions): AsyncGenerator<ExecEvent>;
|
|
137
140
|
/** Read a file from the machine. */
|
|
138
141
|
readFile(path: string): Promise<Buffer>;
|
|
142
|
+
/** Read a file in chunks without buffering the entire Cloud response.
|
|
143
|
+
* Local machines currently yield one buffered chunk from the native binding.
|
|
144
|
+
* Stopping iteration early cancels the Cloud HTTP download. */
|
|
145
|
+
readFileStream(path: string): AsyncGenerator<Uint8Array>;
|
|
139
146
|
/** Write a file into the machine. */
|
|
140
147
|
writeFile(path: string, data: string | Uint8Array, mode?: number): Promise<void>;
|
|
141
148
|
/** Pull an OCI image into the machine's storage. (local) */
|
package/dist/machine.js
CHANGED
|
@@ -57,8 +57,8 @@ class Machine {
|
|
|
57
57
|
}
|
|
58
58
|
/**
|
|
59
59
|
* Create and start a machine. By default this waits for the guest agent and
|
|
60
|
-
* all published ports.
|
|
61
|
-
*
|
|
60
|
+
* all published ports. `waitForPorts: false` returns when the agent is
|
|
61
|
+
* ready so `exec()` can start a service on a published port.
|
|
62
62
|
* A cloud state of `"started"` alone means only that the VM process launched.
|
|
63
63
|
*
|
|
64
64
|
* @param config machine configuration (a name is generated if omitted; `image`
|
|
@@ -78,11 +78,14 @@ class Machine {
|
|
|
78
78
|
* machine made elsewhere (another process, the console, the REST API).
|
|
79
79
|
* - local (default): re-opens a persisted machine by NAME, starting it if
|
|
80
80
|
* stopped — pairs with `Machine.create({ name, … }, …)` + `persistent`.
|
|
81
|
+
* A paused machine stays paused; call `resume()` to restore its saved
|
|
82
|
+
* execution before running commands.
|
|
81
83
|
* Set `conn.waitForPorts: false` to attach after agent readiness when a
|
|
82
84
|
* published service is not listening (for example, to delete it).
|
|
83
85
|
* - cloud: looks up the machine by id; throws if it doesn't exist.
|
|
84
|
-
* This does not wait for readiness
|
|
85
|
-
*
|
|
86
|
+
* This does not wait for readiness by default. With `waitForPorts: false`,
|
|
87
|
+
* it waits for the guest agent so `exec()` can start a published service.
|
|
88
|
+
* Call `waitUntilReady()` before expecting the published service to respond.
|
|
86
89
|
*
|
|
87
90
|
* @param id local machine name, or cloud machine id (`mach-…`)
|
|
88
91
|
* @param conn backend selection (local by default; cloud via `{ target: 'cloud', apiKey }` or `SMOL_CLOUD_TOKEN`)
|
|
@@ -262,6 +265,12 @@ class Machine {
|
|
|
262
265
|
readFile(path) {
|
|
263
266
|
return this.transport.readFile(path);
|
|
264
267
|
}
|
|
268
|
+
/** Read a file in chunks without buffering the entire Cloud response.
|
|
269
|
+
* Local machines currently yield one buffered chunk from the native binding.
|
|
270
|
+
* Stopping iteration early cancels the Cloud HTTP download. */
|
|
271
|
+
readFileStream(path) {
|
|
272
|
+
return this.transport.readFileStream(path);
|
|
273
|
+
}
|
|
265
274
|
/** Write a file into the machine. */
|
|
266
275
|
writeFile(path, data, mode) {
|
|
267
276
|
const buf = typeof data === "string" ? Buffer.from(data) : Buffer.from(data);
|
package/dist/native.d.ts
CHANGED
|
@@ -150,6 +150,7 @@ export interface NapiMachine {
|
|
|
150
150
|
}
|
|
151
151
|
export interface NapiMachineCtor {
|
|
152
152
|
new (config: NativeMachineConfig): NapiMachine;
|
|
153
|
+
prepareImage(image: string): Promise<string>;
|
|
153
154
|
connect(name: string, interceptorAddress?: string, interceptorToken?: string): NapiMachine;
|
|
154
155
|
list(): Promise<NativeMachineSummary[]>;
|
|
155
156
|
restoreCheckpoint(name: string, artifact: string, keepIdentity?: boolean): NapiMachine;
|
package/dist/transport.d.ts
CHANGED
|
@@ -41,6 +41,7 @@ export interface Transport {
|
|
|
41
41
|
run(image: string, command: string[], opts?: ExecOptions): Promise<RawExec>;
|
|
42
42
|
execStream(command: string[], opts?: ExecOptions): AsyncGenerator<ExecEvent>;
|
|
43
43
|
readFile(path: string): Promise<Buffer>;
|
|
44
|
+
readFileStream(path: string): AsyncGenerator<Uint8Array>;
|
|
44
45
|
writeFile(path: string, data: Buffer, mode?: number): Promise<void>;
|
|
45
46
|
pullImage(image: string): Promise<ImageInfo>;
|
|
46
47
|
listImages(): Promise<ImageInfo[]>;
|
|
@@ -122,6 +123,13 @@ interface CloudConn {
|
|
|
122
123
|
fetch?: typeof fetch;
|
|
123
124
|
readResponseBytes?: (response: Response) => Promise<Uint8Array>;
|
|
124
125
|
}
|
|
126
|
+
/** The largest file the cloud's file routes accept. */
|
|
127
|
+
export declare const CLOUD_MAX_FILE_BYTES: number;
|
|
128
|
+
/** How long moving `bytes` may take (ms): the request timeout plus the time
|
|
129
|
+
* `bytes` needs at the floor rate. */
|
|
130
|
+
export declare function transferTimeoutMs(bytes: number): number;
|
|
131
|
+
/** Refuse a file the cloud would refuse, before sending any of it. */
|
|
132
|
+
export declare function checkCloudFileSize(path: string, bytes: number): void;
|
|
125
133
|
/** The sentence a person should read from a failed response's body. The
|
|
126
134
|
* control plane answers errors as RFC 9457 problem+json, whose `detail` is the
|
|
127
135
|
* sentence; older builds sent `{"error": …}` or plain text. A JSON object
|
package/dist/transport.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* Cloud-only/local-only capability gaps surface as `NotSupportedError`.
|
|
9
9
|
*/
|
|
10
10
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.CLOUD_MAX_FILE_BYTES = void 0;
|
|
11
12
|
exports.cliConfigApiKey = cliConfigApiKey;
|
|
12
13
|
exports.resolveNetwork = resolveNetwork;
|
|
13
14
|
exports.networkMode = networkMode;
|
|
@@ -15,6 +16,8 @@ exports.cloudNetwork = cloudNetwork;
|
|
|
15
16
|
exports.egressPolicy = egressPolicy;
|
|
16
17
|
exports.resolveBranchable = resolveBranchable;
|
|
17
18
|
exports.toNativeConfig = toNativeConfig;
|
|
19
|
+
exports.transferTimeoutMs = transferTimeoutMs;
|
|
20
|
+
exports.checkCloudFileSize = checkCloudFileSize;
|
|
18
21
|
exports.errorSentence = errorSentence;
|
|
19
22
|
exports.encodePath = encodePath;
|
|
20
23
|
exports.selectsCloud = selectsCloud;
|
|
@@ -468,6 +471,11 @@ class LocalTransport {
|
|
|
468
471
|
throw (0, errors_1.wrapNativeError)(e);
|
|
469
472
|
}
|
|
470
473
|
}
|
|
474
|
+
// The native local binding currently returns a Buffer; keep the same API
|
|
475
|
+
// across targets until native streaming is available.
|
|
476
|
+
async *readFileStream(path) {
|
|
477
|
+
yield await this.readFile(path);
|
|
478
|
+
}
|
|
471
479
|
async writeFile(path, data, mode) {
|
|
472
480
|
try {
|
|
473
481
|
await this.inner.writeFile(path, data, mode === undefined ? undefined : { mode });
|
|
@@ -699,6 +707,23 @@ function cloudConnOf(conn, baseUrl, apiKey) {
|
|
|
699
707
|
const DEFAULT_CLOUD_URL = "https://api.smolmachines.com";
|
|
700
708
|
/** Default per-request timeout for cloud calls (ms). Override via opts.timeoutMs. */
|
|
701
709
|
const CLOUD_TIMEOUT_MS = 30_000;
|
|
710
|
+
// A file transfer is given time for the slowest link it should survive: a
|
|
711
|
+
// large file over a slow uplink must not fail at CLOUD_TIMEOUT_MS while it is
|
|
712
|
+
// still moving. Same floor as the Rust and Python SDKs.
|
|
713
|
+
const CLOUD_TRANSFER_FLOOR_BYTES_PER_S = 256 * 1024;
|
|
714
|
+
/** The largest file the cloud's file routes accept. */
|
|
715
|
+
exports.CLOUD_MAX_FILE_BYTES = 100 * 1024 * 1024;
|
|
716
|
+
/** How long moving `bytes` may take (ms): the request timeout plus the time
|
|
717
|
+
* `bytes` needs at the floor rate. */
|
|
718
|
+
function transferTimeoutMs(bytes) {
|
|
719
|
+
return CLOUD_TIMEOUT_MS + Math.ceil(bytes / CLOUD_TRANSFER_FLOOR_BYTES_PER_S) * 1_000;
|
|
720
|
+
}
|
|
721
|
+
/** Refuse a file the cloud would refuse, before sending any of it. */
|
|
722
|
+
function checkCloudFileSize(path, bytes) {
|
|
723
|
+
if (bytes > exports.CLOUD_MAX_FILE_BYTES) {
|
|
724
|
+
throw new errors_1.SmolError("SMOLVM_ERROR", `${path} is ${(bytes / (1024 * 1024)).toFixed(1)} MiB; cloud machines accept files up to ${exports.CLOUD_MAX_FILE_BYTES / (1024 * 1024)} MiB`);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
702
727
|
// Starting can include a cold image pull. Keep ordinary API calls short, but
|
|
703
728
|
// give this explicitly long-running operation the same bounded window already
|
|
704
729
|
// used by checkpoint restore.
|
|
@@ -795,6 +820,8 @@ async function cloudFetch(conn, method, path, opts = {}) {
|
|
|
795
820
|
? "UNAUTHORIZED"
|
|
796
821
|
: "SMOLVM_ERROR", `cloud ${method} ${path} → ${res.status}${text ? `: ${text}` : ""}${rid ? ` [request id: ${rid}]` : ""}`);
|
|
797
822
|
}
|
|
823
|
+
if (opts.accept === "response")
|
|
824
|
+
return res;
|
|
798
825
|
if (opts.accept === "text")
|
|
799
826
|
return (await res.text());
|
|
800
827
|
if (opts.accept === "bytes") {
|
|
@@ -816,11 +843,11 @@ async function cloudFetch(conn, method, path, opts = {}) {
|
|
|
816
843
|
* caller keeps polling until this passes or the deadline hits. */
|
|
817
844
|
async function agentReachable(conn, id) {
|
|
818
845
|
try {
|
|
819
|
-
await cloudFetch(conn, "POST", `/v1/machines/${id}/exec`, {
|
|
846
|
+
const result = await cloudFetch(conn, "POST", `/v1/machines/${id}/exec`, {
|
|
820
847
|
json: { command: ["sh", "-c", "true"] },
|
|
821
848
|
timeoutMs: 15_000,
|
|
822
849
|
});
|
|
823
|
-
return
|
|
850
|
+
return result?.exitCode === 0;
|
|
824
851
|
}
|
|
825
852
|
catch {
|
|
826
853
|
return false;
|
|
@@ -835,8 +862,10 @@ async function agentReachable(conn, id) {
|
|
|
835
862
|
* `started` is NOT yet usable: the guest is still booting, and acting then is
|
|
836
863
|
* the classic teardown race (works on a slow cold start, times out on a warm
|
|
837
864
|
* one). Older control planes omit `ready`; there we fall back to the coarse
|
|
838
|
-
* `started`/`running` state so this never hangs against them.
|
|
839
|
-
|
|
865
|
+
* `started`/`running` state so this never hangs against them.
|
|
866
|
+
* With `waitForPorts: false`, probe the agent even if ports are published:
|
|
867
|
+
* the caller must be able to exec the service before any port can open. */
|
|
868
|
+
async function waitForReady(conn, id, timeoutMs = 120_000, intervalMs = 1_000, waitForPorts = true) {
|
|
840
869
|
const start = Date.now();
|
|
841
870
|
const deadline = start + timeoutMs;
|
|
842
871
|
for (;;) {
|
|
@@ -862,8 +891,15 @@ async function waitForReady(conn, id, timeoutMs = 120_000, intervalMs = 1_000) {
|
|
|
862
891
|
if (state === "stopped" || state === "deleted") {
|
|
863
892
|
throw new errors_1.SmolError("SMOLVM_ERROR", `machine ${id} entered ${state} before becoming ready`);
|
|
864
893
|
}
|
|
894
|
+
// With deferred ports, even `started` and legacy servers' absent `ready`
|
|
895
|
+
// are insufficient: only a successful in-guest exec proves agent readiness.
|
|
896
|
+
if (!waitForPorts && (state === "started" || state === "running")) {
|
|
897
|
+
if (await agentReachable(conn, id))
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
865
900
|
// Back-compat: `ready` absent entirely → old server, gate on state.
|
|
866
|
-
if (
|
|
901
|
+
if (waitForPorts &&
|
|
902
|
+
m &&
|
|
867
903
|
m.ready === undefined &&
|
|
868
904
|
(state === "started" || state === "running"))
|
|
869
905
|
return;
|
|
@@ -873,7 +909,8 @@ async function waitForReady(conn, id, timeoutMs = 120_000, intervalMs = 1_000) {
|
|
|
873
909
|
// into). After a grace (so `ready` can flip first and this never preempts a
|
|
874
910
|
// machine legitimately about to become ready), confirm the guest agent is
|
|
875
911
|
// reachable directly (a trivial exec) and treat that as ready.
|
|
876
|
-
if (
|
|
912
|
+
if (waitForPorts &&
|
|
913
|
+
(state === "started" || state === "running") &&
|
|
877
914
|
!m?.ports?.length &&
|
|
878
915
|
Date.now() - start >= NO_PORT_READY_PROBE_GRACE_MS &&
|
|
879
916
|
(await agentReachable(conn, id)))
|
|
@@ -1132,11 +1169,59 @@ class CloudTransport {
|
|
|
1132
1169
|
async readFile(path) {
|
|
1133
1170
|
return cloudFetch(this.conn, "GET", `/v1/machines/${this.id}/files/${encodePath(path)}`, {
|
|
1134
1171
|
accept: "bytes",
|
|
1172
|
+
timeoutMs: transferTimeoutMs(exports.CLOUD_MAX_FILE_BYTES),
|
|
1135
1173
|
});
|
|
1136
1174
|
}
|
|
1175
|
+
async *readFileStream(path) {
|
|
1176
|
+
const route = `/v1/machines/${this.id}/files/${encodePath(path)}`;
|
|
1177
|
+
const res = await cloudFetch(this.conn, "GET", route, {
|
|
1178
|
+
accept: "response",
|
|
1179
|
+
timeoutMs: transferTimeoutMs(exports.CLOUD_MAX_FILE_BYTES),
|
|
1180
|
+
});
|
|
1181
|
+
// A caller-supplied byte reader may transform the response. Honor it just
|
|
1182
|
+
// as readFile does, even when the injected fetch exposes a body stream.
|
|
1183
|
+
if (this.conn.readResponseBytes) {
|
|
1184
|
+
const bytes = await this.conn.readResponseBytes(res);
|
|
1185
|
+
if (bytes.length > 0)
|
|
1186
|
+
yield bytes;
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
if (!res.body)
|
|
1190
|
+
return;
|
|
1191
|
+
const reader = res.body.getReader();
|
|
1192
|
+
try {
|
|
1193
|
+
for (;;) {
|
|
1194
|
+
let timer;
|
|
1195
|
+
let chunk;
|
|
1196
|
+
try {
|
|
1197
|
+
chunk = await Promise.race([
|
|
1198
|
+
reader.read(),
|
|
1199
|
+
new Promise((_, reject) => {
|
|
1200
|
+
timer = setTimeout(() => reject(new errors_1.SmolError("TIMEOUT", `cloud GET ${route} stalled for ${CLOUD_TIMEOUT_MS}ms`)), CLOUD_TIMEOUT_MS);
|
|
1201
|
+
}),
|
|
1202
|
+
]);
|
|
1203
|
+
}
|
|
1204
|
+
finally {
|
|
1205
|
+
if (timer !== undefined)
|
|
1206
|
+
clearTimeout(timer);
|
|
1207
|
+
}
|
|
1208
|
+
if (chunk.done)
|
|
1209
|
+
return;
|
|
1210
|
+
if (chunk.value.length > 0)
|
|
1211
|
+
yield chunk.value;
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
finally {
|
|
1215
|
+
// `break`, a thrown error, or a stalled read must close the HTTP body.
|
|
1216
|
+
await reader.cancel().catch(() => { });
|
|
1217
|
+
reader.releaseLock();
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1137
1220
|
async writeFile(path, data, mode) {
|
|
1221
|
+
checkCloudFileSize(path, data.length);
|
|
1138
1222
|
await cloudFetch(this.conn, "PUT", `/v1/machines/${this.id}/files/${encodePath(path)}`, {
|
|
1139
1223
|
body: data,
|
|
1224
|
+
timeoutMs: transferTimeoutMs(data.length),
|
|
1140
1225
|
});
|
|
1141
1226
|
// The cloud /files PUT carries no file mode, so apply it with chmod when
|
|
1142
1227
|
// requested — e.g. writing an executable script the caller then runs.
|
|
@@ -1458,8 +1543,6 @@ async function makeTransport(config, conn) {
|
|
|
1458
1543
|
// run the workload as the image's default user.
|
|
1459
1544
|
if (config.user !== undefined)
|
|
1460
1545
|
throw new errors_1.NotSupportedError("user is local-only.");
|
|
1461
|
-
if (config.waitForPorts === false || conn.waitForPorts === false)
|
|
1462
|
-
throw new errors_1.NotSupportedError("waitForPorts: false is local-only.");
|
|
1463
1546
|
// Cloud is settled: NOW the CLI's stored login may supply the credential
|
|
1464
1547
|
// and endpoint, which is the reuse `smol auth login` promises.
|
|
1465
1548
|
const { apiKey: cliKey, endpoint: cliUrl } = cliSession(conn.target);
|
|
@@ -1563,7 +1646,7 @@ async function makeTransport(config, conn) {
|
|
|
1563
1646
|
startError = e instanceof Error ? e.message : String(e);
|
|
1564
1647
|
}
|
|
1565
1648
|
try {
|
|
1566
|
-
await waitForReady(cloudConn, id);
|
|
1649
|
+
await waitForReady(cloudConn, id, 120_000, 1_000, (config.waitForPorts ?? conn.waitForPorts) !== false);
|
|
1567
1650
|
}
|
|
1568
1651
|
catch (e) {
|
|
1569
1652
|
if (startError !== undefined && e instanceof errors_1.SmolError)
|
|
@@ -1589,6 +1672,9 @@ async function makeTransport(config, conn) {
|
|
|
1589
1672
|
// Validate the config before loading the engine, so a config the local
|
|
1590
1673
|
// target cannot honour fails the same way whether or not it is installed.
|
|
1591
1674
|
const nativeConfig = toNativeConfig(name, config);
|
|
1675
|
+
if (nativeConfig.image) {
|
|
1676
|
+
nativeConfig.image = await (0, native_1.getNapiMachine)().prepareImage(nativeConfig.image);
|
|
1677
|
+
}
|
|
1592
1678
|
const inner = new ((0, native_1.getNapiMachine)())(nativeConfig);
|
|
1593
1679
|
// A detached machine is nobody's to stop on a signal — it was created to
|
|
1594
1680
|
// survive this process — and its branches inherit that.
|
|
@@ -1715,9 +1801,10 @@ async function connectTransport(id, conn) {
|
|
|
1715
1801
|
const transport = new LocalTransport(conn.egressInterceptor
|
|
1716
1802
|
? (0, native_1.getNapiMachine)().connect(id, conn.egressInterceptor.address, conn.egressInterceptor.token)
|
|
1717
1803
|
: (0, native_1.getNapiMachine)().connect(id), false, conn.egressInterceptor, conn.handleSignals ?? true);
|
|
1718
|
-
//
|
|
1719
|
-
//
|
|
1720
|
-
|
|
1804
|
+
// Frozen and durably paused machines are intentionally not agent-ready.
|
|
1805
|
+
// A paused machine must be resumed explicitly before it can serve work.
|
|
1806
|
+
const state = await transport.state();
|
|
1807
|
+
if (state !== "frozen" && state !== "paused" && state !== "pausing" && conn.waitForPorts !== false) {
|
|
1721
1808
|
await transport.waitUntilReady();
|
|
1722
1809
|
}
|
|
1723
1810
|
return transport;
|
|
@@ -1728,8 +1815,6 @@ async function connectTransport(id, conn) {
|
|
|
1728
1815
|
}
|
|
1729
1816
|
if (conn.egressInterceptor)
|
|
1730
1817
|
throw new errors_1.NotSupportedError("egressInterceptor is local-only.");
|
|
1731
|
-
if (conn.waitForPorts === false)
|
|
1732
|
-
throw new errors_1.NotSupportedError("waitForPorts: false is local-only.");
|
|
1733
1818
|
// As in makeTransport: the CLI-login fallback applies only once the cloud
|
|
1734
1819
|
// target is already selected.
|
|
1735
1820
|
const { apiKey: cliKey, endpoint: cliUrl } = cliSession(conn.target);
|
|
@@ -1760,7 +1845,11 @@ async function connectTransport(id, conn) {
|
|
|
1760
1845
|
throw e;
|
|
1761
1846
|
m = hit;
|
|
1762
1847
|
}
|
|
1763
|
-
|
|
1848
|
+
const resolvedId = m.id ?? id;
|
|
1849
|
+
if (conn.waitForPorts === false && m.state !== "frozen") {
|
|
1850
|
+
await waitForReady(cloudConn, resolvedId, 120_000, 1_000, false);
|
|
1851
|
+
}
|
|
1852
|
+
return new CloudTransport(cloudConn, m.name ?? id, resolvedId);
|
|
1764
1853
|
}
|
|
1765
1854
|
/** Restore a durable cloud checkpoint into a new machine, then return only once
|
|
1766
1855
|
* the restored guest is ready. */
|
package/dist/types.d.ts
CHANGED
|
@@ -207,11 +207,11 @@ export interface MachineConfig {
|
|
|
207
207
|
* allocates the host port; readiness includes that published port accepting
|
|
208
208
|
* connections. */
|
|
209
209
|
ports?: PortSpec[];
|
|
210
|
-
/**
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
* Default true
|
|
210
|
+
/** Return from `Machine.create()` after the guest agent is ready, even if a
|
|
211
|
+
* published port is not accepting connections yet. Use this when `exec()`
|
|
212
|
+
* starts the service: the default port wait would deadlock before `exec()`
|
|
213
|
+
* can run. Call `machine.waitUntilReady()` after starting it.
|
|
214
|
+
* Default true. On cloud, false probes in-guest execution before returning. */
|
|
215
215
|
waitForPorts?: boolean;
|
|
216
216
|
/** Resource allocation. */
|
|
217
217
|
resources?: ResourceSpec;
|
|
@@ -497,11 +497,10 @@ export interface PortableCheckpointInfo {
|
|
|
497
497
|
}
|
|
498
498
|
/** Selects and configures the backend. Local (embedded) is the default. */
|
|
499
499
|
export interface ConnectOptions {
|
|
500
|
-
/**
|
|
501
|
-
*
|
|
502
|
-
*
|
|
503
|
-
*
|
|
504
|
-
* on `MachineConfig` instead. */
|
|
500
|
+
/** On local machines, allow `Machine.connect()` to return without waiting
|
|
501
|
+
* for published ports. On cloud, false waits for successful in-guest exec
|
|
502
|
+
* without requiring a published listener; the default connection only
|
|
503
|
+
* attaches to the existing machine. For `create`, set this on MachineConfig. */
|
|
505
504
|
waitForPorts?: boolean;
|
|
506
505
|
/** Binding for a stopped local machine that requires intercepted egress. */
|
|
507
506
|
egressInterceptor?: EgressInterceptor;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "smolmachines",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.27.0",
|
|
4
4
|
"description": "Embed isolated microVM sandboxes directly in your code — no server to run.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "smol-machines",
|
|
@@ -51,8 +51,9 @@
|
|
|
51
51
|
"test:e2e": "napi build --platform --js binding.js --dts binding.d.ts && tsx test/e2e.ts",
|
|
52
52
|
"test:staged": "tsx test/staged-mount-e2e.ts",
|
|
53
53
|
"test:agent-ready": "tsx test/local-agent-ready.ts",
|
|
54
|
+
"test:paused-connect": "tsx test/local-paused-connect.ts",
|
|
54
55
|
"gen:openapi": "bash scripts/gen-openapi.sh",
|
|
55
|
-
"test:cloud": "tsx test/cloud-mock.ts",
|
|
56
|
+
"test:cloud": "tsx test/cloud-mock.ts && tsx test/cloud-deferred-ports.ts",
|
|
56
57
|
"test:unit": "tsx test/unit.ts"
|
|
57
58
|
},
|
|
58
59
|
"devDependencies": {
|
|
@@ -63,8 +64,8 @@
|
|
|
63
64
|
"typescript": "^5.6.0"
|
|
64
65
|
},
|
|
65
66
|
"optionalDependencies": {
|
|
66
|
-
"smolmachines-darwin-arm64": "1.
|
|
67
|
-
"smolmachines-linux-x64-gnu": "1.
|
|
68
|
-
"smolmachines-linux-arm64-gnu": "1.
|
|
67
|
+
"smolmachines-darwin-arm64": "1.27.0",
|
|
68
|
+
"smolmachines-linux-x64-gnu": "1.27.0",
|
|
69
|
+
"smolmachines-linux-arm64-gnu": "1.27.0"
|
|
69
70
|
}
|
|
70
71
|
}
|