@watasu/sdk 0.1.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.
@@ -0,0 +1,93 @@
1
+ import { ConnectionConfig } from './connectionConfig.js';
2
+ import { DataPlaneClient } from './transport.js';
3
+ import { ProcessFrame, ProcessSocket } from './processSocket.js';
4
+ import { SandboxError } from './errors.js';
5
+ export interface CommandResult {
6
+ /** Process exit code. Zero means success. */
7
+ exitCode: number;
8
+ error?: string;
9
+ stdout: string;
10
+ stderr: string;
11
+ }
12
+ /** Error thrown by `CommandHandle.wait()` when a process exits non-zero. */
13
+ export declare class CommandExitError extends SandboxError implements CommandResult {
14
+ private readonly result;
15
+ constructor(result: CommandResult);
16
+ get exitCode(): number;
17
+ get error(): string | undefined;
18
+ get stdout(): string;
19
+ get stderr(): string;
20
+ }
21
+ export interface ProcessInfo {
22
+ pid: number | string;
23
+ tag?: string;
24
+ cmd?: string;
25
+ args: string[];
26
+ envs: Record<string, string>;
27
+ cwd?: string;
28
+ }
29
+ export interface CommandStartOpts {
30
+ /** Return a `CommandHandle` immediately instead of waiting for exit. */
31
+ background?: boolean;
32
+ cwd?: string;
33
+ user?: string;
34
+ envs?: Record<string, string>;
35
+ onStdout?: (data: string) => void | Promise<void>;
36
+ onStderr?: (data: string) => void | Promise<void>;
37
+ stdin?: boolean;
38
+ timeoutMs?: number;
39
+ requestTimeoutMs?: number;
40
+ }
41
+ /** Live handle for one sandbox process stream. */
42
+ export declare class CommandHandle implements Partial<CommandResult> {
43
+ readonly pid: number | string;
44
+ private readonly socket;
45
+ private readonly handleKill;
46
+ private readonly events;
47
+ private readonly onStdout?;
48
+ private readonly onStderr?;
49
+ private _stdout;
50
+ private _stderr;
51
+ private result?;
52
+ private readonly pending;
53
+ constructor(pid: number | string, socket: ProcessSocket, handleKill: () => Promise<boolean>, events: AsyncIterable<ProcessFrame>, onStdout?: ((data: string) => void | Promise<void>) | undefined, onStderr?: ((data: string) => void | Promise<void>) | undefined);
54
+ get stdout(): string;
55
+ get stderr(): string;
56
+ get exitCode(): number | undefined;
57
+ get error(): string | undefined;
58
+ /** Wait until the process exits and return captured output. */
59
+ wait(): Promise<CommandResult>;
60
+ /** Kill the process. */
61
+ kill(): Promise<boolean>;
62
+ /** Send stdin bytes or text to the process. */
63
+ sendStdin(data: string | Uint8Array): Promise<void>;
64
+ /** Detach the local stream without killing the process. */
65
+ disconnect(): void;
66
+ private handleEvents;
67
+ }
68
+ /** Command runner for a sandbox data-plane session. */
69
+ export declare class Commands {
70
+ private readonly dataPlane;
71
+ private readonly config;
72
+ private readonly sandboxEnvs;
73
+ constructor(dataPlane: DataPlaneClient, config: ConnectionConfig, sandboxEnvs?: Record<string, string>);
74
+ /** List processes currently known by the sandbox runtime. */
75
+ list(opts?: {
76
+ requestTimeoutMs?: number;
77
+ }): Promise<ProcessInfo[]>;
78
+ /** Send SIGKILL to a process by pid. */
79
+ kill(pid: number | string, opts?: {
80
+ requestTimeoutMs?: number;
81
+ }): Promise<boolean>;
82
+ /** Attach to a process and send stdin bytes or text. */
83
+ sendStdin(pid: number | string, data: string | Uint8Array, opts?: {
84
+ requestTimeoutMs?: number;
85
+ }): Promise<void>;
86
+ run(cmd: string, opts: CommandStartOpts & {
87
+ background: true;
88
+ }): Promise<CommandHandle>;
89
+ run(cmd: string, opts?: CommandStartOpts): Promise<CommandResult>;
90
+ /** Reconnect to a live process stream by pid. */
91
+ connect(pid: number | string, opts?: CommandStartOpts): Promise<CommandHandle>;
92
+ private start;
93
+ }
@@ -0,0 +1,198 @@
1
+ import { ProcessSocket, base64DecodeText } from './processSocket.js';
2
+ import { SandboxError } from './errors.js';
3
+ /** Error thrown by `CommandHandle.wait()` when a process exits non-zero. */
4
+ export class CommandExitError extends SandboxError {
5
+ result;
6
+ constructor(result) {
7
+ super(result.error ?? `Command exited with code ${result.exitCode}`);
8
+ this.result = result;
9
+ this.name = 'CommandExitError';
10
+ }
11
+ get exitCode() { return this.result.exitCode; }
12
+ get error() { return this.result.error; }
13
+ get stdout() { return this.result.stdout; }
14
+ get stderr() { return this.result.stderr; }
15
+ }
16
+ /** Live handle for one sandbox process stream. */
17
+ export class CommandHandle {
18
+ pid;
19
+ socket;
20
+ handleKill;
21
+ events;
22
+ onStdout;
23
+ onStderr;
24
+ _stdout = '';
25
+ _stderr = '';
26
+ result;
27
+ pending;
28
+ constructor(pid, socket, handleKill, events, onStdout, onStderr) {
29
+ this.pid = pid;
30
+ this.socket = socket;
31
+ this.handleKill = handleKill;
32
+ this.events = events;
33
+ this.onStdout = onStdout;
34
+ this.onStderr = onStderr;
35
+ this.pending = this.handleEvents();
36
+ }
37
+ get stdout() { return this._stdout; }
38
+ get stderr() { return this._stderr; }
39
+ get exitCode() { return this.result?.exitCode; }
40
+ get error() { return this.result?.error; }
41
+ /** Wait until the process exits and return captured output. */
42
+ async wait() {
43
+ await this.pending;
44
+ if (!this.result)
45
+ throw new SandboxError('Command ended without an exit event');
46
+ if (this.result.exitCode !== 0)
47
+ throw new CommandExitError(this.result);
48
+ return this.result;
49
+ }
50
+ /** Kill the process. */
51
+ async kill() {
52
+ return this.handleKill();
53
+ }
54
+ /** Send stdin bytes or text to the process. */
55
+ async sendStdin(data) {
56
+ this.socket.sendStdin(data);
57
+ }
58
+ /** Detach the local stream without killing the process. */
59
+ disconnect() {
60
+ this.socket.close();
61
+ }
62
+ async handleEvents() {
63
+ try {
64
+ for await (const frame of this.events) {
65
+ const type = frame.type;
66
+ if (type === 'started' || type === 'ready' || type === 'pong')
67
+ continue;
68
+ if (type === 'stdout') {
69
+ const out = base64DecodeText(frame.data);
70
+ this._stdout += out;
71
+ await this.onStdout?.(out);
72
+ }
73
+ else if (type === 'stderr') {
74
+ const out = base64DecodeText(frame.data);
75
+ this._stderr += out;
76
+ await this.onStderr?.(out);
77
+ }
78
+ else if (type === 'exit') {
79
+ this.result = {
80
+ exitCode: Number(frame.exit_code ?? frame.exitCode ?? 0),
81
+ error: typeof frame.error === 'string' ? frame.error : undefined,
82
+ stdout: this._stdout,
83
+ stderr: this._stderr,
84
+ };
85
+ return;
86
+ }
87
+ else if (type === 'error') {
88
+ throw new SandboxError(String(frame.message ?? frame.code ?? 'process error'));
89
+ }
90
+ }
91
+ }
92
+ finally {
93
+ this.socket.close();
94
+ }
95
+ }
96
+ }
97
+ /** Command runner for a sandbox data-plane session. */
98
+ export class Commands {
99
+ dataPlane;
100
+ config;
101
+ sandboxEnvs;
102
+ constructor(dataPlane, config, sandboxEnvs = {}) {
103
+ this.dataPlane = dataPlane;
104
+ this.config = config;
105
+ this.sandboxEnvs = sandboxEnvs;
106
+ }
107
+ /** List processes currently known by the sandbox runtime. */
108
+ async list(opts = {}) {
109
+ const payload = await this.dataPlane.getJson('/runtime/v1/process', opts);
110
+ const processes = Array.isArray(payload.processes) ? payload.processes : [];
111
+ return processes.map((item) => processInfo(item));
112
+ }
113
+ /** Send SIGKILL to a process by pid. */
114
+ async kill(pid, opts = {}) {
115
+ await this.dataPlane.postJson(`/runtime/v1/process/${pid}/signal`, {
116
+ ...opts,
117
+ json: { signal: 'SIGKILL' },
118
+ });
119
+ return true;
120
+ }
121
+ /** Attach to a process and send stdin bytes or text. */
122
+ async sendStdin(pid, data, opts = {}) {
123
+ const handle = await this.connect(pid, opts);
124
+ try {
125
+ await handle.sendStdin(data);
126
+ }
127
+ finally {
128
+ handle.disconnect();
129
+ }
130
+ }
131
+ /** Run a shell command over the WebSocket process runtime. */
132
+ async run(cmd, opts = {}) {
133
+ const handle = await this.start(cmd, opts);
134
+ if (opts.background)
135
+ return handle;
136
+ return handle.wait();
137
+ }
138
+ /** Reconnect to a live process stream by pid. */
139
+ async connect(pid, opts = {}) {
140
+ const socket = await new ProcessSocket(this.dataPlane.baseUrl, this.dataPlane.token, `/runtime/v1/process/${pid}/connect?since=0`, opts.requestTimeoutMs ?? this.config.requestTimeoutMs).connect();
141
+ const first = await nextStarted(socket);
142
+ const actualPid = framePid(first) ?? pid;
143
+ return new CommandHandle(actualPid, socket, () => this.kill(actualPid), socket, opts.onStdout, opts.onStderr);
144
+ }
145
+ async start(cmd, opts) {
146
+ const socket = await new ProcessSocket(this.dataPlane.baseUrl, this.dataPlane.token, '/runtime/v1/process', opts.requestTimeoutMs ?? this.config.requestTimeoutMs).connect();
147
+ const environment = { ...this.sandboxEnvs, ...(opts.envs ?? {}) };
148
+ socket.sendJson({
149
+ type: 'start',
150
+ cmd: '/bin/bash',
151
+ args: ['-l', '-c', cmd],
152
+ cwd: opts.cwd,
153
+ user: opts.user,
154
+ environment,
155
+ envs: environment,
156
+ stdin: opts.stdin ?? false,
157
+ timeout_ms: opts.timeoutMs ?? 60_000,
158
+ });
159
+ const first = await nextStarted(socket);
160
+ const pid = framePid(first);
161
+ if (pid === undefined)
162
+ throw new SandboxError('process started frame did not include pid');
163
+ return new CommandHandle(pid, socket, () => this.kill(pid), withFirst(first, socket), opts.onStdout, opts.onStderr);
164
+ }
165
+ }
166
+ async function nextStarted(events) {
167
+ for await (const frame of events) {
168
+ if (frame.type === 'started')
169
+ return frame;
170
+ }
171
+ throw new SandboxError('process ended before started frame');
172
+ }
173
+ async function* withFirst(first, rest) {
174
+ yield first;
175
+ yield* rest;
176
+ }
177
+ function framePid(frame) {
178
+ const process = frame.process && typeof frame.process === 'object' ? frame.process : {};
179
+ const pid = frame.pid ?? process.pid ?? process.id;
180
+ return typeof pid === 'number' || typeof pid === 'string' ? pid : undefined;
181
+ }
182
+ function processInfo(value) {
183
+ const item = value && typeof value === 'object' ? value : {};
184
+ const process = item.process && typeof item.process === 'object' ? item.process : item;
185
+ return {
186
+ pid: framePid(process) ?? '',
187
+ tag: typeof process.tag === 'string' ? process.tag : undefined,
188
+ cmd: typeof process.cmd === 'string' ? process.cmd : undefined,
189
+ args: Array.isArray(process.args) ? process.args.map(String) : [],
190
+ envs: recordOfStrings(process.envs ?? process.environment),
191
+ cwd: typeof process.cwd === 'string' ? process.cwd : undefined,
192
+ };
193
+ }
194
+ function recordOfStrings(value) {
195
+ if (!value || typeof value !== 'object')
196
+ return {};
197
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, String(item)]));
198
+ }
@@ -0,0 +1,25 @@
1
+ export declare const KEEPALIVE_PING_INTERVAL_SEC = 50;
2
+ export declare const SESSION_OPERATION_REQUEST_TIMEOUT_MS = 150000;
3
+ /** Connection options accepted by Watasu SDK entrypoints. */
4
+ export interface ConnectionOpts {
5
+ apiKey?: string;
6
+ domain?: string;
7
+ apiUrl?: string;
8
+ dataPlaneDomain?: string;
9
+ requestTimeoutMs?: number;
10
+ headers?: Record<string, string>;
11
+ proxy?: unknown;
12
+ }
13
+ /** Resolved connection settings used by control-plane and data-plane clients. */
14
+ export declare class ConnectionConfig {
15
+ readonly apiKey?: string;
16
+ readonly domain: string;
17
+ readonly apiUrl: string;
18
+ readonly dataPlaneDomain: string;
19
+ readonly requestTimeoutMs: number;
20
+ readonly headers: Record<string, string>;
21
+ readonly proxy?: unknown;
22
+ constructor(opts?: ConnectionOpts);
23
+ /** HTTP headers including the configured bearer token. */
24
+ get authHeaders(): Record<string, string>;
25
+ }
@@ -0,0 +1,32 @@
1
+ export const KEEPALIVE_PING_INTERVAL_SEC = 50;
2
+ export const SESSION_OPERATION_REQUEST_TIMEOUT_MS = 150_000;
3
+ /** Resolved connection settings used by control-plane and data-plane clients. */
4
+ export class ConnectionConfig {
5
+ apiKey;
6
+ domain;
7
+ apiUrl;
8
+ dataPlaneDomain;
9
+ requestTimeoutMs;
10
+ headers;
11
+ proxy;
12
+ constructor(opts = {}) {
13
+ const env = typeof process !== 'undefined' ? process.env : {};
14
+ this.apiKey = opts.apiKey ?? env.WATASU_API_KEY;
15
+ this.domain = opts.domain ?? env.WATASU_DOMAIN ?? 'watasu.io';
16
+ this.apiUrl =
17
+ opts.apiUrl ?? env.WATASU_API_URL ?? `https://api.${this.domain}/v1`;
18
+ this.dataPlaneDomain =
19
+ opts.dataPlaneDomain ??
20
+ env.WATASU_DATA_PLANE_DOMAIN ??
21
+ 'watasuhost.com';
22
+ this.requestTimeoutMs = opts.requestTimeoutMs ?? 60_000;
23
+ this.headers = opts.headers ?? {};
24
+ this.proxy = opts.proxy;
25
+ }
26
+ /** HTTP headers including the configured bearer token. */
27
+ get authHeaders() {
28
+ return this.apiKey
29
+ ? { ...this.headers, Authorization: `Bearer ${this.apiKey}` }
30
+ : { ...this.headers };
31
+ }
32
+ }
@@ -0,0 +1,34 @@
1
+ export declare class SandboxError extends Error {
2
+ constructor(message?: string);
3
+ }
4
+ export declare class AuthenticationError extends SandboxError {
5
+ constructor(message?: string);
6
+ }
7
+ export declare class NotFoundError extends SandboxError {
8
+ constructor(message?: string);
9
+ }
10
+ export declare class TimeoutError extends SandboxError {
11
+ constructor(message?: string);
12
+ }
13
+ export declare class InvalidArgumentError extends SandboxError {
14
+ constructor(message?: string);
15
+ }
16
+ export declare class RateLimitError extends SandboxError {
17
+ constructor(message?: string);
18
+ }
19
+ export declare class NotEnoughSpaceError extends SandboxError {
20
+ constructor(message?: string);
21
+ }
22
+ export declare class FileNotFoundError extends NotFoundError {
23
+ constructor(message?: string);
24
+ }
25
+ export declare function unsupported(feature: string): never;
26
+ export declare class NotImplementedError extends Error {
27
+ constructor(message: string);
28
+ }
29
+ export declare class ApiError extends SandboxError {
30
+ readonly status: number;
31
+ readonly code?: string | undefined;
32
+ constructor(message: string, status: number, code?: string | undefined);
33
+ }
34
+ export declare function errorFromResponse(status: number, payload: unknown): Error;
package/dist/errors.js ADDED
@@ -0,0 +1,99 @@
1
+ export class SandboxError extends Error {
2
+ constructor(message = 'Sandbox error') {
3
+ super(message);
4
+ this.name = 'SandboxError';
5
+ }
6
+ }
7
+ export class AuthenticationError extends SandboxError {
8
+ constructor(message = 'Authentication failed') {
9
+ super(message);
10
+ this.name = 'AuthenticationError';
11
+ }
12
+ }
13
+ export class NotFoundError extends SandboxError {
14
+ constructor(message = 'Not found') {
15
+ super(message);
16
+ this.name = 'NotFoundError';
17
+ }
18
+ }
19
+ export class TimeoutError extends SandboxError {
20
+ constructor(message = 'Request timed out') {
21
+ super(message);
22
+ this.name = 'TimeoutError';
23
+ }
24
+ }
25
+ export class InvalidArgumentError extends SandboxError {
26
+ constructor(message = 'Invalid argument') {
27
+ super(message);
28
+ this.name = 'InvalidArgumentError';
29
+ }
30
+ }
31
+ export class RateLimitError extends SandboxError {
32
+ constructor(message = 'Rate limit exceeded') {
33
+ super(message);
34
+ this.name = 'RateLimitError';
35
+ }
36
+ }
37
+ export class NotEnoughSpaceError extends SandboxError {
38
+ constructor(message = 'Not enough space') {
39
+ super(message);
40
+ this.name = 'NotEnoughSpaceError';
41
+ }
42
+ }
43
+ export class FileNotFoundError extends NotFoundError {
44
+ constructor(message = 'File not found') {
45
+ super(message);
46
+ this.name = 'FileNotFoundError';
47
+ }
48
+ }
49
+ export function unsupported(feature) {
50
+ throw new NotImplementedError(`${feature} is not supported by Watasu yet`);
51
+ }
52
+ export class NotImplementedError extends Error {
53
+ constructor(message) {
54
+ super(message);
55
+ this.name = 'NotImplementedError';
56
+ }
57
+ }
58
+ export class ApiError extends SandboxError {
59
+ status;
60
+ code;
61
+ constructor(message, status, code) {
62
+ super(message);
63
+ this.status = status;
64
+ this.code = code;
65
+ this.name = 'ApiError';
66
+ }
67
+ }
68
+ export function errorFromResponse(status, payload) {
69
+ const body = asRecord(payload);
70
+ const code = stringValue(body.error);
71
+ const message = stringValue(body.message) ||
72
+ listMessage(body.errors) ||
73
+ code ||
74
+ `Request failed with status ${status}`;
75
+ if (status === 401 || status === 403)
76
+ return new AuthenticationError(message);
77
+ if (status === 404)
78
+ return new NotFoundError(message);
79
+ if (status === 408 || status === 504)
80
+ return new TimeoutError(message);
81
+ if (status === 422 || status === 400)
82
+ return new InvalidArgumentError(message);
83
+ if (status === 429)
84
+ return new RateLimitError(message);
85
+ if (code === 'not_enough_space')
86
+ return new NotEnoughSpaceError(message);
87
+ if (code === 'file_not_found')
88
+ return new FileNotFoundError(message);
89
+ return new ApiError(message, status, code);
90
+ }
91
+ function asRecord(value) {
92
+ return value && typeof value === 'object' ? value : {};
93
+ }
94
+ function stringValue(value) {
95
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
96
+ }
97
+ function listMessage(value) {
98
+ return Array.isArray(value) ? value.map(String).join(', ') : undefined;
99
+ }
@@ -0,0 +1,65 @@
1
+ import { DataPlaneClient } from './transport.js';
2
+ export declare enum FileType {
3
+ /** Regular file. */
4
+ FILE = "file",
5
+ /** Directory. */
6
+ DIR = "dir",
7
+ /** Symbolic link. */
8
+ SYMLINK = "symlink"
9
+ }
10
+ /** Metadata for one sandbox filesystem entry. */
11
+ export interface EntryInfo {
12
+ name: string;
13
+ type: FileType | string;
14
+ path: string;
15
+ size?: number;
16
+ mode?: number;
17
+ uid?: number;
18
+ gid?: number;
19
+ mtime?: number;
20
+ metadata?: Record<string, string>;
21
+ }
22
+ export type WriteInfo = EntryInfo;
23
+ /** Filesystem helper for a sandbox data-plane session. */
24
+ export declare class Filesystem {
25
+ private readonly dataPlane;
26
+ constructor(dataPlane: DataPlaneClient);
27
+ /** Read a file as UTF-8 text, bytes, or a one-chunk async byte stream. */
28
+ read(path: string, opts?: {
29
+ format?: 'text' | 'bytes' | 'stream';
30
+ requestTimeoutMs?: number;
31
+ gzip?: boolean;
32
+ }): Promise<string | Uint8Array | AsyncIterable<Uint8Array>>;
33
+ /** Write UTF-8 text or bytes to a file. */
34
+ write(path: string, data: string | Uint8Array, opts?: {
35
+ requestTimeoutMs?: number;
36
+ gzip?: boolean;
37
+ metadata?: Record<string, string>;
38
+ }): Promise<WriteInfo>;
39
+ /** List directory entries below `path`. */
40
+ list(path: string, opts?: {
41
+ requestTimeoutMs?: number;
42
+ depth?: number;
43
+ }): Promise<EntryInfo[]>;
44
+ /** Return whether a file or directory exists at `path`. */
45
+ exists(path: string, opts?: {
46
+ requestTimeoutMs?: number;
47
+ }): Promise<boolean>;
48
+ /** Return stat metadata for `path`. */
49
+ getInfo(path: string, opts?: {
50
+ requestTimeoutMs?: number;
51
+ }): Promise<EntryInfo>;
52
+ /** Remove a file at `path`. */
53
+ remove(path: string, opts?: {
54
+ requestTimeoutMs?: number;
55
+ }): Promise<void>;
56
+ /** Move or rename a file. */
57
+ rename(oldPath: string, newPath: string, opts?: {
58
+ requestTimeoutMs?: number;
59
+ }): Promise<EntryInfo>;
60
+ /** Create a directory. */
61
+ makeDir(path: string, opts?: {
62
+ requestTimeoutMs?: number;
63
+ }): Promise<boolean>;
64
+ watchDir(): never;
65
+ }
@@ -0,0 +1,98 @@
1
+ import { withQuery } from './transport.js';
2
+ import { FileNotFoundError, unsupported } from './errors.js';
3
+ export var FileType;
4
+ (function (FileType) {
5
+ /** Regular file. */
6
+ FileType["FILE"] = "file";
7
+ /** Directory. */
8
+ FileType["DIR"] = "dir";
9
+ /** Symbolic link. */
10
+ FileType["SYMLINK"] = "symlink";
11
+ })(FileType || (FileType = {}));
12
+ /** Filesystem helper for a sandbox data-plane session. */
13
+ export class Filesystem {
14
+ dataPlane;
15
+ constructor(dataPlane) {
16
+ this.dataPlane = dataPlane;
17
+ }
18
+ /** Read a file as UTF-8 text, bytes, or a one-chunk async byte stream. */
19
+ async read(path, opts = {}) {
20
+ const bytes = await this.dataPlane.getBytes(withQuery('/runtime/v1/files', { path, gzip: opts.gzip }), opts);
21
+ if (opts.format === 'bytes')
22
+ return bytes;
23
+ if (opts.format === 'stream')
24
+ return (async function* () { yield bytes; })();
25
+ return new TextDecoder().decode(bytes);
26
+ }
27
+ /** Write UTF-8 text or bytes to a file. */
28
+ async write(path, data, opts = {}) {
29
+ const body = typeof data === 'string' ? new TextEncoder().encode(data) : data;
30
+ const payload = await this.dataPlane.putJson(withQuery('/runtime/v1/files', { path, gzip: opts.gzip }), body, opts);
31
+ return entryInfo(payload.file ?? payload);
32
+ }
33
+ /** List directory entries below `path`. */
34
+ async list(path, opts = {}) {
35
+ const payload = await this.dataPlane.getJson(withQuery('/runtime/v1/directories', { path, depth: opts.depth }), opts);
36
+ const entries = Array.isArray(payload.entries) ? payload.entries : [];
37
+ return entries.map(entryInfo);
38
+ }
39
+ /** Return whether a file or directory exists at `path`. */
40
+ async exists(path, opts = {}) {
41
+ try {
42
+ await this.getInfo(path, opts);
43
+ return true;
44
+ }
45
+ catch (error) {
46
+ if (error instanceof FileNotFoundError)
47
+ return false;
48
+ throw error;
49
+ }
50
+ }
51
+ /** Return stat metadata for `path`. */
52
+ async getInfo(path, opts = {}) {
53
+ const payload = await this.dataPlane.getJson(withQuery('/runtime/v1/files/stat', { path }), opts);
54
+ return entryInfo(payload.file ?? payload.entry ?? payload);
55
+ }
56
+ /** Remove a file at `path`. */
57
+ async remove(path, opts = {}) {
58
+ await this.dataPlane.deleteJson(withQuery('/runtime/v1/files', { path }), opts);
59
+ }
60
+ /** Move or rename a file. */
61
+ async rename(oldPath, newPath, opts = {}) {
62
+ const payload = await this.dataPlane.postJson('/runtime/v1/files/move', {
63
+ ...opts,
64
+ json: { from_path: oldPath, to_path: newPath },
65
+ });
66
+ return entryInfo(payload.file ?? payload);
67
+ }
68
+ /** Create a directory. */
69
+ async makeDir(path, opts = {}) {
70
+ await this.dataPlane.postJson(withQuery('/runtime/v1/directories', { path }), opts);
71
+ return true;
72
+ }
73
+ watchDir() {
74
+ unsupported('sandbox.files.watchDir');
75
+ }
76
+ }
77
+ function entryInfo(value) {
78
+ const item = value && typeof value === 'object' ? value : {};
79
+ return {
80
+ name: String(item.name ?? ''),
81
+ type: String(item.type ?? FileType.FILE),
82
+ path: String(item.path ?? ''),
83
+ size: numberValue(item.bytes ?? item.size),
84
+ mode: numberValue(item.mode),
85
+ uid: numberValue(item.uid),
86
+ gid: numberValue(item.gid),
87
+ mtime: numberValue(item.mtime),
88
+ metadata: recordOfStrings(item.metadata),
89
+ };
90
+ }
91
+ function numberValue(value) {
92
+ return typeof value === 'number' ? value : undefined;
93
+ }
94
+ function recordOfStrings(value) {
95
+ if (!value || typeof value !== 'object')
96
+ return undefined;
97
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, String(item)]));
98
+ }
@@ -0,0 +1,9 @@
1
+ export { ApiError, AuthenticationError, FileNotFoundError, InvalidArgumentError, NotEnoughSpaceError, NotFoundError, NotImplementedError, RateLimitError, SandboxError, TimeoutError, } from './errors.js';
2
+ export { ConnectionConfig, KEEPALIVE_PING_INTERVAL_SEC } from './connectionConfig.js';
3
+ export { Sandbox } from './sandbox.js';
4
+ export type { SandboxCreateOpts, SandboxConnectOpts, SandboxInfo } from './sandbox.js';
5
+ export { CommandExitError, CommandHandle, Commands } from './commands.js';
6
+ export type { CommandResult, CommandStartOpts, ProcessInfo } from './commands.js';
7
+ export { FileType, Filesystem } from './filesystem.js';
8
+ export type { EntryInfo, WriteInfo } from './filesystem.js';
9
+ export { ProcessSocket, base64DecodeText, base64Encode } from './processSocket.js';
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { ApiError, AuthenticationError, FileNotFoundError, InvalidArgumentError, NotEnoughSpaceError, NotFoundError, NotImplementedError, RateLimitError, SandboxError, TimeoutError, } from './errors.js';
2
+ export { ConnectionConfig, KEEPALIVE_PING_INTERVAL_SEC } from './connectionConfig.js';
3
+ export { Sandbox } from './sandbox.js';
4
+ export { CommandExitError, CommandHandle, Commands } from './commands.js';
5
+ export { FileType, Filesystem } from './filesystem.js';
6
+ export { ProcessSocket, base64DecodeText, base64Encode } from './processSocket.js';
@@ -0,0 +1,25 @@
1
+ export type ProcessFrame = Record<string, unknown>;
2
+ /** Streaming WebSocket connection to the sandbox process runtime. */
3
+ export declare class ProcessSocket implements AsyncIterable<ProcessFrame> {
4
+ private readonly baseUrl;
5
+ private readonly token;
6
+ private readonly path;
7
+ private readonly requestTimeoutMs;
8
+ private ws?;
9
+ private queue;
10
+ private waiters;
11
+ private closed;
12
+ private keepalive?;
13
+ constructor(baseUrl: string, token: string, path: string, requestTimeoutMs?: number);
14
+ connect(): Promise<this>;
15
+ sendJson(payload: ProcessFrame): void;
16
+ sendStdin(data: string | Uint8Array): void;
17
+ close(): void;
18
+ [Symbol.asyncIterator](): AsyncIterator<ProcessFrame>;
19
+ private next;
20
+ private onMessage;
21
+ private finish;
22
+ private flushDone;
23
+ }
24
+ export declare function base64Encode(bytes: Uint8Array): string;
25
+ export declare function base64DecodeText(value: unknown): string;
@@ -0,0 +1,143 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import WebSocket from 'ws';
3
+ import { KEEPALIVE_PING_INTERVAL_SEC } from './connectionConfig.js';
4
+ import { SandboxError, TimeoutError } from './errors.js';
5
+ /** Streaming WebSocket connection to the sandbox process runtime. */
6
+ export class ProcessSocket {
7
+ baseUrl;
8
+ token;
9
+ path;
10
+ requestTimeoutMs;
11
+ ws;
12
+ queue = [];
13
+ waiters = [];
14
+ closed = false;
15
+ keepalive;
16
+ constructor(baseUrl, token, path, requestTimeoutMs = 60_000) {
17
+ this.baseUrl = baseUrl;
18
+ this.token = token;
19
+ this.path = path;
20
+ this.requestTimeoutMs = requestTimeoutMs;
21
+ }
22
+ async connect() {
23
+ const ws = new WebSocket(wsUrl(this.baseUrl, this.path), {
24
+ headers: { Authorization: `Bearer ${this.token}` },
25
+ });
26
+ this.ws = ws;
27
+ ws.on('message', (data) => this.onMessage(data));
28
+ ws.on('close', () => this.finish());
29
+ ws.on('error', () => this.finish(new SandboxError('process websocket failed')));
30
+ await new Promise((resolve, reject) => {
31
+ const timeout = setTimeout(() => reject(new TimeoutError()), this.requestTimeoutMs);
32
+ ws.once('open', () => {
33
+ clearTimeout(timeout);
34
+ resolve();
35
+ });
36
+ ws.once('error', () => {
37
+ clearTimeout(timeout);
38
+ reject(new SandboxError('process websocket failed to connect'));
39
+ });
40
+ });
41
+ this.keepalive = setInterval(() => {
42
+ if (this.ws?.readyState === WebSocket.OPEN) {
43
+ this.ws.ping('watasu-sdk');
44
+ }
45
+ }, KEEPALIVE_PING_INTERVAL_SEC * 1000);
46
+ return this;
47
+ }
48
+ sendJson(payload) {
49
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
50
+ throw new SandboxError('process websocket is not connected');
51
+ }
52
+ this.ws.send(JSON.stringify(payload));
53
+ }
54
+ sendStdin(data) {
55
+ const raw = typeof data === 'string' ? new TextEncoder().encode(data) : data;
56
+ this.sendJson({ type: 'stdin', data: base64Encode(raw) });
57
+ }
58
+ close() {
59
+ this.closed = true;
60
+ if (this.keepalive)
61
+ clearInterval(this.keepalive);
62
+ this.ws?.close();
63
+ this.flushDone();
64
+ }
65
+ [Symbol.asyncIterator]() {
66
+ return {
67
+ next: () => this.next(),
68
+ };
69
+ }
70
+ next() {
71
+ if (this.queue.length > 0) {
72
+ return Promise.resolve({ done: false, value: this.queue.shift() });
73
+ }
74
+ if (this.closed)
75
+ return Promise.resolve({ done: true, value: undefined });
76
+ return new Promise((resolve) => this.waiters.push(resolve));
77
+ }
78
+ onMessage(message) {
79
+ const text = rawDataToText(message);
80
+ try {
81
+ const frame = JSON.parse(text);
82
+ if (frame.type === 'pong' || frame.type === 'ready')
83
+ return;
84
+ if (frame.type === 'error') {
85
+ this.finish(new SandboxError(String(frame.message ?? frame.code ?? 'process error')));
86
+ return;
87
+ }
88
+ const waiter = this.waiters.shift();
89
+ if (waiter)
90
+ waiter({ done: false, value: frame });
91
+ else
92
+ this.queue.push(frame);
93
+ }
94
+ catch (error) {
95
+ this.finish(error instanceof Error ? error : new SandboxError(String(error)));
96
+ }
97
+ }
98
+ finish(error) {
99
+ this.closed = true;
100
+ if (this.keepalive)
101
+ clearInterval(this.keepalive);
102
+ if (error) {
103
+ const frame = { type: 'error', message: error.message };
104
+ const waiter = this.waiters.shift();
105
+ if (waiter)
106
+ waiter({ done: false, value: frame });
107
+ else
108
+ this.queue.push(frame);
109
+ }
110
+ this.flushDone();
111
+ }
112
+ flushDone() {
113
+ for (const waiter of this.waiters.splice(0)) {
114
+ waiter({ done: true, value: undefined });
115
+ }
116
+ }
117
+ }
118
+ export function base64Encode(bytes) {
119
+ return Buffer.from(bytes).toString('base64');
120
+ }
121
+ export function base64DecodeText(value) {
122
+ if (typeof value !== 'string')
123
+ return String(value ?? '');
124
+ try {
125
+ return Buffer.from(value, 'base64').toString('utf8');
126
+ }
127
+ catch {
128
+ return value;
129
+ }
130
+ }
131
+ function rawDataToText(message) {
132
+ if (typeof message === 'string')
133
+ return message;
134
+ if (Array.isArray(message))
135
+ return Buffer.concat(message).toString('utf8');
136
+ if (message instanceof ArrayBuffer)
137
+ return Buffer.from(new Uint8Array(message)).toString('utf8');
138
+ return Buffer.from(message).toString('utf8');
139
+ }
140
+ function wsUrl(baseUrl, path) {
141
+ const url = new URL(path, baseUrl.replace(/^http/, 'ws'));
142
+ return url.toString();
143
+ }
@@ -0,0 +1,91 @@
1
+ import { Commands } from './commands.js';
2
+ import { ConnectionConfig, ConnectionOpts } from './connectionConfig.js';
3
+ import { ControlClient } from './transport.js';
4
+ import { Filesystem } from './filesystem.js';
5
+ export interface SandboxCreateOpts extends ConnectionOpts {
6
+ /** Template slug to create. Defaults to "base". */
7
+ template?: string;
8
+ /** Sandbox lifetime in milliseconds. Defaults to five minutes. */
9
+ timeoutMs?: number;
10
+ metadata?: Record<string, string>;
11
+ envs?: Record<string, string>;
12
+ secure?: boolean;
13
+ allowInternetAccess?: boolean;
14
+ templateVersionId?: number | string;
15
+ team?: string;
16
+ cpu?: number;
17
+ memoryMb?: number;
18
+ diskMb?: number;
19
+ networkClass?: string;
20
+ allowPackageRegistryAccess?: boolean;
21
+ exposedPorts?: unknown[];
22
+ mcp?: unknown;
23
+ volumeMounts?: unknown;
24
+ }
25
+ export interface SandboxConnectOpts extends ConnectionOpts {
26
+ /** Optional new sandbox lifetime in milliseconds. */
27
+ timeoutMs?: number;
28
+ }
29
+ export interface SandboxInfo {
30
+ sandboxId: string;
31
+ templateVersionId?: number;
32
+ state?: string;
33
+ metadata: Record<string, string>;
34
+ startedAt?: string;
35
+ endAt?: string;
36
+ }
37
+ /** Running Watasu sandbox with ready `files` and `commands` helpers. */
38
+ export declare class Sandbox {
39
+ /** Default template slug used when create is called without a template. */
40
+ static readonly defaultTemplate = "base";
41
+ files: Filesystem;
42
+ commands: Commands;
43
+ readonly sandboxId: string;
44
+ readonly pty: {
45
+ create: () => never;
46
+ };
47
+ readonly git: {
48
+ clone: () => never;
49
+ };
50
+ private readonly config;
51
+ private readonly control;
52
+ private dataPlane;
53
+ private sandbox;
54
+ constructor(opts: {
55
+ sandboxId: string;
56
+ connectionConfig: ConnectionConfig;
57
+ control?: ControlClient;
58
+ session: unknown;
59
+ sandbox?: Record<string, unknown>;
60
+ envs?: Record<string, string>;
61
+ });
62
+ static create(opts?: SandboxCreateOpts): Promise<Sandbox>;
63
+ static create(template: string, opts?: SandboxCreateOpts): Promise<Sandbox>;
64
+ /** Connect to an existing sandbox and return it with a fresh data-plane session. */
65
+ static connect(sandboxId: string, opts?: SandboxConnectOpts): Promise<Sandbox>;
66
+ /** Refresh this sandbox's data-plane session in place. */
67
+ connect(opts?: SandboxConnectOpts): Promise<this>;
68
+ /** Destroy a sandbox by id. */
69
+ static kill(sandboxId: string, opts?: ConnectionOpts): Promise<boolean>;
70
+ /** Destroy this sandbox. */
71
+ kill(): Promise<boolean>;
72
+ /** Set a sandbox's lifetime by id. */
73
+ static setTimeout(sandboxId: string, timeoutMs: number, opts?: ConnectionOpts): Promise<void>;
74
+ /** Set this sandbox's lifetime. */
75
+ setTimeout(timeoutMs: number): Promise<void>;
76
+ /** Fetch control-plane metadata for a sandbox by id. */
77
+ static getInfo(sandboxId: string, opts?: ConnectionOpts): Promise<SandboxInfo>;
78
+ /** Fetch the latest control-plane metadata for this sandbox. */
79
+ getInfo(): Promise<SandboxInfo>;
80
+ /** List sandboxes visible to the configured API key. */
81
+ static list(opts?: ConnectionOpts & {
82
+ team?: string;
83
+ }): Promise<SandboxInfo[]>;
84
+ /** Return the public hostname for an exposed sandbox port. */
85
+ getHost(port: number): Promise<string>;
86
+ pause(): never;
87
+ resume(): never;
88
+ createSnapshot(): never;
89
+ checkpoint(): never;
90
+ restore(): never;
91
+ }
@@ -0,0 +1,204 @@
1
+ import { Commands } from './commands.js';
2
+ import { ConnectionConfig, SESSION_OPERATION_REQUEST_TIMEOUT_MS } from './connectionConfig.js';
3
+ import { DataPlaneClient, ControlClient } from './transport.js';
4
+ import { SandboxError, unsupported } from './errors.js';
5
+ import { Filesystem } from './filesystem.js';
6
+ /** Running Watasu sandbox with ready `files` and `commands` helpers. */
7
+ export class Sandbox {
8
+ /** Default template slug used when create is called without a template. */
9
+ static defaultTemplate = 'base';
10
+ files;
11
+ commands;
12
+ sandboxId;
13
+ pty = { create: () => unsupported('sandbox.pty') };
14
+ git = { clone: () => unsupported('sandbox.git') };
15
+ config;
16
+ control;
17
+ dataPlane;
18
+ sandbox;
19
+ constructor(opts) {
20
+ this.sandboxId = String(opts.sandboxId);
21
+ this.config = opts.connectionConfig;
22
+ this.control = opts.control ?? new ControlClient(this.config);
23
+ this.sandbox = opts.sandbox ?? {};
24
+ const dataPlane = dataPlaneFromSession(opts.session, this.config);
25
+ this.dataPlane = dataPlane;
26
+ this.files = new Filesystem(dataPlane);
27
+ this.commands = new Commands(dataPlane, this.config, opts.envs ?? {});
28
+ }
29
+ /** Create a sandbox and return it only after the API supplies a data-plane session. */
30
+ static async create(templateOrOpts, opts = {}) {
31
+ const template = typeof templateOrOpts === 'string'
32
+ ? templateOrOpts
33
+ : templateOrOpts?.template ?? Sandbox.defaultTemplate;
34
+ const sandboxOpts = typeof templateOrOpts === 'string' ? opts : templateOrOpts ?? {};
35
+ if (sandboxOpts.mcp !== undefined)
36
+ unsupported('mcp');
37
+ if (sandboxOpts.volumeMounts !== undefined)
38
+ unsupported('volumeMounts');
39
+ const config = new ConnectionConfig(sandboxOpts);
40
+ const control = new ControlClient(config);
41
+ const sandboxPayload = {
42
+ template,
43
+ timeout_seconds: Math.ceil((sandboxOpts.timeoutMs ?? 300_000) / 1000),
44
+ metadata: sandboxOpts.metadata ?? {},
45
+ allow_internet_access: sandboxOpts.allowInternetAccess ?? true,
46
+ };
47
+ putIfPresent(sandboxPayload, 'template_version_id', sandboxOpts.templateVersionId);
48
+ putIfPresent(sandboxPayload, 'team', sandboxOpts.team);
49
+ putIfPresent(sandboxPayload, 'cpu', sandboxOpts.cpu);
50
+ putIfPresent(sandboxPayload, 'memory_mb', sandboxOpts.memoryMb);
51
+ putIfPresent(sandboxPayload, 'disk_mb', sandboxOpts.diskMb);
52
+ putIfPresent(sandboxPayload, 'network_class', sandboxOpts.networkClass);
53
+ putIfPresent(sandboxPayload, 'allow_package_registry_access', sandboxOpts.allowPackageRegistryAccess);
54
+ putIfPresent(sandboxPayload, 'exposed_ports', sandboxOpts.exposedPorts);
55
+ const response = await control.post('/sandboxes', {
56
+ json: { sandbox: sandboxPayload },
57
+ requestTimeoutMs: sessionOperationRequestTimeout(config, sandboxOpts),
58
+ });
59
+ const sandbox = record(response.sandbox ?? response);
60
+ const sandboxId = sandbox.id ?? sandbox.sandbox_id;
61
+ if (sandboxId === undefined)
62
+ throw new SandboxError('create response did not include sandbox id');
63
+ return new Sandbox({
64
+ sandboxId: String(sandboxId),
65
+ connectionConfig: config,
66
+ control,
67
+ session: response.session,
68
+ sandbox,
69
+ envs: sandboxOpts.envs,
70
+ });
71
+ }
72
+ /** Connect to an existing sandbox and return it with a fresh data-plane session. */
73
+ static async connect(sandboxId, opts = {}) {
74
+ const config = new ConnectionConfig(opts);
75
+ const control = new ControlClient(config);
76
+ const info = await control.get(`/sandboxes/${sandboxId}`);
77
+ const response = await control.post(`/sandboxes/${sandboxId}/connect`, {
78
+ json: { connect: opts.timeoutMs ? { timeout_seconds: Math.ceil(opts.timeoutMs / 1000) } : {} },
79
+ requestTimeoutMs: sessionOperationRequestTimeout(config, opts),
80
+ });
81
+ return new Sandbox({
82
+ sandboxId,
83
+ connectionConfig: config,
84
+ control,
85
+ session: response.session,
86
+ sandbox: record(response.sandbox ?? info.sandbox ?? {}),
87
+ });
88
+ }
89
+ /** Refresh this sandbox's data-plane session in place. */
90
+ async connect(opts = {}) {
91
+ const response = await this.control.post(`/sandboxes/${this.sandboxId}/connect`, {
92
+ json: { connect: opts.timeoutMs ? { timeout_seconds: Math.ceil(opts.timeoutMs / 1000) } : {} },
93
+ requestTimeoutMs: sessionOperationRequestTimeout(this.config, opts),
94
+ });
95
+ this.sandbox = record(response.sandbox ?? this.sandbox);
96
+ const dataPlane = dataPlaneFromSession(response.session, this.config);
97
+ this.dataPlane = dataPlane;
98
+ this.files = new Filesystem(dataPlane);
99
+ this.commands = new Commands(dataPlane, this.config);
100
+ return this;
101
+ }
102
+ /** Destroy a sandbox by id. */
103
+ static async kill(sandboxId, opts = {}) {
104
+ const control = new ControlClient(new ConnectionConfig(opts));
105
+ await control.delete(`/sandboxes/${sandboxId}`);
106
+ return true;
107
+ }
108
+ /** Destroy this sandbox. */
109
+ async kill() {
110
+ await this.control.delete(`/sandboxes/${this.sandboxId}`);
111
+ return true;
112
+ }
113
+ /** Set a sandbox's lifetime by id. */
114
+ static async setTimeout(sandboxId, timeoutMs, opts = {}) {
115
+ const control = new ControlClient(new ConnectionConfig(opts));
116
+ await control.patch(`/sandboxes/${sandboxId}`, {
117
+ json: { sandbox: { timeout_seconds: Math.ceil(timeoutMs / 1000) } },
118
+ });
119
+ }
120
+ /** Set this sandbox's lifetime. */
121
+ async setTimeout(timeoutMs) {
122
+ await this.control.patch(`/sandboxes/${this.sandboxId}`, {
123
+ json: { sandbox: { timeout_seconds: Math.ceil(timeoutMs / 1000) } },
124
+ });
125
+ }
126
+ /** Fetch control-plane metadata for a sandbox by id. */
127
+ static async getInfo(sandboxId, opts = {}) {
128
+ const control = new ControlClient(new ConnectionConfig(opts));
129
+ const payload = await control.get(`/sandboxes/${sandboxId}`);
130
+ return sandboxInfo(record(payload.sandbox ?? payload));
131
+ }
132
+ /** Fetch the latest control-plane metadata for this sandbox. */
133
+ async getInfo() {
134
+ const payload = await this.control.get(`/sandboxes/${this.sandboxId}`);
135
+ return sandboxInfo(record(payload.sandbox ?? payload));
136
+ }
137
+ /** List sandboxes visible to the configured API key. */
138
+ static async list(opts = {}) {
139
+ const control = new ControlClient(new ConnectionConfig(opts));
140
+ const payload = await control.get(opts.team ? `/sandboxes?team=${encodeURIComponent(opts.team)}` : '/sandboxes');
141
+ const sandboxes = Array.isArray(payload.sandboxes) ? payload.sandboxes : [];
142
+ return sandboxes.map((item) => sandboxInfo(record(item)));
143
+ }
144
+ /** Return the public hostname for an exposed sandbox port. */
145
+ async getHost(port) {
146
+ const payload = await this.control.get(`/sandboxes/${this.sandboxId}/ports/${port}`);
147
+ const portInfo = record(payload.sandbox_port ?? payload.port ?? payload);
148
+ const value = portInfo.host ?? portInfo.url;
149
+ if (typeof value === 'string')
150
+ return hostOnly(value);
151
+ const routeToken = this.sandbox.route_token;
152
+ if (typeof routeToken !== 'string')
153
+ throw new SandboxError('port response did not include host or url');
154
+ return `p${port}-${routeToken}.sandbox.${this.config.dataPlaneDomain}`;
155
+ }
156
+ pause() { unsupported('Sandbox.pause'); }
157
+ resume() { unsupported('Sandbox.resume'); }
158
+ createSnapshot() { unsupported('Sandbox.createSnapshot'); }
159
+ checkpoint() { unsupported('Sandbox.checkpoint'); }
160
+ restore() { unsupported('Sandbox.restore'); }
161
+ }
162
+ function dataPlaneFromSession(session, config) {
163
+ const item = record(session);
164
+ const token = item.token ?? item.access_token;
165
+ const url = item.data_plane_url;
166
+ if (!session)
167
+ throw new SandboxError('sandbox session is required for data-plane operations');
168
+ if (typeof token !== 'string' || typeof url !== 'string') {
169
+ throw new SandboxError('sandbox session did not include data_plane_url and token');
170
+ }
171
+ return new DataPlaneClient(url, token, config);
172
+ }
173
+ function sessionOperationRequestTimeout(config, opts) {
174
+ if (opts.requestTimeoutMs !== undefined)
175
+ return opts.requestTimeoutMs;
176
+ return Math.max(config.requestTimeoutMs, SESSION_OPERATION_REQUEST_TIMEOUT_MS);
177
+ }
178
+ function sandboxInfo(payload) {
179
+ return {
180
+ sandboxId: String(payload.id ?? payload.sandbox_id ?? ''),
181
+ templateVersionId: typeof payload.template_version_id === 'number' ? payload.template_version_id : undefined,
182
+ state: typeof payload.state === 'string' ? payload.state : undefined,
183
+ metadata: recordOfStrings(payload.metadata),
184
+ startedAt: typeof payload.created_at === 'string' ? payload.created_at : undefined,
185
+ endAt: typeof payload.deadline_at === 'string' ? payload.deadline_at : undefined,
186
+ };
187
+ }
188
+ function putIfPresent(target, key, value) {
189
+ if (value !== undefined && value !== null)
190
+ target[key] = value;
191
+ }
192
+ function record(value) {
193
+ return value && typeof value === 'object' ? value : {};
194
+ }
195
+ function recordOfStrings(value) {
196
+ if (!value || typeof value !== 'object')
197
+ return {};
198
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, String(item)]));
199
+ }
200
+ function hostOnly(value) {
201
+ if (value.includes('://'))
202
+ return new URL(value).host;
203
+ return value.split('/')[0];
204
+ }
@@ -0,0 +1,33 @@
1
+ import { ConnectionConfig } from './connectionConfig.js';
2
+ type JsonRecord = Record<string, unknown>;
3
+ export declare class ControlClient {
4
+ private readonly config;
5
+ constructor(config: ConnectionConfig);
6
+ get(path: string, opts?: RequestOpts): Promise<JsonRecord>;
7
+ post(path: string, opts?: RequestOpts): Promise<JsonRecord>;
8
+ patch(path: string, opts?: RequestOpts): Promise<JsonRecord>;
9
+ delete(path: string, opts?: RequestOpts): Promise<JsonRecord>;
10
+ private request;
11
+ }
12
+ export declare class DataPlaneClient {
13
+ readonly baseUrl: string;
14
+ readonly token: string;
15
+ private readonly config;
16
+ constructor(baseUrl: string, token: string, config: ConnectionConfig);
17
+ getJson(path: string, opts?: RequestOpts): Promise<JsonRecord>;
18
+ postJson(path: string, opts?: RequestOpts): Promise<JsonRecord>;
19
+ deleteJson(path: string, opts?: RequestOpts): Promise<JsonRecord>;
20
+ getBytes(path: string, opts?: RequestOpts): Promise<Uint8Array>;
21
+ putJson(path: string, data: BodyInit | Uint8Array, opts?: RequestOpts): Promise<JsonRecord>;
22
+ raw(path: string, opts: RequestOpts): Promise<Response>;
23
+ private request;
24
+ }
25
+ export interface RequestOpts {
26
+ method?: string;
27
+ json?: unknown;
28
+ body?: BodyInit | Uint8Array;
29
+ headers?: Record<string, string>;
30
+ requestTimeoutMs?: number;
31
+ }
32
+ export declare function withQuery(path: string, params: Record<string, string | number | boolean | undefined>): string;
33
+ export {};
@@ -0,0 +1,122 @@
1
+ import { AuthenticationError, TimeoutError, errorFromResponse } from './errors.js';
2
+ export class ControlClient {
3
+ config;
4
+ constructor(config) {
5
+ this.config = config;
6
+ }
7
+ get(path, opts = {}) {
8
+ return this.request(path, { ...opts, method: 'GET' });
9
+ }
10
+ post(path, opts = {}) {
11
+ return this.request(path, { ...opts, method: 'POST' });
12
+ }
13
+ patch(path, opts = {}) {
14
+ return this.request(path, { ...opts, method: 'PATCH' });
15
+ }
16
+ delete(path, opts = {}) {
17
+ return this.request(path, { ...opts, method: 'DELETE' });
18
+ }
19
+ async request(path, opts) {
20
+ if (!this.config.apiKey) {
21
+ throw new AuthenticationError('WATASU_API_KEY is required');
22
+ }
23
+ const response = await fetchWithTimeout(joinUrl(this.config.apiUrl, path), {
24
+ method: opts.method,
25
+ headers: {
26
+ ...this.config.authHeaders,
27
+ ...(opts.json ? { 'content-type': 'application/json' } : {}),
28
+ },
29
+ body: opts.json ? JSON.stringify(opts.json) : undefined,
30
+ }, opts.requestTimeoutMs ?? this.config.requestTimeoutMs);
31
+ return parseJsonResponse(response);
32
+ }
33
+ }
34
+ export class DataPlaneClient {
35
+ baseUrl;
36
+ token;
37
+ config;
38
+ constructor(baseUrl, token, config) {
39
+ this.baseUrl = baseUrl;
40
+ this.token = token;
41
+ this.config = config;
42
+ }
43
+ getJson(path, opts = {}) {
44
+ return this.request(path, { ...opts, method: 'GET' });
45
+ }
46
+ postJson(path, opts = {}) {
47
+ return this.request(path, { ...opts, method: 'POST' });
48
+ }
49
+ deleteJson(path, opts = {}) {
50
+ return this.request(path, { ...opts, method: 'DELETE' });
51
+ }
52
+ async getBytes(path, opts = {}) {
53
+ const response = await this.raw(path, { ...opts, method: 'GET' });
54
+ return new Uint8Array(await response.arrayBuffer());
55
+ }
56
+ async putJson(path, data, opts = {}) {
57
+ return parseJsonResponse(await this.raw(path, {
58
+ ...opts,
59
+ method: 'PUT',
60
+ body: data,
61
+ headers: { 'content-type': 'application/octet-stream', ...(opts.headers ?? {}) },
62
+ }));
63
+ }
64
+ async raw(path, opts) {
65
+ const response = await fetchWithTimeout(joinUrl(this.baseUrl, path), {
66
+ method: opts.method,
67
+ headers: {
68
+ Authorization: `Bearer ${this.token}`,
69
+ ...(opts.json ? { 'content-type': 'application/json' } : {}),
70
+ ...(opts.headers ?? {}),
71
+ },
72
+ body: opts.json ? JSON.stringify(opts.json) : opts.body,
73
+ }, opts.requestTimeoutMs ?? this.config.requestTimeoutMs);
74
+ if (!response.ok) {
75
+ throw errorFromResponse(response.status, await readJsonOrText(response));
76
+ }
77
+ return response;
78
+ }
79
+ async request(path, opts) {
80
+ return parseJsonResponse(await this.raw(path, opts));
81
+ }
82
+ }
83
+ export function withQuery(path, params) {
84
+ const url = new URL(path, 'https://placeholder.local');
85
+ for (const [key, value] of Object.entries(params)) {
86
+ if (value !== undefined)
87
+ url.searchParams.set(key, String(value));
88
+ }
89
+ return `${url.pathname}${url.search}`;
90
+ }
91
+ async function parseJsonResponse(response) {
92
+ const payload = await readJsonOrText(response);
93
+ if (!response.ok) {
94
+ throw errorFromResponse(response.status, payload);
95
+ }
96
+ return payload && typeof payload === 'object' ? payload : {};
97
+ }
98
+ async function readJsonOrText(response) {
99
+ const text = await response.text();
100
+ if (!text)
101
+ return {};
102
+ try {
103
+ return JSON.parse(text);
104
+ }
105
+ catch {
106
+ return { message: text };
107
+ }
108
+ }
109
+ function fetchWithTimeout(url, init, timeoutMs) {
110
+ const controller = new AbortController();
111
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
112
+ return fetch(url, { ...init, signal: controller.signal }).catch((error) => {
113
+ if (error?.name === 'AbortError')
114
+ throw new TimeoutError();
115
+ throw error;
116
+ }).finally(() => clearTimeout(timeout));
117
+ }
118
+ function joinUrl(base, path) {
119
+ const normalizedBase = base.replace(/\/+$/, '');
120
+ const normalizedPath = path.startsWith('/') ? path : `/${path}`;
121
+ return `${normalizedBase}${normalizedPath}`;
122
+ }
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@watasu/sdk",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "license": "MIT OR Apache-2.0",
6
+ "description": "TypeScript SDK for Watasu sandboxes",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js"
14
+ }
15
+ },
16
+ "types": "./dist/index.d.ts",
17
+ "files": [
18
+ "dist"
19
+ ],
20
+ "scripts": {
21
+ "build": "tsc -p tsconfig.json",
22
+ "test": "npm run build && node --test test/*.mjs"
23
+ },
24
+ "dependencies": {
25
+ "ws": "^8.18.3"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^24.0.3",
29
+ "@types/ws": "^8.18.1",
30
+ "typescript": "^5.7.0"
31
+ }
32
+ }