跳到主要内容

文件系统:RPC 元数据通道 + HTTP 数据通道 + 目录监听

30 秒导读: 这一章讲 E2B 沙箱里"读写文件"这件事在 SDK 一侧到底怎么实现。核心结论只有一句: 它被故意拆成两条通道——列目录、建目录、改名、删除、stat 这些"元数据操作"走 Connect-RPC; 而真正搬运字节的 read/write 走一个普通的 HTTP GET/POST /files 端点。再往上,叠了两套目录 监听(流式 + 轮询)和一套带签名的直传/直下 URL。

本章属于 E2B 数据面。它的上游是 数据面连接(URL 推导、鉴权、连到沙箱里的 envd);它的兄弟是 命令执行与 PTY。命令/PTY 的流式 RPC 这里不重复,只讲文件系统。


1. 这是什么(零基础也能懂)

一句话定义: Filesystem 是 SDK 给你操作"沙箱里那台 Linux 机器的磁盘"的模块——sbx.files.read()sbx.files.write()sbx.files.list()sbx.files.watchDir()

解决什么问题 / 给谁用: 假设你让 AI 在一个隔离沙箱里跑代码,你需要:先把输入文件塞进去、跑完再 把产物拿出来、过程中还想"盯着"某个目录看它生成了哪些文件。这三件事分别对应写、读、监听

它能做什么(功能):

能力方法通道
读文件(文本/字节/Blob/流)readHTTP GET /files
写一个或多个文件write / writeFilesHTTP POST /files
列目录listRPC ListDir
建目录makeDirRPC MakeDir
改名/移动renameRPC Move
删除removeRPC Remove
判断存在 / 取信息exists / getInfoRPC Stat
监听目录变化watchDirRPC WatchDir(流)

用起来什么样(一个最小示例):

// 示意,非源码
const sbx = await Sandbox.create()

await sbx.files.write('/home/user/hello.txt', 'hi') // 写:走 HTTP POST /files
const txt = await sbx.files.read('/home/user/hello.txt') // 读:走 HTTP GET /files
const entries = await sbx.files.list('/home/user') // 列目录:走 RPC ListDir

// 监听目录,拿到变化事件
const handle = await sbx.files.watchDir('/home/user', (e) => {
console.log(e.type, e.name) // 例:'write' 'hello.txt'
})
await handle.stop()

一句话直觉/类比: 把它想成两根管子接同一台机器——一根细管专门传"这是什么文件、多大、谁的" 这类结构化元数据(RPC,强类型),一根粗管专门灌字节流(HTTP,能流式、能压缩、能签名直传)。

本节不出现底层细节。下面开始拆。


2. 顶层全景(它大概怎么转)

怎么读这张图: 上半是"元数据通道",下半是"数据通道";两者最终都打到沙箱里同一个 envd 进程, 但用的是两套不同的传输

SDK: Filesystem 类 (packages/js-sdk/src/sandbox/filesystem/index.ts)
────────────────────────────────────────────────────────────────────
元数据通道 数据通道
(强类型、一元 RPC) (字节流、可压缩、可签名)
│ │
list / makeDir / rename read (GET /files)
remove / exists / getInfo write (POST /files)
│ │
this.rpc.* (Connect-RPC over Transport) this.envdApi.api.GET/POST
│ │
└──────────────► envd (沙箱内进程) ◄────────────┘

监听: this.rpc.watchDir(...) → stream WatchDirResponse

WatchHandle 消费流,回调 onEvent

部件一句话职责:

部件干什么在哪
Filesystem对外的文件 API 门面,决定每个方法走哪条通道sandbox/filesystem/index.ts:364
this.rpcConnect-RPC 客户端,发一元 RPC(元数据)index.ts:376(createClient)
this.envdApi.api类型化 HTTP 客户端,打 /files(字节)index.ts:478:699
WatchHandle消费 watchDir 的事件流,派发回调filesystem/watchHandle.ts:79
getSignature给直传/直下 URL 算签名sandbox/signature.ts:23
filesystem.proto定义 RPC 服务与 EntryInfo/事件类型spec/envd/filesystem/filesystem.proto:7

主线走一遍(高层): 你调 sbx.files.xxx()Filesystem 按方法性质分流:元数据this.rpc 发一元 RPC 到 envd,拿回强类型的 EntryInfo;字节this.envdApi.api 发 HTTP 到 /files, 按需选传输格式;监听发一个服务端流式 RPC,由 WatchHandle 在后台把流里的事件喂给你的回调。


3. 核心原理

3.1 为什么拆成两条通道

