@treesap/sandbox 0.2.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/CHANGELOG.md +107 -0
- package/README.md +495 -0
- package/dist/api-server.d.ts +41 -0
- package/dist/api-server.d.ts.map +1 -0
- package/dist/api-server.js +536 -0
- package/dist/api-server.js.map +1 -0
- package/dist/auth-middleware.d.ts +31 -0
- package/dist/auth-middleware.d.ts.map +1 -0
- package/dist/auth-middleware.js +35 -0
- package/dist/auth-middleware.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +65 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +137 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +412 -0
- package/dist/client.js.map +1 -0
- package/dist/file-service.d.ts +94 -0
- package/dist/file-service.d.ts.map +1 -0
- package/dist/file-service.js +203 -0
- package/dist/file-service.js.map +1 -0
- package/dist/http-exposure-service.d.ts +71 -0
- package/dist/http-exposure-service.d.ts.map +1 -0
- package/dist/http-exposure-service.js +172 -0
- package/dist/http-exposure-service.js.map +1 -0
- package/dist/index.d.ts +59 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +66 -0
- package/dist/index.js.map +1 -0
- package/dist/sandbox-manager.d.ts +76 -0
- package/dist/sandbox-manager.d.ts.map +1 -0
- package/dist/sandbox-manager.js +161 -0
- package/dist/sandbox-manager.js.map +1 -0
- package/dist/sandbox.d.ts +118 -0
- package/dist/sandbox.d.ts.map +1 -0
- package/dist/sandbox.js +303 -0
- package/dist/sandbox.js.map +1 -0
- package/dist/server.d.ts +7 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +240 -0
- package/dist/server.js.map +1 -0
- package/dist/stream-service.d.ts +35 -0
- package/dist/stream-service.d.ts.map +1 -0
- package/dist/stream-service.js +136 -0
- package/dist/stream-service.js.map +1 -0
- package/dist/terminal.d.ts +46 -0
- package/dist/terminal.d.ts.map +1 -0
- package/dist/terminal.js +264 -0
- package/dist/terminal.js.map +1 -0
- package/dist/websocket.d.ts +48 -0
- package/dist/websocket.d.ts.map +1 -0
- package/dist/websocket.js +332 -0
- package/dist/websocket.js.map +1 -0
- package/package.json +59 -0
- package/src/api-server.ts +658 -0
- package/src/auth-middleware.ts +65 -0
- package/src/cli.ts +71 -0
- package/src/client.ts +537 -0
- package/src/file-service.ts +273 -0
- package/src/http-exposure-service.ts +232 -0
- package/src/index.ts +101 -0
- package/src/sandbox-manager.ts +202 -0
- package/src/sandbox.ts +396 -0
- package/src/stream-service.ts +174 -0
- package/tsconfig.json +37 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import * as fs from 'fs/promises';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { Readable } from 'stream';
|
|
4
|
+
import { createReadStream, createWriteStream } from 'fs';
|
|
5
|
+
|
|
6
|
+
export interface FileInfo {
|
|
7
|
+
name: string;
|
|
8
|
+
path: string;
|
|
9
|
+
type: 'file' | 'directory';
|
|
10
|
+
size?: number;
|
|
11
|
+
mtime?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ListFilesOptions {
|
|
15
|
+
recursive?: boolean;
|
|
16
|
+
pattern?: string;
|
|
17
|
+
includeHidden?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ReadFileOptions {
|
|
21
|
+
encoding?: BufferEncoding;
|
|
22
|
+
raw?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface WriteFileOptions {
|
|
26
|
+
encoding?: BufferEncoding;
|
|
27
|
+
mode?: number;
|
|
28
|
+
createDirs?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Service for managing file operations within a sandbox
|
|
33
|
+
* Includes path validation to prevent directory traversal attacks
|
|
34
|
+
*/
|
|
35
|
+
export class FileService {
|
|
36
|
+
private basePath: string;
|
|
37
|
+
|
|
38
|
+
constructor(basePath: string) {
|
|
39
|
+
this.basePath = path.resolve(basePath);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Validate and resolve a path within the sandbox
|
|
44
|
+
* Prevents directory traversal attacks
|
|
45
|
+
*/
|
|
46
|
+
private resolvePath(filePath: string): string {
|
|
47
|
+
const resolved = path.resolve(this.basePath, filePath);
|
|
48
|
+
|
|
49
|
+
// Ensure the resolved path is within basePath
|
|
50
|
+
if (!resolved.startsWith(this.basePath)) {
|
|
51
|
+
throw new Error('Path traversal detected - access denied');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return resolved;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Read a file's contents
|
|
59
|
+
*/
|
|
60
|
+
async readFile(filePath: string, options: ReadFileOptions = {}): Promise<string | Buffer> {
|
|
61
|
+
const resolved = this.resolvePath(filePath);
|
|
62
|
+
|
|
63
|
+
if (options.raw) {
|
|
64
|
+
return await fs.readFile(resolved);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const encoding = options.encoding || 'utf-8';
|
|
68
|
+
return await fs.readFile(resolved, encoding);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Write content to a file
|
|
73
|
+
*/
|
|
74
|
+
async writeFile(
|
|
75
|
+
filePath: string,
|
|
76
|
+
content: string | Buffer,
|
|
77
|
+
options: WriteFileOptions = {}
|
|
78
|
+
): Promise<void> {
|
|
79
|
+
const resolved = this.resolvePath(filePath);
|
|
80
|
+
|
|
81
|
+
// Create parent directories if requested
|
|
82
|
+
if (options.createDirs) {
|
|
83
|
+
const dir = path.dirname(resolved);
|
|
84
|
+
await fs.mkdir(dir, { recursive: true });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const encoding = options.encoding || 'utf-8';
|
|
88
|
+
const writeOptions: any = { encoding };
|
|
89
|
+
|
|
90
|
+
if (options.mode) {
|
|
91
|
+
writeOptions.mode = options.mode;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
await fs.writeFile(resolved, content, writeOptions);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* List files in a directory
|
|
99
|
+
*/
|
|
100
|
+
async listFiles(dirPath: string = '.', options: ListFilesOptions = {}): Promise<FileInfo[]> {
|
|
101
|
+
const resolved = this.resolvePath(dirPath);
|
|
102
|
+
|
|
103
|
+
const files: FileInfo[] = [];
|
|
104
|
+
|
|
105
|
+
const entries = await fs.readdir(resolved, { withFileTypes: true });
|
|
106
|
+
|
|
107
|
+
for (const entry of entries) {
|
|
108
|
+
// Skip hidden files unless requested
|
|
109
|
+
if (!options.includeHidden && entry.name.startsWith('.')) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Apply pattern filter if provided
|
|
114
|
+
if (options.pattern && !this.matchPattern(entry.name, options.pattern)) {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const fullPath = path.join(resolved, entry.name);
|
|
119
|
+
const relativePath = path.relative(this.basePath, fullPath);
|
|
120
|
+
|
|
121
|
+
const fileInfo: FileInfo = {
|
|
122
|
+
name: entry.name,
|
|
123
|
+
path: relativePath,
|
|
124
|
+
type: entry.isDirectory() ? 'directory' : 'file',
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// Get file stats for size and mtime
|
|
128
|
+
try {
|
|
129
|
+
const stats = await fs.stat(fullPath);
|
|
130
|
+
fileInfo.size = stats.size;
|
|
131
|
+
fileInfo.mtime = stats.mtimeMs;
|
|
132
|
+
} catch {
|
|
133
|
+
// Ignore stat errors
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
files.push(fileInfo);
|
|
137
|
+
|
|
138
|
+
// Recursively list subdirectories if requested
|
|
139
|
+
if (options.recursive && entry.isDirectory()) {
|
|
140
|
+
const subFiles = await this.listFiles(relativePath, options);
|
|
141
|
+
files.push(...subFiles);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return files;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Delete a file or directory
|
|
150
|
+
*/
|
|
151
|
+
async deleteFile(filePath: string, options: { recursive?: boolean } = {}): Promise<void> {
|
|
152
|
+
const resolved = this.resolvePath(filePath);
|
|
153
|
+
|
|
154
|
+
const stats = await fs.stat(resolved);
|
|
155
|
+
|
|
156
|
+
if (stats.isDirectory()) {
|
|
157
|
+
await fs.rm(resolved, { recursive: options.recursive, force: true });
|
|
158
|
+
} else {
|
|
159
|
+
await fs.unlink(resolved);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Check if a file or directory exists
|
|
165
|
+
*/
|
|
166
|
+
async exists(filePath: string): Promise<boolean> {
|
|
167
|
+
try {
|
|
168
|
+
const resolved = this.resolvePath(filePath);
|
|
169
|
+
await fs.access(resolved);
|
|
170
|
+
return true;
|
|
171
|
+
} catch {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Get file stats
|
|
178
|
+
*/
|
|
179
|
+
async stat(filePath: string): Promise<FileInfo> {
|
|
180
|
+
const resolved = this.resolvePath(filePath);
|
|
181
|
+
const stats = await fs.stat(resolved);
|
|
182
|
+
const relativePath = path.relative(this.basePath, resolved);
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
name: path.basename(resolved),
|
|
186
|
+
path: relativePath,
|
|
187
|
+
type: stats.isDirectory() ? 'directory' : 'file',
|
|
188
|
+
size: stats.size,
|
|
189
|
+
mtime: stats.mtimeMs,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Create a directory
|
|
195
|
+
*/
|
|
196
|
+
async mkdir(dirPath: string, options: { recursive?: boolean } = {}): Promise<void> {
|
|
197
|
+
const resolved = this.resolvePath(dirPath);
|
|
198
|
+
await fs.mkdir(resolved, { recursive: options.recursive });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Copy a file or directory
|
|
203
|
+
*/
|
|
204
|
+
async copy(
|
|
205
|
+
sourcePath: string,
|
|
206
|
+
destPath: string,
|
|
207
|
+
options: { overwrite?: boolean } = {}
|
|
208
|
+
): Promise<void> {
|
|
209
|
+
const resolvedSource = this.resolvePath(sourcePath);
|
|
210
|
+
const resolvedDest = this.resolvePath(destPath);
|
|
211
|
+
|
|
212
|
+
// Check if destination exists
|
|
213
|
+
if (!options.overwrite) {
|
|
214
|
+
try {
|
|
215
|
+
await fs.access(resolvedDest);
|
|
216
|
+
throw new Error('Destination already exists');
|
|
217
|
+
} catch (err: any) {
|
|
218
|
+
if (err.code !== 'ENOENT') {
|
|
219
|
+
throw err;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
await fs.cp(resolvedSource, resolvedDest, { recursive: true });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Move/rename a file or directory
|
|
229
|
+
*/
|
|
230
|
+
async move(sourcePath: string, destPath: string): Promise<void> {
|
|
231
|
+
const resolvedSource = this.resolvePath(sourcePath);
|
|
232
|
+
const resolvedDest = this.resolvePath(destPath);
|
|
233
|
+
|
|
234
|
+
await fs.rename(resolvedSource, resolvedDest);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Create a readable stream for a file
|
|
239
|
+
*/
|
|
240
|
+
createReadStream(filePath: string): Readable {
|
|
241
|
+
const resolved = this.resolvePath(filePath);
|
|
242
|
+
return createReadStream(resolved);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Create a writable stream for a file
|
|
247
|
+
*/
|
|
248
|
+
createWriteStream(filePath: string): NodeJS.WritableStream {
|
|
249
|
+
const resolved = this.resolvePath(filePath);
|
|
250
|
+
return createWriteStream(resolved);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Simple glob pattern matching
|
|
255
|
+
*/
|
|
256
|
+
private matchPattern(filename: string, pattern: string): boolean {
|
|
257
|
+
// Convert glob pattern to regex
|
|
258
|
+
const regexPattern = pattern
|
|
259
|
+
.replace(/\./g, '\\.')
|
|
260
|
+
.replace(/\*/g, '.*')
|
|
261
|
+
.replace(/\?/g, '.');
|
|
262
|
+
|
|
263
|
+
const regex = new RegExp(`^${regexPattern}$`, 'i');
|
|
264
|
+
return regex.test(filename);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Get the base path of this file service
|
|
269
|
+
*/
|
|
270
|
+
getBasePath(): string {
|
|
271
|
+
return this.basePath;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP Exposure Service
|
|
3
|
+
* Integrates with Caddy Admin API to dynamically expose sandbox ports
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface HttpExposureConfig {
|
|
7
|
+
/**
|
|
8
|
+
* Caddy Admin API URL (default: http://localhost:2019)
|
|
9
|
+
*/
|
|
10
|
+
caddyAdminUrl: string;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Base domain for sandbox subdomains (e.g., sandbox.yourdomain.com)
|
|
14
|
+
*/
|
|
15
|
+
baseDomain: string;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Protocol to use for generated URLs (default: https)
|
|
19
|
+
*/
|
|
20
|
+
protocol: 'http' | 'https';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Host where sandbox ports are accessible (default: localhost)
|
|
24
|
+
* This is the internal address Caddy will proxy to
|
|
25
|
+
*/
|
|
26
|
+
upstreamHost: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ExposedEndpoint {
|
|
30
|
+
sandboxId: string;
|
|
31
|
+
port: number;
|
|
32
|
+
publicUrl: string;
|
|
33
|
+
routeId: string;
|
|
34
|
+
createdAt: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DEFAULT_CONFIG: HttpExposureConfig = {
|
|
38
|
+
caddyAdminUrl: 'http://localhost:2019',
|
|
39
|
+
baseDomain: 'sandbox.localhost',
|
|
40
|
+
protocol: 'https',
|
|
41
|
+
upstreamHost: 'localhost',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Service for managing HTTP exposure of sandbox ports via Caddy
|
|
46
|
+
*/
|
|
47
|
+
export class HttpExposureService {
|
|
48
|
+
private config: HttpExposureConfig;
|
|
49
|
+
private exposures: Map<string, ExposedEndpoint[]> = new Map();
|
|
50
|
+
|
|
51
|
+
constructor(config: Partial<HttpExposureConfig> = {}) {
|
|
52
|
+
this.config = {
|
|
53
|
+
...DEFAULT_CONFIG,
|
|
54
|
+
...config,
|
|
55
|
+
// Allow env vars to override
|
|
56
|
+
caddyAdminUrl: config.caddyAdminUrl || process.env.CADDY_ADMIN_URL || DEFAULT_CONFIG.caddyAdminUrl,
|
|
57
|
+
baseDomain: config.baseDomain || process.env.SANDBOX_DOMAIN || DEFAULT_CONFIG.baseDomain,
|
|
58
|
+
protocol: (config.protocol || process.env.SANDBOX_PROTOCOL || DEFAULT_CONFIG.protocol) as 'http' | 'https',
|
|
59
|
+
upstreamHost: config.upstreamHost || process.env.SANDBOX_UPSTREAM_HOST || DEFAULT_CONFIG.upstreamHost,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Generate a unique subdomain for a sandbox port
|
|
65
|
+
*/
|
|
66
|
+
private generateSubdomain(sandboxId: string, port: number): string {
|
|
67
|
+
// Use first 8 chars of sandbox ID + port for a shorter, readable subdomain
|
|
68
|
+
const shortId = sandboxId.substring(0, 8);
|
|
69
|
+
return `${shortId}-${port}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Generate a unique route ID for Caddy
|
|
74
|
+
*/
|
|
75
|
+
private generateRouteId(sandboxId: string, port: number): string {
|
|
76
|
+
return `sandbox-${sandboxId}-${port}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Expose a sandbox port and return the public URL
|
|
81
|
+
*/
|
|
82
|
+
async expose(sandboxId: string, port: number): Promise<string> {
|
|
83
|
+
const subdomain = this.generateSubdomain(sandboxId, port);
|
|
84
|
+
const routeId = this.generateRouteId(sandboxId, port);
|
|
85
|
+
const hostname = `${subdomain}.${this.config.baseDomain}`;
|
|
86
|
+
const publicUrl = `${this.config.protocol}://${hostname}`;
|
|
87
|
+
|
|
88
|
+
// Check if already exposed
|
|
89
|
+
const existing = this.getExposure(sandboxId, port);
|
|
90
|
+
if (existing) {
|
|
91
|
+
return existing.publicUrl;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Create Caddy route configuration
|
|
95
|
+
const route = {
|
|
96
|
+
'@id': routeId,
|
|
97
|
+
match: [{ host: [hostname] }],
|
|
98
|
+
handle: [
|
|
99
|
+
{
|
|
100
|
+
handler: 'reverse_proxy',
|
|
101
|
+
upstreams: [{ dial: `${this.config.upstreamHost}:${port}` }],
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
// Add route to Caddy via Admin API
|
|
108
|
+
const response = await fetch(
|
|
109
|
+
`${this.config.caddyAdminUrl}/config/apps/http/servers/srv0/routes`,
|
|
110
|
+
{
|
|
111
|
+
method: 'POST',
|
|
112
|
+
headers: { 'Content-Type': 'application/json' },
|
|
113
|
+
body: JSON.stringify(route),
|
|
114
|
+
}
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
if (!response.ok) {
|
|
118
|
+
const errorText = await response.text();
|
|
119
|
+
throw new Error(`Failed to add Caddy route: ${errorText}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Track the exposure
|
|
123
|
+
const endpoint: ExposedEndpoint = {
|
|
124
|
+
sandboxId,
|
|
125
|
+
port,
|
|
126
|
+
publicUrl,
|
|
127
|
+
routeId,
|
|
128
|
+
createdAt: Date.now(),
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const sandboxExposures = this.exposures.get(sandboxId) || [];
|
|
132
|
+
sandboxExposures.push(endpoint);
|
|
133
|
+
this.exposures.set(sandboxId, sandboxExposures);
|
|
134
|
+
|
|
135
|
+
return publicUrl;
|
|
136
|
+
} catch (error: any) {
|
|
137
|
+
// If Caddy is not available, return a placeholder URL for development
|
|
138
|
+
if (error.cause?.code === 'ECONNREFUSED') {
|
|
139
|
+
console.warn(`Caddy not available at ${this.config.caddyAdminUrl}. Using placeholder URL.`);
|
|
140
|
+
|
|
141
|
+
// Still track the exposure locally
|
|
142
|
+
const endpoint: ExposedEndpoint = {
|
|
143
|
+
sandboxId,
|
|
144
|
+
port,
|
|
145
|
+
publicUrl: `http://${this.config.upstreamHost}:${port}`,
|
|
146
|
+
routeId,
|
|
147
|
+
createdAt: Date.now(),
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const sandboxExposures = this.exposures.get(sandboxId) || [];
|
|
151
|
+
sandboxExposures.push(endpoint);
|
|
152
|
+
this.exposures.set(sandboxId, sandboxExposures);
|
|
153
|
+
|
|
154
|
+
return endpoint.publicUrl;
|
|
155
|
+
}
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Remove HTTP exposure for a sandbox port (or all ports if port is undefined)
|
|
162
|
+
*/
|
|
163
|
+
async unexpose(sandboxId: string, port?: number): Promise<void> {
|
|
164
|
+
const endpoints = this.exposures.get(sandboxId) || [];
|
|
165
|
+
const toRemove = port
|
|
166
|
+
? endpoints.filter((e) => e.port === port)
|
|
167
|
+
: endpoints;
|
|
168
|
+
|
|
169
|
+
for (const endpoint of toRemove) {
|
|
170
|
+
try {
|
|
171
|
+
// Remove route from Caddy via Admin API
|
|
172
|
+
const response = await fetch(
|
|
173
|
+
`${this.config.caddyAdminUrl}/id/${endpoint.routeId}`,
|
|
174
|
+
{ method: 'DELETE' }
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// Ignore 404 errors (route doesn't exist)
|
|
178
|
+
if (!response.ok && response.status !== 404) {
|
|
179
|
+
console.warn(`Failed to remove Caddy route ${endpoint.routeId}: ${response.status}`);
|
|
180
|
+
}
|
|
181
|
+
} catch (error: any) {
|
|
182
|
+
// Ignore connection errors (Caddy not available)
|
|
183
|
+
if (error.cause?.code !== 'ECONNREFUSED') {
|
|
184
|
+
console.warn(`Error removing Caddy route: ${error.message}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Update local tracking
|
|
190
|
+
if (port) {
|
|
191
|
+
this.exposures.set(
|
|
192
|
+
sandboxId,
|
|
193
|
+
endpoints.filter((e) => e.port !== port)
|
|
194
|
+
);
|
|
195
|
+
} else {
|
|
196
|
+
this.exposures.delete(sandboxId);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Get all exposures for a sandbox
|
|
202
|
+
*/
|
|
203
|
+
getExposures(sandboxId: string): ExposedEndpoint[] {
|
|
204
|
+
return this.exposures.get(sandboxId) || [];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Get a specific exposure
|
|
209
|
+
*/
|
|
210
|
+
getExposure(sandboxId: string, port: number): ExposedEndpoint | undefined {
|
|
211
|
+
const endpoints = this.exposures.get(sandboxId) || [];
|
|
212
|
+
return endpoints.find((e) => e.port === port);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Get all exposures across all sandboxes
|
|
217
|
+
*/
|
|
218
|
+
getAllExposures(): ExposedEndpoint[] {
|
|
219
|
+
const all: ExposedEndpoint[] = [];
|
|
220
|
+
for (const endpoints of this.exposures.values()) {
|
|
221
|
+
all.push(...endpoints);
|
|
222
|
+
}
|
|
223
|
+
return all;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Clean up all exposures for a sandbox (called when sandbox is destroyed)
|
|
228
|
+
*/
|
|
229
|
+
async cleanup(sandboxId: string): Promise<void> {
|
|
230
|
+
await this.unexpose(sandboxId);
|
|
231
|
+
}
|
|
232
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TreeSap Sandbox v1.0
|
|
3
|
+
*
|
|
4
|
+
* A self-hosted sandbox API for isolated code execution and file management.
|
|
5
|
+
* Provides a Cloudflare-style API for managing sandboxed environments.
|
|
6
|
+
*
|
|
7
|
+
* @example Server usage
|
|
8
|
+
* ```typescript
|
|
9
|
+
* import { startServer } from '@treesap/sandbox';
|
|
10
|
+
*
|
|
11
|
+
* // Start the sandbox server
|
|
12
|
+
* const { server, manager } = await startServer({
|
|
13
|
+
* port: 3000,
|
|
14
|
+
* basePath: './.sandboxes'
|
|
15
|
+
* });
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* @example Client SDK usage
|
|
19
|
+
* ```typescript
|
|
20
|
+
* import { SandboxClient } from '@treesap/sandbox';
|
|
21
|
+
*
|
|
22
|
+
* // Create a new sandbox
|
|
23
|
+
* const sandbox = await SandboxClient.create('http://localhost:3000');
|
|
24
|
+
*
|
|
25
|
+
* // Execute commands
|
|
26
|
+
* const result = await sandbox.exec('npm install');
|
|
27
|
+
* console.log(result.stdout);
|
|
28
|
+
*
|
|
29
|
+
* // File operations
|
|
30
|
+
* await sandbox.writeFile('package.json', JSON.stringify(pkg, null, 2));
|
|
31
|
+
* const files = await sandbox.listFiles();
|
|
32
|
+
*
|
|
33
|
+
* // Process management
|
|
34
|
+
* const server = await sandbox.startProcess('node server.js');
|
|
35
|
+
* const logs = await sandbox.streamProcessLogs(server.id);
|
|
36
|
+
*
|
|
37
|
+
* // Cleanup
|
|
38
|
+
* await sandbox.destroy({ cleanup: true });
|
|
39
|
+
* ```
|
|
40
|
+
*
|
|
41
|
+
* @packageDocumentation
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
// ============================================================================
|
|
45
|
+
// Server API
|
|
46
|
+
// ============================================================================
|
|
47
|
+
|
|
48
|
+
export { startServer, createServer } from './api-server';
|
|
49
|
+
export type { ServerConfig } from './api-server';
|
|
50
|
+
|
|
51
|
+
// ============================================================================
|
|
52
|
+
// Client SDK
|
|
53
|
+
// ============================================================================
|
|
54
|
+
|
|
55
|
+
export { SandboxClient, parseSSEStream } from './client';
|
|
56
|
+
export type { SandboxClientConfig, CreateSandboxResponse } from './client';
|
|
57
|
+
|
|
58
|
+
// ============================================================================
|
|
59
|
+
// Core Components
|
|
60
|
+
// ============================================================================
|
|
61
|
+
|
|
62
|
+
export { Sandbox } from './sandbox';
|
|
63
|
+
export { SandboxManager } from './sandbox-manager';
|
|
64
|
+
export { FileService } from './file-service';
|
|
65
|
+
export { StreamService } from './stream-service';
|
|
66
|
+
|
|
67
|
+
// ============================================================================
|
|
68
|
+
// Type Exports
|
|
69
|
+
// ============================================================================
|
|
70
|
+
|
|
71
|
+
export type {
|
|
72
|
+
SandboxConfig,
|
|
73
|
+
ProcessInfo,
|
|
74
|
+
ExecOptions,
|
|
75
|
+
ExecuteResponse,
|
|
76
|
+
} from './sandbox';
|
|
77
|
+
|
|
78
|
+
export type { SandboxManagerConfig } from './sandbox-manager';
|
|
79
|
+
|
|
80
|
+
export type {
|
|
81
|
+
FileInfo,
|
|
82
|
+
ListFilesOptions,
|
|
83
|
+
ReadFileOptions,
|
|
84
|
+
WriteFileOptions,
|
|
85
|
+
} from './file-service';
|
|
86
|
+
|
|
87
|
+
export type { ExecEvent, ExecEventType, LogEvent } from './stream-service';
|
|
88
|
+
|
|
89
|
+
// ============================================================================
|
|
90
|
+
// Auth & Middleware
|
|
91
|
+
// ============================================================================
|
|
92
|
+
|
|
93
|
+
export { createAuthMiddleware, parseApiKeysFromEnv } from './auth-middleware';
|
|
94
|
+
export type { AuthConfig } from './auth-middleware';
|
|
95
|
+
|
|
96
|
+
// ============================================================================
|
|
97
|
+
// HTTP Exposure
|
|
98
|
+
// ============================================================================
|
|
99
|
+
|
|
100
|
+
export { HttpExposureService } from './http-exposure-service';
|
|
101
|
+
export type { HttpExposureConfig, ExposedEndpoint } from './http-exposure-service';
|