@runko/sandbox-cloudflare 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.
- package/README.md +81 -0
- package/dist/index.cjs +308 -0
- package/dist/index.d.cts +12 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +307 -0
- package/dist/protocol-BXejIk9L.cjs +226 -0
- package/dist/protocol-Dj8Yv54w.js +101 -0
- package/dist/worker.cjs +279 -0
- package/dist/worker.d.cts +70 -0
- package/dist/worker.d.ts +70 -0
- package/dist/worker.js +278 -0
- package/package.json +63 -0
package/dist/worker.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { c as execRequestSchema, f as mkdirRequestSchema, g as readdirRequestSchema, i as NDJSON_CONTENT_TYPE, m as readRequestSchema, r as ENDPOINTS, s as execExitEventSchema, u as globRequestSchema, v as rmRequestSchema, x as writeRequestSchema, y as statRequestSchema } from "./protocol-Dj8Yv54w.js";
|
|
2
|
+
import { basename, dirname, matchesGlob, normalizePath } from "@runko/virtual-fs";
|
|
3
|
+
import "zod";
|
|
4
|
+
//#region src/worker.ts
|
|
5
|
+
/**
|
|
6
|
+
* `./worker`:Cloudflare Sandbox 网关(部署在宿主自己的 wrangler 项目里)。
|
|
7
|
+
* 把 `src/protocol.ts` 的八个端点翻译成对注入的 `CfSandboxLike` 的调用——本文件
|
|
8
|
+
* **零 `@cloudflare/sandbox` import**(该包加载依赖 `cloudflare:workers` 内置模块,
|
|
9
|
+
* Node 下不可加载,见 docs/tech/sandbox.md §8.2/§4"包本体零
|
|
10
|
+
* cloudflare import"):`CfSandboxLike` 是以该包 0.12.3 版本 `ISandbox` d.ts 为
|
|
11
|
+
* 蓝本手写的结构化最小子集,真实装配(`getSandbox(env.Sandbox, id)` 返回值天然
|
|
12
|
+
* 结构兼容本接口)留给宿主项目经 `createSandboxGateway({ getSandbox })` 注入。
|
|
13
|
+
*
|
|
14
|
+
* ---- 路径锚定:虚拟根 "/" ↔ 沙盒默认 cwd(工单裁量,见协议表 §8.3 附近注释)----
|
|
15
|
+
*
|
|
16
|
+
* `@cloudflare/sandbox` 的文件/目录方法把"/"开头的路径当作容器真实绝对路径、
|
|
17
|
+
* 把不带前导斜杠的路径当作相对于沙盒自身默认工作目录(其官方文档称之为
|
|
18
|
+
* "/workspace")解析——这与 E2B/Vercel 适配器"client 端 `opts.root` 选项做
|
|
19
|
+
* 虚拟根↔真实目录换算"的形状不同:Cloudflare 网关没有 root 配置(`cloudflareWorkspace`
|
|
20
|
+
* 的选项里也确实没有 root 字段),换算发生在**网关**这一侧、且是隐式的——
|
|
21
|
+
* `toSandboxPath()` 把客户端送来的虚拟绝对路径(`normalizePath()` 产出,形如
|
|
22
|
+
* "/" 或 "/a/b.txt")去掉前导斜杠变成沙盒相对路径("." 或 "a/b.txt"),从而让
|
|
23
|
+
* 沙盒自己的默认工作目录天然充当虚拟根——不需要任何一侧显式配置真实目录。
|
|
24
|
+
*/
|
|
25
|
+
function describeError(error) {
|
|
26
|
+
return error instanceof Error ? error.message : String(error);
|
|
27
|
+
}
|
|
28
|
+
function jsonResponse(body, status = 200) {
|
|
29
|
+
return new Response(JSON.stringify(body), {
|
|
30
|
+
status,
|
|
31
|
+
headers: { "content-type": "application/json" }
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
function errorResponse(status, code, message) {
|
|
35
|
+
return jsonResponse({
|
|
36
|
+
code,
|
|
37
|
+
message
|
|
38
|
+
}, status);
|
|
39
|
+
}
|
|
40
|
+
/** 虚拟绝对路径 → 沙盒路径:见文件头"路径锚定"注释。 */
|
|
41
|
+
function toSandboxPath(virtualPath) {
|
|
42
|
+
return virtualPath === "/" ? "." : virtualPath.slice(1);
|
|
43
|
+
}
|
|
44
|
+
function normalizeOrBadRequest(rawPath) {
|
|
45
|
+
try {
|
|
46
|
+
return {
|
|
47
|
+
ok: true,
|
|
48
|
+
path: normalizePath(rawPath)
|
|
49
|
+
};
|
|
50
|
+
} catch (error) {
|
|
51
|
+
return {
|
|
52
|
+
ok: false,
|
|
53
|
+
response: errorResponse(400, "bad_request", `invalid path "${rawPath}": ${describeError(error)}`)
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async function withValidatedBody(request, schema, handler) {
|
|
58
|
+
let raw;
|
|
59
|
+
try {
|
|
60
|
+
raw = await request.json();
|
|
61
|
+
} catch {
|
|
62
|
+
return errorResponse(400, "bad_request", "request body is not valid JSON");
|
|
63
|
+
}
|
|
64
|
+
const parsed = schema.safeParse(raw);
|
|
65
|
+
if (!parsed.success) return errorResponse(400, "bad_request", `request body failed validation: ${parsed.error.message}`);
|
|
66
|
+
return handler(parsed.data);
|
|
67
|
+
}
|
|
68
|
+
/** `read/mkdir/readdir/stat` 四个 `{ path }` 形状的端点共用:先校验+规范化 path 再交给业务 handler。 */
|
|
69
|
+
async function withNormalizedPath(body, handler) {
|
|
70
|
+
const normalized = normalizeOrBadRequest(body.path);
|
|
71
|
+
if (!normalized.ok) return normalized.response;
|
|
72
|
+
return handler(normalized.path, body);
|
|
73
|
+
}
|
|
74
|
+
/** stat("/") 没有对应的"父目录条目"可查——合成一个恒为目录的条目代表虚拟根本身。 */
|
|
75
|
+
const ROOT_ENTRY = {
|
|
76
|
+
name: "",
|
|
77
|
+
relativePath: "",
|
|
78
|
+
type: "directory",
|
|
79
|
+
size: 0,
|
|
80
|
+
modifiedAt: (/* @__PURE__ */ new Date(0)).toISOString()
|
|
81
|
+
};
|
|
82
|
+
/** 工单要求的 "stat 经 listFiles(dirname) 找条目" 策略——stat/readdir/rm 三个端点共用。 */
|
|
83
|
+
async function findEntry(sandbox, virtualPath) {
|
|
84
|
+
if (virtualPath === "/") return {
|
|
85
|
+
found: true,
|
|
86
|
+
entry: ROOT_ENTRY
|
|
87
|
+
};
|
|
88
|
+
const parentVirtual = dirname(virtualPath);
|
|
89
|
+
let listing;
|
|
90
|
+
try {
|
|
91
|
+
listing = await sandbox.listFiles(toSandboxPath(parentVirtual), { recursive: false });
|
|
92
|
+
} catch {
|
|
93
|
+
return { found: false };
|
|
94
|
+
}
|
|
95
|
+
const name = basename(virtualPath);
|
|
96
|
+
const entry = listing.files.find((f) => f.name === name);
|
|
97
|
+
return entry === void 0 ? { found: false } : {
|
|
98
|
+
found: true,
|
|
99
|
+
entry
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/** `CfFileInfo.type` → `FileStat.type`:沙盒工作区没有 reference 概念(§3.1),symlink/other 一律按 file 呈现。 */
|
|
103
|
+
function mapEntryType(type) {
|
|
104
|
+
return type === "directory" ? "dir" : "file";
|
|
105
|
+
}
|
|
106
|
+
function toStatResponse(entry) {
|
|
107
|
+
const type = mapEntryType(entry.type);
|
|
108
|
+
const mtimeMs = Date.parse(entry.modifiedAt);
|
|
109
|
+
return {
|
|
110
|
+
type,
|
|
111
|
+
...type === "file" ? { size: entry.size } : {},
|
|
112
|
+
...Number.isFinite(mtimeMs) ? { mtime: mtimeMs } : {}
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/** existence/kind 查不出更具体原因时的兜底翻译:找不到→404,是目录→400 not_dir,否则→500 sandbox_error。 */
|
|
116
|
+
async function translateFsError(sandbox, virtualPath, error) {
|
|
117
|
+
const lookup = await findEntry(sandbox, virtualPath);
|
|
118
|
+
if (!lookup.found) return errorResponse(404, "not_found", `no such file or directory: "${virtualPath}"`);
|
|
119
|
+
if (lookup.entry.type === "directory") return errorResponse(400, "not_dir", `"${virtualPath}" is a directory: ${describeError(error)}`);
|
|
120
|
+
return errorResponse(500, "sandbox_error", `sandbox operation failed for "${virtualPath}": ${describeError(error)}`);
|
|
121
|
+
}
|
|
122
|
+
async function handleRead(sandbox, virtualPath) {
|
|
123
|
+
try {
|
|
124
|
+
return jsonResponse({ dataBase64: (await sandbox.readFile(toSandboxPath(virtualPath), { encoding: "base64" })).content });
|
|
125
|
+
} catch (error) {
|
|
126
|
+
return translateFsError(sandbox, virtualPath, error);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
async function handleWrite(sandbox, virtualPath, dataBase64) {
|
|
130
|
+
try {
|
|
131
|
+
await sandbox.mkdir(toSandboxPath(dirname(virtualPath)), { recursive: true });
|
|
132
|
+
await sandbox.writeFile(toSandboxPath(virtualPath), dataBase64, { encoding: "base64" });
|
|
133
|
+
return jsonResponse({ ok: true });
|
|
134
|
+
} catch (error) {
|
|
135
|
+
return translateFsError(sandbox, virtualPath, error);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
async function handleMkdir(sandbox, virtualPath) {
|
|
139
|
+
try {
|
|
140
|
+
await sandbox.mkdir(toSandboxPath(virtualPath), { recursive: true });
|
|
141
|
+
return jsonResponse({ ok: true });
|
|
142
|
+
} catch (error) {
|
|
143
|
+
return translateFsError(sandbox, virtualPath, error);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async function handleReaddir(sandbox, virtualPath) {
|
|
147
|
+
const lookup = await findEntry(sandbox, virtualPath);
|
|
148
|
+
if (!lookup.found) return errorResponse(404, "not_found", `no such file or directory: "${virtualPath}"`);
|
|
149
|
+
if (lookup.entry.type !== "directory") return errorResponse(400, "not_dir", `"${virtualPath}" is not a directory`);
|
|
150
|
+
try {
|
|
151
|
+
return jsonResponse({ entries: (await sandbox.listFiles(toSandboxPath(virtualPath), { recursive: false })).files.map((f) => ({
|
|
152
|
+
name: f.name,
|
|
153
|
+
type: mapEntryType(f.type)
|
|
154
|
+
})) });
|
|
155
|
+
} catch (error) {
|
|
156
|
+
return errorResponse(500, "sandbox_error", `failed to list "${virtualPath}": ${describeError(error)}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
async function handleStat(sandbox, virtualPath) {
|
|
160
|
+
const lookup = await findEntry(sandbox, virtualPath);
|
|
161
|
+
if (!lookup.found) return errorResponse(404, "not_found", `no such file or directory: "${virtualPath}"`);
|
|
162
|
+
return jsonResponse(toStatResponse(lookup.entry));
|
|
163
|
+
}
|
|
164
|
+
async function handleGlob(sandbox, pattern) {
|
|
165
|
+
try {
|
|
166
|
+
return jsonResponse({ paths: (await sandbox.listFiles(".", { recursive: true })).files.filter((f) => f.type !== "directory").map((f) => `/${f.relativePath}`).filter((virtualPath) => matchesGlob(pattern, virtualPath)).sort() });
|
|
167
|
+
} catch (error) {
|
|
168
|
+
return errorResponse(500, "sandbox_error", `glob failed for pattern "${pattern}": ${describeError(error)}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async function handleRm(sandbox, virtualPath, recursive) {
|
|
172
|
+
if (virtualPath === "/") return errorResponse(400, "bad_request", "cannot remove the virtual root \"/\"");
|
|
173
|
+
const lookup = await findEntry(sandbox, virtualPath);
|
|
174
|
+
if (!lookup.found) return errorResponse(404, "not_found", `no such file or directory: "${virtualPath}"`);
|
|
175
|
+
const sandboxPath = toSandboxPath(virtualPath);
|
|
176
|
+
if (lookup.entry.type === "directory" && recursive !== true) {
|
|
177
|
+
let childCount = 0;
|
|
178
|
+
try {
|
|
179
|
+
childCount = (await sandbox.listFiles(sandboxPath, { recursive: false })).files.length;
|
|
180
|
+
} catch {
|
|
181
|
+
childCount = 0;
|
|
182
|
+
}
|
|
183
|
+
if (childCount > 0) return errorResponse(409, "dir_not_empty", `directory not empty (pass { recursive: true } to delete): "${virtualPath}"`);
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
await sandbox.deleteFile(sandboxPath);
|
|
187
|
+
return jsonResponse({ ok: true });
|
|
188
|
+
} catch (error) {
|
|
189
|
+
return errorResponse(500, "sandbox_error", `failed to delete "${virtualPath}": ${describeError(error)}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const textEncoder = new TextEncoder();
|
|
193
|
+
function ndjsonLine(event) {
|
|
194
|
+
return textEncoder.encode(`${JSON.stringify(event)}\n`);
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* `/exec`:NDJSON 流式响应。响应对象立即返回(`readable` 端交给 `Response`),
|
|
198
|
+
* 真正跑命令的工作在一个不 await 的后台 IIFE 里进行,通过 `TransformStream` 的
|
|
199
|
+
* `writable` 端把 N 个 output 事件 + 1 个终块 exit 事件逐行写入——客户端读到
|
|
200
|
+
* 流结束(`writer.close()`)即视为命令完成。
|
|
201
|
+
*/
|
|
202
|
+
async function handleExec(sandbox, body, requestSignal) {
|
|
203
|
+
let sandboxCwd;
|
|
204
|
+
if (body.cwd !== void 0) {
|
|
205
|
+
const normalized = normalizeOrBadRequest(body.cwd);
|
|
206
|
+
if (!normalized.ok) return normalized.response;
|
|
207
|
+
sandboxCwd = toSandboxPath(normalized.path);
|
|
208
|
+
}
|
|
209
|
+
const { readable, writable } = new TransformStream();
|
|
210
|
+
const writer = writable.getWriter();
|
|
211
|
+
const writeEvent = (event) => {
|
|
212
|
+
writer.write(ndjsonLine(event)).catch(() => {});
|
|
213
|
+
};
|
|
214
|
+
(async () => {
|
|
215
|
+
const start = Date.now();
|
|
216
|
+
try {
|
|
217
|
+
const result = await sandbox.exec(body.command, {
|
|
218
|
+
cwd: sandboxCwd,
|
|
219
|
+
timeout: body.timeoutMs,
|
|
220
|
+
signal: requestSignal,
|
|
221
|
+
stream: true,
|
|
222
|
+
onOutput: (stream, data) => writeEvent({
|
|
223
|
+
type: "output",
|
|
224
|
+
stream,
|
|
225
|
+
data
|
|
226
|
+
})
|
|
227
|
+
});
|
|
228
|
+
const exitEvent = execExitEventSchema.parse({
|
|
229
|
+
type: "exit",
|
|
230
|
+
exitCode: result.exitCode,
|
|
231
|
+
stdout: result.stdout,
|
|
232
|
+
stderr: result.stderr,
|
|
233
|
+
durationMs: result.duration
|
|
234
|
+
});
|
|
235
|
+
writeEvent(exitEvent);
|
|
236
|
+
} catch (error) {
|
|
237
|
+
writeEvent({
|
|
238
|
+
type: "exit",
|
|
239
|
+
exitCode: 1,
|
|
240
|
+
stdout: "",
|
|
241
|
+
stderr: `sandbox exec failed: ${describeError(error)}`,
|
|
242
|
+
durationMs: Date.now() - start
|
|
243
|
+
});
|
|
244
|
+
} finally {
|
|
245
|
+
await writer.close().catch(() => {});
|
|
246
|
+
}
|
|
247
|
+
})();
|
|
248
|
+
return new Response(readable, {
|
|
249
|
+
status: 200,
|
|
250
|
+
headers: { "content-type": NDJSON_CONTENT_TYPE }
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
function createSandboxGateway(opts) {
|
|
254
|
+
return { async fetch(request) {
|
|
255
|
+
if (request.headers.get("authorization") !== `Bearer ${opts.token}`) return errorResponse(401, "unauthorized", "missing or invalid bearer token");
|
|
256
|
+
const sandboxId = request.headers.get("x-runko-sandbox") ?? "default";
|
|
257
|
+
let sandbox;
|
|
258
|
+
try {
|
|
259
|
+
sandbox = await opts.getSandbox(sandboxId);
|
|
260
|
+
} catch (error) {
|
|
261
|
+
return errorResponse(500, "sandbox_error", `getSandbox("${sandboxId}") failed: ${describeError(error)}`);
|
|
262
|
+
}
|
|
263
|
+
const pathname = new URL(request.url).pathname;
|
|
264
|
+
switch (pathname) {
|
|
265
|
+
case ENDPOINTS.read: return withValidatedBody(request, readRequestSchema, (body) => withNormalizedPath(body, (path) => handleRead(sandbox, path)));
|
|
266
|
+
case ENDPOINTS.write: return withValidatedBody(request, writeRequestSchema, (body) => withNormalizedPath(body, (path, b) => handleWrite(sandbox, path, b.dataBase64)));
|
|
267
|
+
case ENDPOINTS.rm: return withValidatedBody(request, rmRequestSchema, (body) => withNormalizedPath(body, (path, b) => handleRm(sandbox, path, b.recursive)));
|
|
268
|
+
case ENDPOINTS.mkdir: return withValidatedBody(request, mkdirRequestSchema, (body) => withNormalizedPath(body, (path) => handleMkdir(sandbox, path)));
|
|
269
|
+
case ENDPOINTS.readdir: return withValidatedBody(request, readdirRequestSchema, (body) => withNormalizedPath(body, (path) => handleReaddir(sandbox, path)));
|
|
270
|
+
case ENDPOINTS.stat: return withValidatedBody(request, statRequestSchema, (body) => withNormalizedPath(body, (path) => handleStat(sandbox, path)));
|
|
271
|
+
case ENDPOINTS.glob: return withValidatedBody(request, globRequestSchema, (body) => handleGlob(sandbox, body.pattern));
|
|
272
|
+
case ENDPOINTS.exec: return withValidatedBody(request, execRequestSchema, (body) => handleExec(sandbox, body, request.signal));
|
|
273
|
+
default: return errorResponse(404, "not_found", `unknown endpoint: ${pathname}`);
|
|
274
|
+
}
|
|
275
|
+
} };
|
|
276
|
+
}
|
|
277
|
+
//#endregion
|
|
278
|
+
export { createSandboxGateway };
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@runko/sandbox-cloudflare",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "RunkoFS & RunkoExec 同源工作区:适配 Cloudflare Sandbox(网关形态——`.` 纯 fetch 客户端跑在任意 Node,`./worker` 网关部署在宿主 wrangler 项目)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "ISC",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public",
|
|
9
|
+
"registry": "https://registry.npmjs.org/"
|
|
10
|
+
},
|
|
11
|
+
"engines": {
|
|
12
|
+
"node": ">=24"
|
|
13
|
+
},
|
|
14
|
+
"main": "./dist/index.cjs",
|
|
15
|
+
"module": "./dist/index.js",
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"import": {
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"default": "./dist/index.js"
|
|
22
|
+
},
|
|
23
|
+
"require": {
|
|
24
|
+
"types": "./dist/index.d.cts",
|
|
25
|
+
"default": "./dist/index.cjs"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"./worker": {
|
|
29
|
+
"import": {
|
|
30
|
+
"types": "./dist/worker.d.ts",
|
|
31
|
+
"default": "./dist/worker.js"
|
|
32
|
+
},
|
|
33
|
+
"require": {
|
|
34
|
+
"types": "./dist/worker.d.cts",
|
|
35
|
+
"default": "./dist/worker.cjs"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"./package.json": "./package.json"
|
|
39
|
+
},
|
|
40
|
+
"files": [
|
|
41
|
+
"dist"
|
|
42
|
+
],
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"zod": "^4.4.3",
|
|
45
|
+
"@runko/core": "^0.1.0",
|
|
46
|
+
"@runko/virtual-fs": "^0.1.0"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@cloudflare/sandbox": "^0.12.3",
|
|
50
|
+
"@types/node": "^24.13.3",
|
|
51
|
+
"ai": "^7.0.20",
|
|
52
|
+
"tsdown": "^0.22.4",
|
|
53
|
+
"typescript": "^7.0.2",
|
|
54
|
+
"vitest": "^4.1.10"
|
|
55
|
+
},
|
|
56
|
+
"scripts": {
|
|
57
|
+
"build": "tsdown",
|
|
58
|
+
"typecheck": "tsc --noEmit",
|
|
59
|
+
"lint": "eslint . --report-unused-disable-directives --max-warnings 0",
|
|
60
|
+
"lint:fix": "eslint . --fix",
|
|
61
|
+
"test": "vitest run"
|
|
62
|
+
}
|
|
63
|
+
}
|