它要解决的小问题: 文件系统操作有两类需求天差地别——一类是"告诉我这个目录里有啥"(小、结构化、 要强类型),一类是"把这 500MB 传进去"(大、二进制、想流式/压缩)。

思路/直觉: 用一种传输同时伺候两者会别扭。所以 E2B 把它们物理分开:

  • 元数据 → Connect-RPC。 ListDir/Stat/Move 等在 proto 里是一元 RPC (filesystem.proto:7-12),请求/响应都是 protobuf 消息,天然强类型,SDK 直接拿到 EntryInfo(名字、大小、权限、mtime、xattr 元数据)。
  • 字节 → HTTP /files read/writeenvd 暴露的一个普通 HTTP 端点。HTTP 才有的东西正是 文件传输需要的:ReadableStream 流式、Content-Encoding: gzip 压缩、multipart/form-data 一次 多文件、以及带签名的 URL 让第三方直传直下(见 3.5)。

证据在代码里一眼可辨。 同一个类里,两组方法用的客户端不同:

  • 元数据:listthis.rpc.listDir(index.ts:829)、makeDirthis.rpc.makeDir(:870)、 renamethis.rpc.move(:908)、removethis.rpc.remove(:941)、exists/getInfo 都调 this.rpc.stat(:966:1002)。
  • 字节:readthis.envdApi.api.GET('/files', ...)(index.ts:479)、writethis.envdApi.api.POST('/files', ...)(:700:754)。

一个巧妙细节:exists 复用 stat 没有单独的 exists RPC——exists 就是发 Stat,抓 Code.NotFound 返回 false,其它错误再抛(index.ts:964-987)。makeDir 同理:靠捕获 Code.AlreadyExists 把"已存在"翻成返回 false(:883-887)。用错误码承载语义,省了一批 RPC。

元数据里的 xattr: EntryInfo.metadata 是文件上的 user.e2b.* 扩展属性(xattr),proto 侧 map<string,string> metadata = 11(filesystem.proto:65-68)读回来时会剥掉 user.e2b. 前缀; SDK 侧 mapMetadata 把空 map 归一成 undefined(index.ts:184-189)。

3.2 write 的传输选择:multipart / octet-stream / gzip

它要解决的小问题: "写文件"看着简单,但怎么把 body 放到 HTTP 请求里有三种做法,各有取舍。 write 要在运行时/版本/数据类型之间自动选一个对的。

三种传输格式:

传输何时用特点
multipart/form-data默认、浏览器、旧 envdbody 被 toBlob 缓冲进内存;能一次带多文件
application/octet-stream数据是 ReadableStream,或显式要求非浏览器下真流式,不缓冲;每文件一发
octet-stream + gzipopts.gzip === true整个 body 用 CompressionStream('gzip') 压缩

判定逻辑(index.ts:641-656)——这是本章最该看懂的一段:

// 示意,浓缩自 index.ts:641-656
const useGzip = writeOpts?.gzip === true

// 沙箱的 envd 版本得够新(≥0.5.7)才支持 octet-stream
const supportsOctetStream =
compareVersions(this.envdApi.version, ENVD_OCTET_STREAM_UPLOAD) >= 0

// 只有 octet-stream 路径能真流式;multipart 会缓冲。
// 所以:数据里有 ReadableStream 就默认走 octet-stream(否则流会被"偷偷缓冲")。
// 但浏览器不能流式发请求体 → 强制留在 multipart。
const hasStreamableData =
runtime !== 'browser' &&
writeFiles.some((f) => f.data instanceof ReadableStream)

// gzip 也隐含 octet-stream;显式 useOctetStream 优先级最高。
const useOctetStream =
((writeOpts?.useOctetStream ?? hasStreamableData) || useGzip) &&
supportsOctetStream

几个关键点,逐条拆开:

  • 浏览器强制 multipart。 hasStreamableData 里带了 runtime !== 'browser',因为浏览器 fetch 不支持流式请求体。旧 envd 也回退——supportsOctetStream 为假时,无论如何都落 multipart (对应 FilesystemWriteOpts.useOctetStream 文档里"not supported → falls back"的注释,index.ts:278-284)。
  • 默认值的用意。 useOctetStream 默认 undefined,规则是"有流数据就 octet-stream,否则 multipart"。 这是为了不把流白白缓冲进内存——如果一个流数据走了 multipart,toBlob 会把它整个读进来 (utils.ts:120-121new Response(data).blob())。
  • body 怎么造。 octet-stream 路径用 toUploadBody(utils.ts:154):要 gzip 就把数据 stream()pipeThrough(new CompressionStream('gzip'));非浏览器且是流就原样返回流(不缓冲);其余落到 toBlob(utils.ts:154-174)。multipart 路径则对每个文件 toBlobformData.append('file', ...) (index.ts:744-751)。
  • gzip 头。 octet-stream 且 gzip 时,除了 Content-Type: application/octet-stream 还加 Content-Encoding: gzip(index.ts:678-680)——这个头作用于整个请求体,所以 gzip 天然只能 走 octet-stream(multipart 的分段边界会被压坏)。

