r2-fs 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 +127 -0
- package/dist/index.d.ts +65 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +429 -0
- package/dist/index.js.map +1 -0
- package/package.json +56 -0
- package/src/index.ts +504 -0
package/README.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# r2-fs
|
|
2
|
+
|
|
3
|
+
An R2-backed filesystem implementation for Cloudflare Workers. Can be mounted via `worker-fs-mount` to provide persistent filesystem storage using Cloudflare R2 object storage.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install r2-fs worker-fs-mount
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
### 1. Configure wrangler.toml
|
|
14
|
+
|
|
15
|
+
```toml
|
|
16
|
+
[[r2_buckets]]
|
|
17
|
+
binding = "MY_BUCKET"
|
|
18
|
+
bucket_name = "my-bucket-name"
|
|
19
|
+
|
|
20
|
+
[alias]
|
|
21
|
+
"node:fs/promises" = "worker-fs-mount/fs"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### 2. Generate types with wrangler
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
wrangler types
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
This generates a `worker-configuration.d.ts` file with typed bindings for your R2 bucket.
|
|
31
|
+
|
|
32
|
+
### 3. Use in your Worker
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
import { env } from 'cloudflare:workers';
|
|
36
|
+
import { R2Filesystem } from 'r2-fs';
|
|
37
|
+
import { mount } from 'worker-fs-mount';
|
|
38
|
+
import fs from 'node:fs/promises';
|
|
39
|
+
|
|
40
|
+
// Create filesystem backed by R2 at module level
|
|
41
|
+
const r2fs = new R2Filesystem(env.MY_BUCKET);
|
|
42
|
+
mount('/storage', r2fs);
|
|
43
|
+
|
|
44
|
+
export default {
|
|
45
|
+
async fetch(request: Request): Promise<Response> {
|
|
46
|
+
// Use standard fs operations
|
|
47
|
+
await fs.writeFile('/storage/hello.txt', 'Hello, World!');
|
|
48
|
+
const content = await fs.readFile('/storage/hello.txt', 'utf8');
|
|
49
|
+
|
|
50
|
+
// Create directories
|
|
51
|
+
await fs.mkdir('/storage/projects/my-app', { recursive: true });
|
|
52
|
+
|
|
53
|
+
// List directory contents
|
|
54
|
+
const files = await fs.readdir('/storage/projects');
|
|
55
|
+
|
|
56
|
+
return new Response(content);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Alternative: Wrap in a WorkerEntrypoint
|
|
62
|
+
|
|
63
|
+
For service bindings or more complex setups, you can wrap `R2Filesystem` in a WorkerEntrypoint:
|
|
64
|
+
|
|
65
|
+
```typescript
|
|
66
|
+
import { R2Filesystem } from 'r2-fs';
|
|
67
|
+
import type { WorkerFilesystem } from 'worker-fs-mount';
|
|
68
|
+
import { WorkerEntrypoint } from 'cloudflare:workers';
|
|
69
|
+
|
|
70
|
+
export class MyFilesystem extends WorkerEntrypoint<Env> implements WorkerFilesystem {
|
|
71
|
+
private fs = new R2Filesystem(this.env.MY_BUCKET);
|
|
72
|
+
|
|
73
|
+
// Required methods (6)
|
|
74
|
+
stat = this.fs.stat.bind(this.fs);
|
|
75
|
+
createReadStream = this.fs.createReadStream.bind(this.fs);
|
|
76
|
+
createWriteStream = this.fs.createWriteStream.bind(this.fs);
|
|
77
|
+
readdir = this.fs.readdir.bind(this.fs);
|
|
78
|
+
mkdir = this.fs.mkdir.bind(this.fs);
|
|
79
|
+
rm = this.fs.rm.bind(this.fs);
|
|
80
|
+
|
|
81
|
+
// Optional methods (2)
|
|
82
|
+
symlink = this.fs.symlink.bind(this.fs);
|
|
83
|
+
readlink = this.fs.readlink.bind(this.fs);
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Features
|
|
88
|
+
|
|
89
|
+
- Full `WorkerFilesystem` interface implementation
|
|
90
|
+
- Persistent storage via R2 (survives restarts)
|
|
91
|
+
- Support for files, directories, and symlinks
|
|
92
|
+
- Streaming read/write support
|
|
93
|
+
- Large file support (up to 5GB per file)
|
|
94
|
+
- Automatic symlink resolution
|
|
95
|
+
|
|
96
|
+
## API
|
|
97
|
+
|
|
98
|
+
The `R2Filesystem` class implements the full `WorkerFilesystem` interface. See the [worker-fs-mount README](../worker-fs-mount/README.md) for the complete API reference.
|
|
99
|
+
|
|
100
|
+
## Storage
|
|
101
|
+
|
|
102
|
+
Data is stored in R2 using the following conventions:
|
|
103
|
+
|
|
104
|
+
- **Files**: Stored as R2 objects at their path (e.g., `/foo/bar.txt` -> `foo/bar.txt`)
|
|
105
|
+
- **Directories**: Created as marker objects with a `.dir` suffix (e.g., `/foo/mydir` -> `foo/mydir.dir`)
|
|
106
|
+
- **Symlinks**: Stored as empty objects with `symlinkTarget` in custom metadata
|
|
107
|
+
- **Metadata**: File type, creation time, and symlink targets stored in R2 custom metadata
|
|
108
|
+
|
|
109
|
+
## Limitations
|
|
110
|
+
|
|
111
|
+
- **File size**: R2 supports files up to 5GB, but very large files may impact performance
|
|
112
|
+
- **Partial writes**: R2 doesn't support partial writes, so writing at an offset requires read-modify-write
|
|
113
|
+
- **Streaming writes**: Streams buffer content in memory before writing to R2
|
|
114
|
+
|
|
115
|
+
## R2 vs Durable Object Storage
|
|
116
|
+
|
|
117
|
+
| Feature | r2-fs | durable-object-fs |
|
|
118
|
+
|---------|-------|-------------------|
|
|
119
|
+
| Max file size | 5GB | ~100MB recommended |
|
|
120
|
+
| Storage cost | Lower (R2 pricing) | Higher (DO pricing) |
|
|
121
|
+
| Latency | Higher (object storage) | Lower (edge SQLite) |
|
|
122
|
+
| Concurrency | Automatic | Single-threaded DO |
|
|
123
|
+
| Use case | Large files, archives | Small files, metadata |
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { DirEntry, Stat, WorkerFilesystem } from 'worker-fs-mount';
|
|
2
|
+
/**
|
|
3
|
+
* An R2-backed filesystem implementation.
|
|
4
|
+
* Can be used directly or extended in a WorkerEntrypoint.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```typescript
|
|
8
|
+
* import { R2Filesystem } from 'r2-fs';
|
|
9
|
+
* import { mount } from 'worker-fs-mount';
|
|
10
|
+
* import { env } from 'cloudflare:workers';
|
|
11
|
+
* import fs from 'node:fs/promises';
|
|
12
|
+
*
|
|
13
|
+
* // Mount at module level using importable env
|
|
14
|
+
* const r2fs = new R2Filesystem(env.MY_BUCKET);
|
|
15
|
+
* mount('/storage', r2fs);
|
|
16
|
+
*
|
|
17
|
+
* export default {
|
|
18
|
+
* async fetch(request: Request) {
|
|
19
|
+
* await fs.writeFile('/storage/hello.txt', 'Hello, World!');
|
|
20
|
+
* const content = await fs.readFile('/storage/hello.txt', 'utf8');
|
|
21
|
+
* return new Response(content);
|
|
22
|
+
* }
|
|
23
|
+
* }
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export declare class R2Filesystem implements WorkerFilesystem {
|
|
27
|
+
private bucket;
|
|
28
|
+
constructor(bucket: R2Bucket);
|
|
29
|
+
/**
|
|
30
|
+
* Parse R2 object metadata into our internal format.
|
|
31
|
+
*/
|
|
32
|
+
private parseMetadata;
|
|
33
|
+
/**
|
|
34
|
+
* Resolve symlinks in a path, following up to 40 levels deep.
|
|
35
|
+
*/
|
|
36
|
+
private resolveSymlinks;
|
|
37
|
+
/**
|
|
38
|
+
* Check if a directory exists (either implicitly via prefix or explicitly via marker).
|
|
39
|
+
*/
|
|
40
|
+
private directoryExists;
|
|
41
|
+
stat(path: string, options?: {
|
|
42
|
+
followSymlinks?: boolean;
|
|
43
|
+
}): Promise<Stat | null>;
|
|
44
|
+
createReadStream(path: string, options?: {
|
|
45
|
+
start?: number;
|
|
46
|
+
end?: number;
|
|
47
|
+
}): Promise<ReadableStream<Uint8Array>>;
|
|
48
|
+
createWriteStream(path: string, options?: {
|
|
49
|
+
start?: number;
|
|
50
|
+
flags?: 'w' | 'a' | 'r+';
|
|
51
|
+
}): Promise<WritableStream<Uint8Array>>;
|
|
52
|
+
readdir(path: string, options?: {
|
|
53
|
+
recursive?: boolean;
|
|
54
|
+
}): Promise<DirEntry[]>;
|
|
55
|
+
mkdir(path: string, options?: {
|
|
56
|
+
recursive?: boolean;
|
|
57
|
+
}): Promise<string | undefined>;
|
|
58
|
+
rm(path: string, options?: {
|
|
59
|
+
recursive?: boolean;
|
|
60
|
+
force?: boolean;
|
|
61
|
+
}): Promise<void>;
|
|
62
|
+
symlink(linkPath: string, targetPath: string): Promise<void>;
|
|
63
|
+
readlink(path: string): Promise<string>;
|
|
64
|
+
}
|
|
65
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,IAAI,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AA0BxE;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,qBAAa,YAAa,YAAW,gBAAgB;IACvC,OAAO,CAAC,MAAM;gBAAN,MAAM,EAAE,QAAQ;IAEpC;;OAEG;IACH,OAAO,CAAC,aAAa;IAYrB;;OAEG;YACW,eAAe;IAsB7B;;OAEG;YACW,eAAe;IAiBvB,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,cAAc,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,GAAG,IAAI,CAAC;IAgEhF,gBAAgB,CACpB,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,GAAG,CAAC,EAAE,MAAM,CAAA;KAAE,GACzC,OAAO,CAAC,cAAc,CAAC,UAAU,CAAC,CAAC;IAwBhC,iBAAiB,CACrB,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,GAAG,GAAG,GAAG,GAAG,IAAI,CAAA;KAAE,GACrD,OAAO,CAAC,cAAc,CAAC,UAAU,CAAC,CAAC;IAgFhC,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,SAAS,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC;IA2E7E,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,SAAS,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC;IAyCnF,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,SAAS,CAAC,EAAE,OAAO,CAAC;QAAC,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAkDnF,OAAO,CAAC,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAkC5D,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;CAgB9C"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import { createFsError, getParentPath, normalizePath, resolvePath } from 'worker-fs-mount/utils';
|
|
2
|
+
/**
|
|
3
|
+
* Convert a filesystem path to an R2 key.
|
|
4
|
+
* R2 keys don't have a leading slash.
|
|
5
|
+
*/
|
|
6
|
+
function pathToKey(path) {
|
|
7
|
+
const normalized = normalizePath(path);
|
|
8
|
+
return normalized === '/' ? '' : normalized.slice(1);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Suffix used to mark directory objects in R2.
|
|
12
|
+
*/
|
|
13
|
+
const DIR_MARKER = '.dir';
|
|
14
|
+
/**
|
|
15
|
+
* An R2-backed filesystem implementation.
|
|
16
|
+
* Can be used directly or extended in a WorkerEntrypoint.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```typescript
|
|
20
|
+
* import { R2Filesystem } from 'r2-fs';
|
|
21
|
+
* import { mount } from 'worker-fs-mount';
|
|
22
|
+
* import { env } from 'cloudflare:workers';
|
|
23
|
+
* import fs from 'node:fs/promises';
|
|
24
|
+
*
|
|
25
|
+
* // Mount at module level using importable env
|
|
26
|
+
* const r2fs = new R2Filesystem(env.MY_BUCKET);
|
|
27
|
+
* mount('/storage', r2fs);
|
|
28
|
+
*
|
|
29
|
+
* export default {
|
|
30
|
+
* async fetch(request: Request) {
|
|
31
|
+
* await fs.writeFile('/storage/hello.txt', 'Hello, World!');
|
|
32
|
+
* const content = await fs.readFile('/storage/hello.txt', 'utf8');
|
|
33
|
+
* return new Response(content);
|
|
34
|
+
* }
|
|
35
|
+
* }
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export class R2Filesystem {
|
|
39
|
+
bucket;
|
|
40
|
+
constructor(bucket) {
|
|
41
|
+
this.bucket = bucket;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Parse R2 object metadata into our internal format.
|
|
45
|
+
*/
|
|
46
|
+
parseMetadata(obj) {
|
|
47
|
+
const meta = obj.customMetadata;
|
|
48
|
+
return {
|
|
49
|
+
// biome-ignore lint/complexity/useLiteralKeys: TypeScript requires bracket notation for index signatures
|
|
50
|
+
type: meta?.['type'] ?? 'file',
|
|
51
|
+
// biome-ignore lint/complexity/useLiteralKeys: TypeScript requires bracket notation for index signatures
|
|
52
|
+
created: meta?.['created'] ?? obj.uploaded.toISOString(),
|
|
53
|
+
// biome-ignore lint/complexity/useLiteralKeys: TypeScript requires bracket notation for index signatures
|
|
54
|
+
symlinkTarget: meta?.['symlinkTarget'],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Resolve symlinks in a path, following up to 40 levels deep.
|
|
59
|
+
*/
|
|
60
|
+
async resolveSymlinks(path, depth = 0) {
|
|
61
|
+
if (depth > 40) {
|
|
62
|
+
throw createFsError('ELOOP', path);
|
|
63
|
+
}
|
|
64
|
+
const normalized = normalizePath(path);
|
|
65
|
+
if (normalized === '/')
|
|
66
|
+
return normalized;
|
|
67
|
+
const key = pathToKey(normalized);
|
|
68
|
+
const obj = await this.bucket.head(key);
|
|
69
|
+
if (!obj)
|
|
70
|
+
return normalized;
|
|
71
|
+
const meta = this.parseMetadata(obj);
|
|
72
|
+
if (meta.type !== 'symlink' || !meta.symlinkTarget) {
|
|
73
|
+
return normalized;
|
|
74
|
+
}
|
|
75
|
+
const target = resolvePath(getParentPath(normalized), meta.symlinkTarget);
|
|
76
|
+
return this.resolveSymlinks(target, depth + 1);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Check if a directory exists (either implicitly via prefix or explicitly via marker).
|
|
80
|
+
*/
|
|
81
|
+
async directoryExists(path) {
|
|
82
|
+
if (path === '/')
|
|
83
|
+
return true;
|
|
84
|
+
const key = pathToKey(path);
|
|
85
|
+
// Check for explicit directory marker
|
|
86
|
+
const dirMarker = await this.bucket.head(key + DIR_MARKER);
|
|
87
|
+
if (dirMarker)
|
|
88
|
+
return true;
|
|
89
|
+
// Check for any objects with this prefix (implicit directory)
|
|
90
|
+
const prefix = `${key}/`;
|
|
91
|
+
const listed = await this.bucket.list({ prefix, limit: 1 });
|
|
92
|
+
return listed.objects.length > 0;
|
|
93
|
+
}
|
|
94
|
+
// === Metadata Operations ===
|
|
95
|
+
async stat(path, options) {
|
|
96
|
+
let normalized = normalizePath(path);
|
|
97
|
+
// Handle root directory
|
|
98
|
+
if (normalized === '/') {
|
|
99
|
+
return {
|
|
100
|
+
type: 'directory',
|
|
101
|
+
size: 0,
|
|
102
|
+
writable: true,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
if (options?.followSymlinks !== false) {
|
|
106
|
+
try {
|
|
107
|
+
normalized = await this.resolveSymlinks(normalized);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const key = pathToKey(normalized);
|
|
114
|
+
// First check if it's a file or symlink
|
|
115
|
+
const obj = await this.bucket.head(key);
|
|
116
|
+
if (obj) {
|
|
117
|
+
const meta = this.parseMetadata(obj);
|
|
118
|
+
return {
|
|
119
|
+
type: meta.type,
|
|
120
|
+
size: obj.size,
|
|
121
|
+
created: new Date(meta.created),
|
|
122
|
+
lastModified: obj.uploaded,
|
|
123
|
+
writable: true,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
// Check if it's a directory
|
|
127
|
+
const dirMarker = await this.bucket.head(key + DIR_MARKER);
|
|
128
|
+
if (dirMarker) {
|
|
129
|
+
const meta = this.parseMetadata(dirMarker);
|
|
130
|
+
return {
|
|
131
|
+
type: 'directory',
|
|
132
|
+
size: 0,
|
|
133
|
+
created: new Date(meta.created),
|
|
134
|
+
lastModified: dirMarker.uploaded,
|
|
135
|
+
writable: true,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
// Check for implicit directory (objects with this prefix)
|
|
139
|
+
const prefix = `${key}/`;
|
|
140
|
+
const listed = await this.bucket.list({ prefix, limit: 1 });
|
|
141
|
+
if (listed.objects.length > 0) {
|
|
142
|
+
return {
|
|
143
|
+
type: 'directory',
|
|
144
|
+
size: 0,
|
|
145
|
+
writable: true,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
// === Streaming Operations ===
|
|
151
|
+
async createReadStream(path, options) {
|
|
152
|
+
const normalized = await this.resolveSymlinks(path);
|
|
153
|
+
const key = pathToKey(normalized);
|
|
154
|
+
const r2Options = {};
|
|
155
|
+
if (options?.start !== undefined || options?.end !== undefined) {
|
|
156
|
+
const start = options?.start ?? 0;
|
|
157
|
+
const length = options?.end !== undefined ? options.end - start + 1 : undefined;
|
|
158
|
+
r2Options.range = length !== undefined ? { offset: start, length } : { offset: start };
|
|
159
|
+
}
|
|
160
|
+
const obj = await this.bucket.get(key, r2Options);
|
|
161
|
+
if (!obj) {
|
|
162
|
+
throw createFsError('ENOENT', path);
|
|
163
|
+
}
|
|
164
|
+
const meta = this.parseMetadata(obj);
|
|
165
|
+
if (meta.type === 'directory') {
|
|
166
|
+
throw createFsError('EISDIR', path);
|
|
167
|
+
}
|
|
168
|
+
return obj.body;
|
|
169
|
+
}
|
|
170
|
+
async createWriteStream(path, options) {
|
|
171
|
+
const normalized = normalizePath(path);
|
|
172
|
+
const parentPath = getParentPath(normalized);
|
|
173
|
+
// Verify parent directory exists
|
|
174
|
+
if (parentPath !== '/') {
|
|
175
|
+
const parentExists = await this.directoryExists(parentPath);
|
|
176
|
+
if (!parentExists) {
|
|
177
|
+
throw createFsError('ENOENT', parentPath);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const key = pathToKey(normalized);
|
|
181
|
+
// Check if trying to write to a directory
|
|
182
|
+
const dirMarker = await this.bucket.head(key + DIR_MARKER);
|
|
183
|
+
if (dirMarker) {
|
|
184
|
+
throw createFsError('EISDIR', path);
|
|
185
|
+
}
|
|
186
|
+
const self = this;
|
|
187
|
+
let offset = options?.start ?? 0;
|
|
188
|
+
// Collect all chunks and write at once on close
|
|
189
|
+
const chunks = [];
|
|
190
|
+
let existingContent = null;
|
|
191
|
+
let existingMeta = null;
|
|
192
|
+
// Get existing content if needed
|
|
193
|
+
if (options?.flags === 'r+' || options?.flags === 'a') {
|
|
194
|
+
const existing = await this.bucket.get(key);
|
|
195
|
+
if (existing) {
|
|
196
|
+
existingContent = new Uint8Array(await existing.arrayBuffer());
|
|
197
|
+
existingMeta = this.parseMetadata(existing);
|
|
198
|
+
if (options?.flags === 'a') {
|
|
199
|
+
offset = existingContent.length;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
else if (options?.flags === 'r+') {
|
|
203
|
+
throw createFsError('ENOENT', path);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return new WritableStream({
|
|
207
|
+
write(chunk) {
|
|
208
|
+
chunks.push(chunk);
|
|
209
|
+
},
|
|
210
|
+
async close() {
|
|
211
|
+
// Combine all chunks
|
|
212
|
+
const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
|
|
213
|
+
const combinedChunks = new Uint8Array(totalLength);
|
|
214
|
+
let pos = 0;
|
|
215
|
+
for (const chunk of chunks) {
|
|
216
|
+
combinedChunks.set(chunk, pos);
|
|
217
|
+
pos += chunk.length;
|
|
218
|
+
}
|
|
219
|
+
// Build final content
|
|
220
|
+
let finalContent;
|
|
221
|
+
if (existingContent && options?.flags !== 'w') {
|
|
222
|
+
const newLength = Math.max(existingContent.length, offset + combinedChunks.length);
|
|
223
|
+
finalContent = new Uint8Array(newLength);
|
|
224
|
+
finalContent.set(existingContent, 0);
|
|
225
|
+
finalContent.set(combinedChunks, offset);
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
finalContent = combinedChunks;
|
|
229
|
+
}
|
|
230
|
+
const now = new Date().toISOString();
|
|
231
|
+
await self.bucket.put(key, finalContent, {
|
|
232
|
+
customMetadata: {
|
|
233
|
+
type: 'file',
|
|
234
|
+
created: existingMeta?.created ?? now,
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
// === Directory Operations ===
|
|
241
|
+
async readdir(path, options) {
|
|
242
|
+
const normalized = normalizePath(path);
|
|
243
|
+
// Verify directory exists
|
|
244
|
+
const stat = await this.stat(normalized);
|
|
245
|
+
if (!stat) {
|
|
246
|
+
throw createFsError('ENOENT', path);
|
|
247
|
+
}
|
|
248
|
+
if (stat.type !== 'directory') {
|
|
249
|
+
throw createFsError('ENOTDIR', path);
|
|
250
|
+
}
|
|
251
|
+
const prefix = normalized === '/' ? '' : `${pathToKey(normalized)}/`;
|
|
252
|
+
const entries = [];
|
|
253
|
+
const seenDirs = new Set();
|
|
254
|
+
let cursor;
|
|
255
|
+
do {
|
|
256
|
+
const listOptions = { prefix };
|
|
257
|
+
if (!options?.recursive) {
|
|
258
|
+
listOptions.delimiter = '/';
|
|
259
|
+
}
|
|
260
|
+
if (cursor) {
|
|
261
|
+
listOptions.cursor = cursor;
|
|
262
|
+
}
|
|
263
|
+
const listed = await this.bucket.list(listOptions);
|
|
264
|
+
// Process objects
|
|
265
|
+
for (const obj of listed.objects) {
|
|
266
|
+
// Skip directory markers
|
|
267
|
+
if (obj.key.endsWith(DIR_MARKER)) {
|
|
268
|
+
// Extract directory name from marker
|
|
269
|
+
const dirKey = obj.key.slice(0, -DIR_MARKER.length);
|
|
270
|
+
const relativePath = prefix ? dirKey.slice(prefix.length) : dirKey;
|
|
271
|
+
if (relativePath && !seenDirs.has(relativePath)) {
|
|
272
|
+
seenDirs.add(relativePath);
|
|
273
|
+
entries.push({
|
|
274
|
+
name: relativePath,
|
|
275
|
+
type: 'directory',
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const relativePath = prefix ? obj.key.slice(prefix.length) : obj.key;
|
|
281
|
+
if (!relativePath)
|
|
282
|
+
continue;
|
|
283
|
+
const meta = this.parseMetadata(obj);
|
|
284
|
+
entries.push({
|
|
285
|
+
name: relativePath,
|
|
286
|
+
type: meta.type,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
// Process common prefixes (implicit directories from delimiter)
|
|
290
|
+
if (!options?.recursive && listed.delimitedPrefixes) {
|
|
291
|
+
for (const dirPrefix of listed.delimitedPrefixes) {
|
|
292
|
+
const dirName = dirPrefix.slice(prefix.length, -1); // Remove trailing /
|
|
293
|
+
if (dirName && !seenDirs.has(dirName)) {
|
|
294
|
+
seenDirs.add(dirName);
|
|
295
|
+
entries.push({
|
|
296
|
+
name: dirName,
|
|
297
|
+
type: 'directory',
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
cursor = listed.truncated ? listed.cursor : undefined;
|
|
303
|
+
} while (cursor);
|
|
304
|
+
return entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
305
|
+
}
|
|
306
|
+
async mkdir(path, options) {
|
|
307
|
+
const normalized = normalizePath(path);
|
|
308
|
+
if (normalized === '/') {
|
|
309
|
+
if (options?.recursive)
|
|
310
|
+
return undefined;
|
|
311
|
+
throw createFsError('EEXIST', path);
|
|
312
|
+
}
|
|
313
|
+
const key = pathToKey(normalized);
|
|
314
|
+
// Check if already exists
|
|
315
|
+
const existing = await this.stat(normalized);
|
|
316
|
+
if (existing) {
|
|
317
|
+
if (options?.recursive)
|
|
318
|
+
return undefined;
|
|
319
|
+
throw createFsError('EEXIST', path);
|
|
320
|
+
}
|
|
321
|
+
// Verify parent exists
|
|
322
|
+
const parentPath = getParentPath(normalized);
|
|
323
|
+
if (parentPath !== '/') {
|
|
324
|
+
const parentExists = await this.directoryExists(parentPath);
|
|
325
|
+
if (!parentExists) {
|
|
326
|
+
if (options?.recursive) {
|
|
327
|
+
await this.mkdir(parentPath, { recursive: true });
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
throw createFsError('ENOENT', parentPath);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
// Create directory marker
|
|
335
|
+
await this.bucket.put(key + DIR_MARKER, new Uint8Array(0), {
|
|
336
|
+
customMetadata: {
|
|
337
|
+
type: 'directory',
|
|
338
|
+
created: new Date().toISOString(),
|
|
339
|
+
},
|
|
340
|
+
});
|
|
341
|
+
return normalized;
|
|
342
|
+
}
|
|
343
|
+
async rm(path, options) {
|
|
344
|
+
const normalized = normalizePath(path);
|
|
345
|
+
if (normalized === '/') {
|
|
346
|
+
throw createFsError('EINVAL', path);
|
|
347
|
+
}
|
|
348
|
+
const stat = await this.stat(normalized);
|
|
349
|
+
if (!stat) {
|
|
350
|
+
if (options?.force)
|
|
351
|
+
return;
|
|
352
|
+
throw createFsError('ENOENT', path);
|
|
353
|
+
}
|
|
354
|
+
const key = pathToKey(normalized);
|
|
355
|
+
if (stat.type === 'directory') {
|
|
356
|
+
const prefix = `${key}/`;
|
|
357
|
+
// Check for children
|
|
358
|
+
const listed = await this.bucket.list({ prefix, limit: 1 });
|
|
359
|
+
if (listed.objects.length > 0) {
|
|
360
|
+
if (!options?.recursive) {
|
|
361
|
+
throw createFsError('ENOTEMPTY', path);
|
|
362
|
+
}
|
|
363
|
+
// Delete all children
|
|
364
|
+
let cursor;
|
|
365
|
+
do {
|
|
366
|
+
const listOptions = { prefix, limit: 1000 };
|
|
367
|
+
if (cursor) {
|
|
368
|
+
listOptions.cursor = cursor;
|
|
369
|
+
}
|
|
370
|
+
const batch = await this.bucket.list(listOptions);
|
|
371
|
+
if (batch.objects.length > 0) {
|
|
372
|
+
await this.bucket.delete(batch.objects.map((o) => o.key));
|
|
373
|
+
}
|
|
374
|
+
cursor = batch.truncated ? batch.cursor : undefined;
|
|
375
|
+
} while (cursor);
|
|
376
|
+
}
|
|
377
|
+
// Delete directory marker if it exists
|
|
378
|
+
await this.bucket.delete(key + DIR_MARKER);
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
// Delete file or symlink
|
|
382
|
+
await this.bucket.delete(key);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
// === Link Operations ===
|
|
386
|
+
async symlink(linkPath, targetPath) {
|
|
387
|
+
const normalizedLink = normalizePath(linkPath);
|
|
388
|
+
const parentPath = getParentPath(normalizedLink);
|
|
389
|
+
// Verify parent exists
|
|
390
|
+
if (parentPath !== '/') {
|
|
391
|
+
const parentExists = await this.directoryExists(parentPath);
|
|
392
|
+
if (!parentExists) {
|
|
393
|
+
throw createFsError('ENOENT', parentPath);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
// Check link doesn't exist
|
|
397
|
+
const key = pathToKey(normalizedLink);
|
|
398
|
+
const existing = await this.bucket.head(key);
|
|
399
|
+
if (existing) {
|
|
400
|
+
throw createFsError('EEXIST', linkPath);
|
|
401
|
+
}
|
|
402
|
+
// Check it's not a directory
|
|
403
|
+
const dirMarker = await this.bucket.head(key + DIR_MARKER);
|
|
404
|
+
if (dirMarker) {
|
|
405
|
+
throw createFsError('EEXIST', linkPath);
|
|
406
|
+
}
|
|
407
|
+
await this.bucket.put(key, new Uint8Array(0), {
|
|
408
|
+
customMetadata: {
|
|
409
|
+
type: 'symlink',
|
|
410
|
+
created: new Date().toISOString(),
|
|
411
|
+
symlinkTarget: targetPath,
|
|
412
|
+
},
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
async readlink(path) {
|
|
416
|
+
const normalized = normalizePath(path);
|
|
417
|
+
const key = pathToKey(normalized);
|
|
418
|
+
const obj = await this.bucket.head(key);
|
|
419
|
+
if (!obj) {
|
|
420
|
+
throw createFsError('ENOENT', path);
|
|
421
|
+
}
|
|
422
|
+
const meta = this.parseMetadata(obj);
|
|
423
|
+
if (meta.type !== 'symlink' || !meta.symlinkTarget) {
|
|
424
|
+
throw createFsError('EINVAL', path);
|
|
425
|
+
}
|
|
426
|
+
return meta.symlinkTarget;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "r2-fs",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "R2-backed filesystem for Cloudflare Workers",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"require": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"src"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc",
|
|
21
|
+
"dev": "tsc --watch",
|
|
22
|
+
"clean": "rm -rf dist",
|
|
23
|
+
"prepublishOnly": "npm run clean && npm run build",
|
|
24
|
+
"typecheck": "tsc --noEmit"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"cloudflare",
|
|
28
|
+
"workers",
|
|
29
|
+
"r2",
|
|
30
|
+
"filesystem",
|
|
31
|
+
"storage"
|
|
32
|
+
],
|
|
33
|
+
"author": "Cloudflare",
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/cloudflare/worker-fs-mount.git",
|
|
38
|
+
"directory": "packages/r2-fs"
|
|
39
|
+
},
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/cloudflare/worker-fs-mount/issues"
|
|
42
|
+
},
|
|
43
|
+
"homepage": "https://github.com/cloudflare/worker-fs-mount/tree/main/packages/r2-fs#readme",
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"worker-fs-mount": "workspace:*"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@cloudflare/workers-types": "^4.20251225.0",
|
|
49
|
+
"@types/node": "^25.0.3",
|
|
50
|
+
"typescript": "^5.7.2",
|
|
51
|
+
"worker-fs-mount": "workspace:*"
|
|
52
|
+
},
|
|
53
|
+
"engines": {
|
|
54
|
+
"node": ">=18.0.0"
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import type { DirEntry, Stat, WorkerFilesystem } from 'worker-fs-mount';
|
|
2
|
+
import { createFsError, getParentPath, normalizePath, resolvePath } from 'worker-fs-mount/utils';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Convert a filesystem path to an R2 key.
|
|
6
|
+
* R2 keys don't have a leading slash.
|
|
7
|
+
*/
|
|
8
|
+
function pathToKey(path: string): string {
|
|
9
|
+
const normalized = normalizePath(path);
|
|
10
|
+
return normalized === '/' ? '' : normalized.slice(1);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Metadata stored in R2 customMetadata for each object.
|
|
15
|
+
*/
|
|
16
|
+
interface R2FsMetadata {
|
|
17
|
+
type: 'file' | 'directory' | 'symlink';
|
|
18
|
+
created: string; // ISO timestamp
|
|
19
|
+
symlinkTarget?: string | undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Suffix used to mark directory objects in R2.
|
|
24
|
+
*/
|
|
25
|
+
const DIR_MARKER = '.dir';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* An R2-backed filesystem implementation.
|
|
29
|
+
* Can be used directly or extended in a WorkerEntrypoint.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```typescript
|
|
33
|
+
* import { R2Filesystem } from 'r2-fs';
|
|
34
|
+
* import { mount } from 'worker-fs-mount';
|
|
35
|
+
* import { env } from 'cloudflare:workers';
|
|
36
|
+
* import fs from 'node:fs/promises';
|
|
37
|
+
*
|
|
38
|
+
* // Mount at module level using importable env
|
|
39
|
+
* const r2fs = new R2Filesystem(env.MY_BUCKET);
|
|
40
|
+
* mount('/storage', r2fs);
|
|
41
|
+
*
|
|
42
|
+
* export default {
|
|
43
|
+
* async fetch(request: Request) {
|
|
44
|
+
* await fs.writeFile('/storage/hello.txt', 'Hello, World!');
|
|
45
|
+
* const content = await fs.readFile('/storage/hello.txt', 'utf8');
|
|
46
|
+
* return new Response(content);
|
|
47
|
+
* }
|
|
48
|
+
* }
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
export class R2Filesystem implements WorkerFilesystem {
|
|
52
|
+
constructor(private bucket: R2Bucket) {}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Parse R2 object metadata into our internal format.
|
|
56
|
+
*/
|
|
57
|
+
private parseMetadata(obj: R2Object): R2FsMetadata {
|
|
58
|
+
const meta = obj.customMetadata;
|
|
59
|
+
return {
|
|
60
|
+
// biome-ignore lint/complexity/useLiteralKeys: TypeScript requires bracket notation for index signatures
|
|
61
|
+
type: (meta?.['type'] as R2FsMetadata['type']) ?? 'file',
|
|
62
|
+
// biome-ignore lint/complexity/useLiteralKeys: TypeScript requires bracket notation for index signatures
|
|
63
|
+
created: meta?.['created'] ?? obj.uploaded.toISOString(),
|
|
64
|
+
// biome-ignore lint/complexity/useLiteralKeys: TypeScript requires bracket notation for index signatures
|
|
65
|
+
symlinkTarget: meta?.['symlinkTarget'],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Resolve symlinks in a path, following up to 40 levels deep.
|
|
71
|
+
*/
|
|
72
|
+
private async resolveSymlinks(path: string, depth = 0): Promise<string> {
|
|
73
|
+
if (depth > 40) {
|
|
74
|
+
throw createFsError('ELOOP', path);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const normalized = normalizePath(path);
|
|
78
|
+
if (normalized === '/') return normalized;
|
|
79
|
+
|
|
80
|
+
const key = pathToKey(normalized);
|
|
81
|
+
const obj = await this.bucket.head(key);
|
|
82
|
+
|
|
83
|
+
if (!obj) return normalized;
|
|
84
|
+
|
|
85
|
+
const meta = this.parseMetadata(obj);
|
|
86
|
+
if (meta.type !== 'symlink' || !meta.symlinkTarget) {
|
|
87
|
+
return normalized;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const target = resolvePath(getParentPath(normalized), meta.symlinkTarget);
|
|
91
|
+
return this.resolveSymlinks(target, depth + 1);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Check if a directory exists (either implicitly via prefix or explicitly via marker).
|
|
96
|
+
*/
|
|
97
|
+
private async directoryExists(path: string): Promise<boolean> {
|
|
98
|
+
if (path === '/') return true;
|
|
99
|
+
|
|
100
|
+
const key = pathToKey(path);
|
|
101
|
+
|
|
102
|
+
// Check for explicit directory marker
|
|
103
|
+
const dirMarker = await this.bucket.head(key + DIR_MARKER);
|
|
104
|
+
if (dirMarker) return true;
|
|
105
|
+
|
|
106
|
+
// Check for any objects with this prefix (implicit directory)
|
|
107
|
+
const prefix = `${key}/`;
|
|
108
|
+
const listed = await this.bucket.list({ prefix, limit: 1 });
|
|
109
|
+
return listed.objects.length > 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// === Metadata Operations ===
|
|
113
|
+
|
|
114
|
+
async stat(path: string, options?: { followSymlinks?: boolean }): Promise<Stat | null> {
|
|
115
|
+
let normalized = normalizePath(path);
|
|
116
|
+
|
|
117
|
+
// Handle root directory
|
|
118
|
+
if (normalized === '/') {
|
|
119
|
+
return {
|
|
120
|
+
type: 'directory',
|
|
121
|
+
size: 0,
|
|
122
|
+
writable: true,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (options?.followSymlinks !== false) {
|
|
127
|
+
try {
|
|
128
|
+
normalized = await this.resolveSymlinks(normalized);
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const key = pathToKey(normalized);
|
|
135
|
+
|
|
136
|
+
// First check if it's a file or symlink
|
|
137
|
+
const obj = await this.bucket.head(key);
|
|
138
|
+
if (obj) {
|
|
139
|
+
const meta = this.parseMetadata(obj);
|
|
140
|
+
return {
|
|
141
|
+
type: meta.type,
|
|
142
|
+
size: obj.size,
|
|
143
|
+
created: new Date(meta.created),
|
|
144
|
+
lastModified: obj.uploaded,
|
|
145
|
+
writable: true,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Check if it's a directory
|
|
150
|
+
const dirMarker = await this.bucket.head(key + DIR_MARKER);
|
|
151
|
+
if (dirMarker) {
|
|
152
|
+
const meta = this.parseMetadata(dirMarker);
|
|
153
|
+
return {
|
|
154
|
+
type: 'directory',
|
|
155
|
+
size: 0,
|
|
156
|
+
created: new Date(meta.created),
|
|
157
|
+
lastModified: dirMarker.uploaded,
|
|
158
|
+
writable: true,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Check for implicit directory (objects with this prefix)
|
|
163
|
+
const prefix = `${key}/`;
|
|
164
|
+
const listed = await this.bucket.list({ prefix, limit: 1 });
|
|
165
|
+
if (listed.objects.length > 0) {
|
|
166
|
+
return {
|
|
167
|
+
type: 'directory',
|
|
168
|
+
size: 0,
|
|
169
|
+
writable: true,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// === Streaming Operations ===
|
|
177
|
+
|
|
178
|
+
async createReadStream(
|
|
179
|
+
path: string,
|
|
180
|
+
options?: { start?: number; end?: number }
|
|
181
|
+
): Promise<ReadableStream<Uint8Array>> {
|
|
182
|
+
const normalized = await this.resolveSymlinks(path);
|
|
183
|
+
const key = pathToKey(normalized);
|
|
184
|
+
|
|
185
|
+
const r2Options: R2GetOptions = {};
|
|
186
|
+
if (options?.start !== undefined || options?.end !== undefined) {
|
|
187
|
+
const start = options?.start ?? 0;
|
|
188
|
+
const length = options?.end !== undefined ? options.end - start + 1 : undefined;
|
|
189
|
+
r2Options.range = length !== undefined ? { offset: start, length } : { offset: start };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const obj = await this.bucket.get(key, r2Options);
|
|
193
|
+
if (!obj) {
|
|
194
|
+
throw createFsError('ENOENT', path);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const meta = this.parseMetadata(obj);
|
|
198
|
+
if (meta.type === 'directory') {
|
|
199
|
+
throw createFsError('EISDIR', path);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return obj.body;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async createWriteStream(
|
|
206
|
+
path: string,
|
|
207
|
+
options?: { start?: number; flags?: 'w' | 'a' | 'r+' }
|
|
208
|
+
): Promise<WritableStream<Uint8Array>> {
|
|
209
|
+
const normalized = normalizePath(path);
|
|
210
|
+
const parentPath = getParentPath(normalized);
|
|
211
|
+
|
|
212
|
+
// Verify parent directory exists
|
|
213
|
+
if (parentPath !== '/') {
|
|
214
|
+
const parentExists = await this.directoryExists(parentPath);
|
|
215
|
+
if (!parentExists) {
|
|
216
|
+
throw createFsError('ENOENT', parentPath);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const key = pathToKey(normalized);
|
|
221
|
+
|
|
222
|
+
// Check if trying to write to a directory
|
|
223
|
+
const dirMarker = await this.bucket.head(key + DIR_MARKER);
|
|
224
|
+
if (dirMarker) {
|
|
225
|
+
throw createFsError('EISDIR', path);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const self = this;
|
|
229
|
+
let offset = options?.start ?? 0;
|
|
230
|
+
|
|
231
|
+
// Collect all chunks and write at once on close
|
|
232
|
+
const chunks: Uint8Array[] = [];
|
|
233
|
+
let existingContent: Uint8Array | null = null;
|
|
234
|
+
let existingMeta: R2FsMetadata | null = null;
|
|
235
|
+
|
|
236
|
+
// Get existing content if needed
|
|
237
|
+
if (options?.flags === 'r+' || options?.flags === 'a') {
|
|
238
|
+
const existing = await this.bucket.get(key);
|
|
239
|
+
if (existing) {
|
|
240
|
+
existingContent = new Uint8Array(await existing.arrayBuffer());
|
|
241
|
+
existingMeta = this.parseMetadata(existing);
|
|
242
|
+
if (options?.flags === 'a') {
|
|
243
|
+
offset = existingContent.length;
|
|
244
|
+
}
|
|
245
|
+
} else if (options?.flags === 'r+') {
|
|
246
|
+
throw createFsError('ENOENT', path);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return new WritableStream({
|
|
251
|
+
write(chunk) {
|
|
252
|
+
chunks.push(chunk);
|
|
253
|
+
},
|
|
254
|
+
async close() {
|
|
255
|
+
// Combine all chunks
|
|
256
|
+
const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
|
|
257
|
+
const combinedChunks = new Uint8Array(totalLength);
|
|
258
|
+
let pos = 0;
|
|
259
|
+
for (const chunk of chunks) {
|
|
260
|
+
combinedChunks.set(chunk, pos);
|
|
261
|
+
pos += chunk.length;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Build final content
|
|
265
|
+
let finalContent: Uint8Array;
|
|
266
|
+
if (existingContent && options?.flags !== 'w') {
|
|
267
|
+
const newLength = Math.max(existingContent.length, offset + combinedChunks.length);
|
|
268
|
+
finalContent = new Uint8Array(newLength);
|
|
269
|
+
finalContent.set(existingContent, 0);
|
|
270
|
+
finalContent.set(combinedChunks, offset);
|
|
271
|
+
} else {
|
|
272
|
+
finalContent = combinedChunks;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const now = new Date().toISOString();
|
|
276
|
+
await self.bucket.put(key, finalContent, {
|
|
277
|
+
customMetadata: {
|
|
278
|
+
type: 'file',
|
|
279
|
+
created: existingMeta?.created ?? now,
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// === Directory Operations ===
|
|
287
|
+
|
|
288
|
+
async readdir(path: string, options?: { recursive?: boolean }): Promise<DirEntry[]> {
|
|
289
|
+
const normalized = normalizePath(path);
|
|
290
|
+
|
|
291
|
+
// Verify directory exists
|
|
292
|
+
const stat = await this.stat(normalized);
|
|
293
|
+
if (!stat) {
|
|
294
|
+
throw createFsError('ENOENT', path);
|
|
295
|
+
}
|
|
296
|
+
if (stat.type !== 'directory') {
|
|
297
|
+
throw createFsError('ENOTDIR', path);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const prefix = normalized === '/' ? '' : `${pathToKey(normalized)}/`;
|
|
301
|
+
const entries: DirEntry[] = [];
|
|
302
|
+
const seenDirs = new Set<string>();
|
|
303
|
+
|
|
304
|
+
let cursor: string | undefined;
|
|
305
|
+
|
|
306
|
+
do {
|
|
307
|
+
const listOptions: R2ListOptions = { prefix };
|
|
308
|
+
if (!options?.recursive) {
|
|
309
|
+
listOptions.delimiter = '/';
|
|
310
|
+
}
|
|
311
|
+
if (cursor) {
|
|
312
|
+
listOptions.cursor = cursor;
|
|
313
|
+
}
|
|
314
|
+
const listed = await this.bucket.list(listOptions);
|
|
315
|
+
|
|
316
|
+
// Process objects
|
|
317
|
+
for (const obj of listed.objects) {
|
|
318
|
+
// Skip directory markers
|
|
319
|
+
if (obj.key.endsWith(DIR_MARKER)) {
|
|
320
|
+
// Extract directory name from marker
|
|
321
|
+
const dirKey = obj.key.slice(0, -DIR_MARKER.length);
|
|
322
|
+
const relativePath = prefix ? dirKey.slice(prefix.length) : dirKey;
|
|
323
|
+
if (relativePath && !seenDirs.has(relativePath)) {
|
|
324
|
+
seenDirs.add(relativePath);
|
|
325
|
+
entries.push({
|
|
326
|
+
name: relativePath,
|
|
327
|
+
type: 'directory',
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const relativePath = prefix ? obj.key.slice(prefix.length) : obj.key;
|
|
334
|
+
if (!relativePath) continue;
|
|
335
|
+
|
|
336
|
+
const meta = this.parseMetadata(obj);
|
|
337
|
+
entries.push({
|
|
338
|
+
name: relativePath,
|
|
339
|
+
type: meta.type,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Process common prefixes (implicit directories from delimiter)
|
|
344
|
+
if (!options?.recursive && listed.delimitedPrefixes) {
|
|
345
|
+
for (const dirPrefix of listed.delimitedPrefixes) {
|
|
346
|
+
const dirName = dirPrefix.slice(prefix.length, -1); // Remove trailing /
|
|
347
|
+
if (dirName && !seenDirs.has(dirName)) {
|
|
348
|
+
seenDirs.add(dirName);
|
|
349
|
+
entries.push({
|
|
350
|
+
name: dirName,
|
|
351
|
+
type: 'directory',
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
cursor = listed.truncated ? listed.cursor : undefined;
|
|
358
|
+
} while (cursor);
|
|
359
|
+
|
|
360
|
+
return entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async mkdir(path: string, options?: { recursive?: boolean }): Promise<string | undefined> {
|
|
364
|
+
const normalized = normalizePath(path);
|
|
365
|
+
|
|
366
|
+
if (normalized === '/') {
|
|
367
|
+
if (options?.recursive) return undefined;
|
|
368
|
+
throw createFsError('EEXIST', path);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const key = pathToKey(normalized);
|
|
372
|
+
|
|
373
|
+
// Check if already exists
|
|
374
|
+
const existing = await this.stat(normalized);
|
|
375
|
+
if (existing) {
|
|
376
|
+
if (options?.recursive) return undefined;
|
|
377
|
+
throw createFsError('EEXIST', path);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Verify parent exists
|
|
381
|
+
const parentPath = getParentPath(normalized);
|
|
382
|
+
if (parentPath !== '/') {
|
|
383
|
+
const parentExists = await this.directoryExists(parentPath);
|
|
384
|
+
if (!parentExists) {
|
|
385
|
+
if (options?.recursive) {
|
|
386
|
+
await this.mkdir(parentPath, { recursive: true });
|
|
387
|
+
} else {
|
|
388
|
+
throw createFsError('ENOENT', parentPath);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Create directory marker
|
|
394
|
+
await this.bucket.put(key + DIR_MARKER, new Uint8Array(0), {
|
|
395
|
+
customMetadata: {
|
|
396
|
+
type: 'directory',
|
|
397
|
+
created: new Date().toISOString(),
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
return normalized;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async rm(path: string, options?: { recursive?: boolean; force?: boolean }): Promise<void> {
|
|
405
|
+
const normalized = normalizePath(path);
|
|
406
|
+
|
|
407
|
+
if (normalized === '/') {
|
|
408
|
+
throw createFsError('EINVAL', path);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const stat = await this.stat(normalized);
|
|
412
|
+
if (!stat) {
|
|
413
|
+
if (options?.force) return;
|
|
414
|
+
throw createFsError('ENOENT', path);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const key = pathToKey(normalized);
|
|
418
|
+
|
|
419
|
+
if (stat.type === 'directory') {
|
|
420
|
+
const prefix = `${key}/`;
|
|
421
|
+
|
|
422
|
+
// Check for children
|
|
423
|
+
const listed = await this.bucket.list({ prefix, limit: 1 });
|
|
424
|
+
if (listed.objects.length > 0) {
|
|
425
|
+
if (!options?.recursive) {
|
|
426
|
+
throw createFsError('ENOTEMPTY', path);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Delete all children
|
|
430
|
+
let cursor: string | undefined;
|
|
431
|
+
do {
|
|
432
|
+
const listOptions: R2ListOptions = { prefix, limit: 1000 };
|
|
433
|
+
if (cursor) {
|
|
434
|
+
listOptions.cursor = cursor;
|
|
435
|
+
}
|
|
436
|
+
const batch = await this.bucket.list(listOptions);
|
|
437
|
+
if (batch.objects.length > 0) {
|
|
438
|
+
await this.bucket.delete(batch.objects.map((o) => o.key));
|
|
439
|
+
}
|
|
440
|
+
cursor = batch.truncated ? batch.cursor : undefined;
|
|
441
|
+
} while (cursor);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Delete directory marker if it exists
|
|
445
|
+
await this.bucket.delete(key + DIR_MARKER);
|
|
446
|
+
} else {
|
|
447
|
+
// Delete file or symlink
|
|
448
|
+
await this.bucket.delete(key);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// === Link Operations ===
|
|
453
|
+
|
|
454
|
+
async symlink(linkPath: string, targetPath: string): Promise<void> {
|
|
455
|
+
const normalizedLink = normalizePath(linkPath);
|
|
456
|
+
const parentPath = getParentPath(normalizedLink);
|
|
457
|
+
|
|
458
|
+
// Verify parent exists
|
|
459
|
+
if (parentPath !== '/') {
|
|
460
|
+
const parentExists = await this.directoryExists(parentPath);
|
|
461
|
+
if (!parentExists) {
|
|
462
|
+
throw createFsError('ENOENT', parentPath);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Check link doesn't exist
|
|
467
|
+
const key = pathToKey(normalizedLink);
|
|
468
|
+
const existing = await this.bucket.head(key);
|
|
469
|
+
if (existing) {
|
|
470
|
+
throw createFsError('EEXIST', linkPath);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Check it's not a directory
|
|
474
|
+
const dirMarker = await this.bucket.head(key + DIR_MARKER);
|
|
475
|
+
if (dirMarker) {
|
|
476
|
+
throw createFsError('EEXIST', linkPath);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
await this.bucket.put(key, new Uint8Array(0), {
|
|
480
|
+
customMetadata: {
|
|
481
|
+
type: 'symlink',
|
|
482
|
+
created: new Date().toISOString(),
|
|
483
|
+
symlinkTarget: targetPath,
|
|
484
|
+
},
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async readlink(path: string): Promise<string> {
|
|
489
|
+
const normalized = normalizePath(path);
|
|
490
|
+
const key = pathToKey(normalized);
|
|
491
|
+
|
|
492
|
+
const obj = await this.bucket.head(key);
|
|
493
|
+
if (!obj) {
|
|
494
|
+
throw createFsError('ENOENT', path);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const meta = this.parseMetadata(obj);
|
|
498
|
+
if (meta.type !== 'symlink' || !meta.symlinkTarget) {
|
|
499
|
+
throw createFsError('EINVAL', path);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return meta.symlinkTarget;
|
|
503
|
+
}
|
|
504
|
+
}
|