流式上传的超时被特意去掉。 流式 body 不挂客户端超时:socket 写这层 fetch 看不见,而"卡住的生产者" 是调用方自己的代码,所以流式上传的停摆交给服务端(或 writeOpts.signal)兜底,缓冲上传才用正常请求超时 (index.ts:686-697)。流式请求体还需要 duplex: 'half'(index.ts:712-714)。

元数据随写入。 opts.metadata 作为请求级 X-Metadata-<key> 头发出(index.ts:667-669metadataHeaders:219-228),所以多文件上传时同一份元数据套到每个文件。key 只能是 HTTP token 字符、 value 只能是可打印 US-ASCII,validateMetadata 不合规就抛 InvalidArgumentError(index.ts:199-217); 需要 envd ≥ 0.6.2,否则抛 TemplateError(index.ts:660-666)。

3.3 read 的多重载:text / bytes / blob / stream

它要解决的小问题: 同一个 read,调用方想要的返回类型不一样——有人要字符串,有人要 Uint8Array,有人要 Blob,有人要边下边处理的 ReadableStream

思路: 用 TypeScript 函数重载 + 一个 format 选项统一入口。四个重载签名分别声明返回 Promise<string>(index.ts:391)、Promise<Uint8Array>(:406)、Promise<Blob>(:421)、 Promise<ReadableStream>(:441),实现体按 format 分派(默认 'text',:451)。

底层落到 GET /filesparseAs 非流路径直接把 format 映射成 openapi-fetch 的 parseAs (bytes'arrayBuffer',其余同名,index.ts:533)。空文件有个专门处理:content-length0 时按 format 返回正确的空值(空串 / 空 Uint8Array / 空 Blob),因为空 body 会让 res.dataundefined(index.ts:549-556)。

stream 路径最特殊。 它单独走一段(index.ts:466-523):

  • 请求超时只约束初次握手,响应一到,流就一直活到被读完/取消/signal 中止/每块空闲超时触发 (streamIdleTimeoutMs,默认等于请求超时,可传 0 关掉,index.ts:305-311:516)。
  • 出错时会主动 cancel() 未消费的错误 body,把连接池里那条连接放掉,再抛(index.ts:497-508)。
  • 成功则用 wrapStreamWithConnectionCleanup 把流包起来,附上空闲超时和清理钩子(index.ts:510-518)。

坑: 拿了 format: 'stream'必须消费到底或主动取消,否则那条池化连接会被一直占住(签名文档在 index.ts:425-434)。gzip: true 时给请求加 Accept-Encoding: gzip,让服务端返回压缩响应 (index.ts:462-464)。

3.4 目录监听:流式 watcher 与轮询 watcher

它要解决的小问题: 你想"实时"知道某目录里文件的增删改。理想是服务端推;但有些环境(如无法保持 长连接的场景)只能客户端轮询。所以 proto 给了两套。

proto 层的两套 API(filesystem.proto:14-19):

流式: WatchDir(WatchDirRequest) → stream WatchDirResponse ← 服务端推
轮询: CreateWatcher → watcher_id ← 建
GetWatcherEvents(watcher_id) → 一批 events ← 轮
RemoveWatcher(watcher_id) ← 拆

JS SDK 用的是流式那套。 watchDirthis.rpc.watchDir(...)(index.ts:1081),返回一个 AsyncIterable<WatchDirResponse>WatchDirResponse 是个 oneof:start / filesystem / keepalive 三选一(filesystem.proto:105-115)。SDK 先 handleWatchDirStartEvent(events) 等到 start 事件才 认为订阅建立、清掉握手超时(index.ts:1099-1100),再把流交给 WatchHandle

WatchHandle 怎么干活(watchHandle.ts:79):

构造时就启动后台循环 handleEvents() (watchHandle.ts:87)

iterateEvents(): 遍历流,只挑 case 'filesystem' 的事件 (:97-109)

handleEvents(): 每个事件 mapEventType → 调你的 onEvent (:111-131)
│ (await 回调:让异步失败能进 onExit、并支持背压)

流结束/出错 → onExit 恰好调一次 → handleStop() (:136-150)

几个设计点:

  • 只透传文件事件。 keepalive/start 在迭代时被过滤掉,只有 case 'filesystem' 往下走 (watchHandle.ts:100-105)。keep-alive 靠请求头 KEEPALIVE_PING_HEADER 让服务端定期发心跳, 避免连接被判死(index.ts:1091)。
  • 事件类型映射。 proto 的 EventType(filesystem.proto:145-152:CREATE/WRITE/REMOVE/RENAME/CHMOD) 被 mapEventType 翻成字符串枚举 FilesystemEventType(watchHandle.ts:14-50)。
  • await onEvent 是刻意的。 等回调完成,既能让异步回调的 reject 被路由到 onExit(而不是变成能 搞崩 Node 的 unhandled rejection),又给了调用方背压能力(watchHandle.ts:120-131)。
  • onExit 恰好一次且吞掉自身异常。 它是终点回调,抛错无处可去,所以被 try/catch 吞掉,finally 里一定 handleStop()(watchHandle.ts:136-150)。

版本门槛(都在 index.ts:1041-1071,抛 TemplateError):

选项作用需要 envd
recursive递归监听子目录0.1.4(ENVD_VERSION_RECURSIVE_WATCH)
includeEntry事件里带上受影响条目的 EntryInfo0.6.3(..._FS_EVENT_ENTRY_INFO)
allowNetworkMounts允许监听 NFS/CIFS/SMB/FUSE 挂载0.6.4(..._WATCH_NETWORK_MOUNTS)

includeEntry尽力而为的:remove / rename-away 这类"条目已不在原路径"的事件里,entry 会是 undefined(proto 注释 filesystem.proto:99-102;SDK 侧 watchHandle.ts:127-129 有无判空)。

3.5 签名的直传 / 直下 URL

它要解决的小问题: 有时你不想让字节都过 SDK,而是想给"别的进程/前端"一个 URL,让它自己直接 POST/GET 沙箱文件。安全沙箱下这个 URL 必须带签名,不然谁都能读写。

入口在 Sandbox 而非 Filesystem: uploadUrl(path?)(sandbox/index.ts:648)和 downloadUrl(path)(:700)。两者结构对称:

  1. 先用 fileUrl(path, username) 拼出基础 URL——就是 /files 端点带上 username/path query (sandbox/index.ts:795-806)。
  2. 若沙箱是"安全"的(有 envdAccessToken),调 getSignature 算签名,拼进 query 的 signature (和可选 signature_expiration)(:670-686 / :721-737)。上传用 operation: 'write',下载用 'read'——签名把操作也绑死了

签名怎么算(signature.ts:23): 把关键字段拼成一个原文串再 SHA-256:

// 示意,浓缩自 signature.ts:44-55
if (user == undefined) user = '' // 默认用户 → 空串
signatureRaw = expiration === null
? `${path}:${operation}:${user}:${envdAccessToken}`
: `${path}:${operation}:${user}:${envdAccessToken}:${expiration}`
const signature = 'v1_' + (await sha256(signatureRaw)).replace(/=+$/, '')

要点: 签名由 path + 操作 + 用户 + envd 访问令牌 (+ 过期时间戳) 共同决定,v1_ 前缀 + 去掉 base64 尾部 =没有 envdAccessToken 直接抛错(signature.ts:30-34)——签名根植于那个密钥。 expirationInSeconds 转成 unix 绝对时间戳(signature.ts:37-39);而在非安全沙箱上传 useSignatureExpiration 会抛 InvalidArgumentError(sandbox/index.ts:653-657)。


4. 类型速查:EntryInfo / FileType

EntryInfo(index.ts:110-145)是"一个文件/目录的全部元信息",由 mapEntryInfo 从 protobuf EntryInfo(filesystem.proto:53-69)转成 SDK 形状(index.ts:233-247):

字段含义来源转换
name / path名字 / 完整路径直传
typeFileType.FILEFileType.DIRmapFileType(index.ts:166-173)
size字节数proto int64Number
mode / permissions权限位 / 'rwxr-xr-x' 字串直传
owner / group属主 / 属组直传
modifiedTimemtimemapModifiedTime:秒+纳秒 → Date(:175-182)
symlinkTarget软链目标(可选)直传
metadatauser.e2b.* xattrmapMetadata(空 map → undefined)

FileType 是字符串枚举 'file'/'dir'(index.ts:150-159),对应 proto 的 FILE_TYPE_FILE/FILE_TYPE_DIRECTORY;FILE_TYPE_UNSPECIFIED 会在 list 里被跳过 (mapFileType 返回 undefinedcontinue,index.ts:846-849)。


5. 巧妙之处(可借鉴的技术)

  • 按"数据形状"选传输,而不是一刀切。 元数据要强类型 → RPC;字节要流式/压缩/签名 → HTTP。同一个类 里两套客户端并存(index.ts:376 vs :478),把各自的传输长处用满。
  • 默认值编码了"别偷偷缓冲"的意图。 useOctetStream 默认跟随"是否有流数据",避免把 ReadableStream 在 multipart 路径被 toBlob 读进内存(index.ts:645-656 + utils.ts:120-121)。
  • 用错误码承载语义,省 RPC。 exists/makeDir 靠捕获 NotFound/AlreadyExists 返回布尔,不新增端点 (index.ts:883-887:979-985)。
  • 流式读写把超时拆成"握手 vs 传输"。 请求超时只管建立连接,数据期靠空闲超时/signal;流式上传干脆不挂 客户端超时,交给服务端(index.ts:466-471:686-697)。
  • 签名把 path+操作+用户 绑进哈希。 一个 URL 只能干一件被授权的事,过期时间也进哈希,防篡改 (signature.ts:44-55)。

6. 边界与局限

  • 两套 watcher,JS SDK 只用了流式那套。 proto 里的 CreateWatcher/GetWatcherEvents/RemoveWatcher 轮询接口存在(filesystem.proto:17-19),但 Filesystem 类没有对应方法——JS 侧只走 watchDir 流式。
  • 一堆能力有版本门槛。 octet-stream(0.5.7)、xattr 元数据(0.6.2)、事件带 EntryInfo(0.6.3)、 监听网络挂载(0.6.4);沙箱 envd 太旧会回退(上传)或TemplateError(监听)。
  • 网络挂载监听不可靠。 即便开了 allowNetworkMounts,NFS/CIFS/SMB/FUSE 上的事件可能延迟或丢失 (proto 注释 filesystem.proto:91-93)。
  • 签名只在安全沙箱可用。envdAccessTokengetSignature 直接抛错(signature.ts:30-34)。
  • 本章不覆盖命令/PTY。 命令执行、CommandHandle、PTY 的流式 RPC 见 03-commands-pty.md; URL 推导与鉴权见 02-data-plane-connection.md

7. 代码地图(导航索引)

主题文件路径符号名
文件系统门面类packages/js-sdk/src/sandbox/filesystem/index.ts:364Filesystem
列目录(RPC)packages/js-sdk/src/sandbox/filesystem/index.ts:823list / this.rpc.listDir
建目录(RPC)packages/js-sdk/src/sandbox/filesystem/index.ts:868makeDir / this.rpc.makeDir
改名(RPC)packages/js-sdk/src/sandbox/filesystem/index.ts:902rename / this.rpc.move
删除(RPC)packages/js-sdk/src/sandbox/filesystem/index.ts:939remove / this.rpc.remove
存在/信息(RPC)packages/js-sdk/src/sandbox/filesystem/index.ts:964,997exists / getInfo / this.rpc.stat
读(HTTP,多重载)packages/js-sdk/src/sandbox/filesystem/index.ts:391,445read
写(HTTP,传输选择)packages/js-sdk/src/sandbox/filesystem/index.ts:581,641write / useOctetStream
多文件写packages/js-sdk/src/sandbox/filesystem/index.ts:808writeFiles
上传 body 构造packages/js-sdk/src/utils.ts:154,109toUploadBody / toBlob
元数据头/校验packages/js-sdk/src/sandbox/filesystem/index.ts:199,219validateMetadata / metadataHeaders
监听目录packages/js-sdk/src/sandbox/filesystem/index.ts:1034watchDir
监听句柄packages/js-sdk/src/sandbox/filesystem/watchHandle.ts:79,111WatchHandle / handleEvents
事件类型枚举packages/js-sdk/src/sandbox/filesystem/watchHandle.ts:14FilesystemEventType
直传/直下 URLpackages/js-sdk/src/sandbox/index.ts:648,700uploadUrl / downloadUrl
签名计算packages/js-sdk/src/sandbox/signature.ts:23getSignature
协议定义spec/envd/filesystem/filesystem.proto:7service Filesystem
条目/事件类型spec/envd/filesystem/filesystem.proto:53,145EntryInfo / EventType
版本常量packages/js-sdk/src/envd/versions.ts:6-9ENVD_OCTET_STREAM_UPLOAD

同组其它章: index.md(总览) · 01-control-plane.md(控制面) · 02-data-plane-connection.md(数据面连接) · 03-commands-pty.md(命令/PTY) · 05-templates.md(模板构建) · 06-spec-and-multilang.md(一份 spec,多个 SDK)。