worker-fs-mount 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 +360 -0
- package/dist/fs-promises.d.ts +83 -0
- package/dist/fs-promises.d.ts.map +1 -0
- package/dist/fs-promises.js +688 -0
- package/dist/fs-promises.js.map +1 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +41 -0
- package/dist/index.js.map +1 -0
- package/dist/registry.d.ts +101 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +165 -0
- package/dist/registry.js.map +1 -0
- package/dist/types.d.ts +95 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/utils.d.ts +40 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +80 -0
- package/dist/utils.js.map +1 -0
- package/package.json +65 -0
- package/src/fs-promises.ts +832 -0
- package/src/index.ts +50 -0
- package/src/registry.ts +199 -0
- package/src/types.ts +102 -0
- package/src/utils.ts +95 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* worker-fs-mount
|
|
3
|
+
*
|
|
4
|
+
* Mount WorkerEntrypoints as virtual filesystems in Cloudflare Workers.
|
|
5
|
+
*
|
|
6
|
+
* ## Setup
|
|
7
|
+
*
|
|
8
|
+
* Add the following alias to your wrangler.toml:
|
|
9
|
+
*
|
|
10
|
+
* ```toml
|
|
11
|
+
* [alias]
|
|
12
|
+
* "node:fs/promises" = "worker-fs-mount/fs"
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* ## Usage
|
|
16
|
+
*
|
|
17
|
+
* ```typescript
|
|
18
|
+
* import { withMounts, mount } from 'worker-fs-mount';
|
|
19
|
+
* import fs from 'node:fs/promises';
|
|
20
|
+
*
|
|
21
|
+
* export default {
|
|
22
|
+
* async fetch(request: Request, env: Env) {
|
|
23
|
+
* return withMounts(async () => {
|
|
24
|
+
* // Mount a WorkerEntrypoint (scoped to this request)
|
|
25
|
+
* mount('/mnt/storage', env.STORAGE_SERVICE);
|
|
26
|
+
*
|
|
27
|
+
* // Use standard fs operations - they're automatically intercepted
|
|
28
|
+
* await fs.writeFile('/mnt/storage/file.txt', 'Hello, World!');
|
|
29
|
+
* const content = await fs.readFile('/mnt/storage/file.txt', 'utf8');
|
|
30
|
+
*
|
|
31
|
+
* return new Response(content);
|
|
32
|
+
* }); // Mounts automatically cleaned up
|
|
33
|
+
* }
|
|
34
|
+
* };
|
|
35
|
+
* ```
|
|
36
|
+
*
|
|
37
|
+
* @packageDocumentation
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
// Export public API
|
|
41
|
+
export {
|
|
42
|
+
isInMountContext,
|
|
43
|
+
isMounted,
|
|
44
|
+
mount,
|
|
45
|
+
unmount,
|
|
46
|
+
withMounts,
|
|
47
|
+
} from './registry.js';
|
|
48
|
+
|
|
49
|
+
// Export types
|
|
50
|
+
export type { DirEntry, Stat, WorkerFilesystem } from './types.js';
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import type { WorkerFilesystem } from './types.js';
|
|
3
|
+
import { normalizePath } from './utils.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Internal mount structure.
|
|
7
|
+
*/
|
|
8
|
+
interface Mount {
|
|
9
|
+
path: string;
|
|
10
|
+
stub: WorkerFilesystem;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Result of finding a mount for a path.
|
|
15
|
+
*/
|
|
16
|
+
interface MountMatch {
|
|
17
|
+
mount: Mount;
|
|
18
|
+
relativePath: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* AsyncLocalStorage for request-scoped mounts.
|
|
23
|
+
* Each request can have its own isolated mount registry.
|
|
24
|
+
*/
|
|
25
|
+
const mountStorage = new AsyncLocalStorage<Map<string, Mount>>();
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Global mount registry (fallback for backwards compatibility).
|
|
29
|
+
* Used when mount() is called outside of withMounts().
|
|
30
|
+
*/
|
|
31
|
+
const globalMounts = new Map<string, Mount>();
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Get the current mount registry (request-scoped or global fallback).
|
|
35
|
+
*/
|
|
36
|
+
function getMountRegistry(): Map<string, Mount> {
|
|
37
|
+
return mountStorage.getStore() ?? globalMounts;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Reserved paths that cannot be mounted over.
|
|
42
|
+
*/
|
|
43
|
+
const RESERVED_PATHS = ['/bundle', '/tmp', '/dev'];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Validate a mount path.
|
|
47
|
+
* @throws If the path is invalid
|
|
48
|
+
*/
|
|
49
|
+
function validateMountPath(path: string): void {
|
|
50
|
+
if (!path.startsWith('/')) {
|
|
51
|
+
throw new Error(`Mount path must be absolute (start with /): ${path}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Check reserved paths
|
|
55
|
+
for (const reserved of RESERVED_PATHS) {
|
|
56
|
+
if (path === reserved || path.startsWith(`${reserved}/`)) {
|
|
57
|
+
throw new Error(`Cannot mount over reserved path: ${reserved}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Run a function with an isolated mount context.
|
|
64
|
+
* Mounts created within the callback are scoped to that request
|
|
65
|
+
* and automatically cleaned up when the callback completes.
|
|
66
|
+
*
|
|
67
|
+
* @param fn - The function to run with isolated mounts
|
|
68
|
+
* @returns The result of the function
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* ```typescript
|
|
72
|
+
* import { withMounts, mount } from 'worker-fs-mount';
|
|
73
|
+
* import fs from 'node:fs/promises';
|
|
74
|
+
*
|
|
75
|
+
* export default {
|
|
76
|
+
* async fetch(request: Request, env: Env) {
|
|
77
|
+
* return withMounts(async () => {
|
|
78
|
+
* const id = env.FILESYSTEM.idFromName('user-123');
|
|
79
|
+
* mount('/data', env.FILESYSTEM.get(id));
|
|
80
|
+
*
|
|
81
|
+
* const content = await fs.readFile('/data/file.txt', 'utf8');
|
|
82
|
+
* return new Response(content);
|
|
83
|
+
* });
|
|
84
|
+
* }
|
|
85
|
+
* };
|
|
86
|
+
* ```
|
|
87
|
+
*/
|
|
88
|
+
export function withMounts<T>(fn: () => T): T {
|
|
89
|
+
const requestMounts = new Map<string, Mount>();
|
|
90
|
+
return mountStorage.run(requestMounts, fn);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Mount a WorkerFilesystem at the specified path.
|
|
95
|
+
*
|
|
96
|
+
* When called within withMounts(), the mount is scoped to that context.
|
|
97
|
+
* When called outside withMounts(), uses a global registry (for backwards compatibility).
|
|
98
|
+
*
|
|
99
|
+
* @param path - The mount point (must be absolute, starting with /)
|
|
100
|
+
* @param stub - The WorkerFilesystem implementation to mount
|
|
101
|
+
*
|
|
102
|
+
* @example
|
|
103
|
+
* ```typescript
|
|
104
|
+
* import { withMounts, mount } from 'worker-fs-mount';
|
|
105
|
+
*
|
|
106
|
+
* // Recommended: use withMounts for request isolation
|
|
107
|
+
* withMounts(() => {
|
|
108
|
+
* mount('/mnt/storage', env.STORAGE_SERVICE);
|
|
109
|
+
* // ... use fs operations ...
|
|
110
|
+
* });
|
|
111
|
+
* ```
|
|
112
|
+
*/
|
|
113
|
+
export function mount(path: string, stub: WorkerFilesystem): void {
|
|
114
|
+
const mounts = getMountRegistry();
|
|
115
|
+
const normalized = normalizePath(path);
|
|
116
|
+
|
|
117
|
+
validateMountPath(normalized);
|
|
118
|
+
|
|
119
|
+
if (mounts.has(normalized)) {
|
|
120
|
+
throw new Error(`Path already mounted: ${normalized}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Check for overlapping mounts
|
|
124
|
+
for (const existing of mounts.keys()) {
|
|
125
|
+
if (normalized.startsWith(`${existing}/`)) {
|
|
126
|
+
throw new Error(`Cannot mount at ${normalized}: parent path ${existing} is already mounted`);
|
|
127
|
+
}
|
|
128
|
+
if (existing.startsWith(`${normalized}/`)) {
|
|
129
|
+
throw new Error(`Cannot mount at ${normalized}: child path ${existing} is already mounted`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
mounts.set(normalized, { path: normalized, stub });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Unmount a filesystem at the specified path.
|
|
138
|
+
*
|
|
139
|
+
* @param path - The mount point to unmount
|
|
140
|
+
* @returns True if a mount was removed, false if nothing was mounted at that path
|
|
141
|
+
*
|
|
142
|
+
* @example
|
|
143
|
+
* ```typescript
|
|
144
|
+
* import { mount, unmount } from 'worker-fs-mount';
|
|
145
|
+
*
|
|
146
|
+
* mount('/mnt/storage', env.STORAGE_SERVICE);
|
|
147
|
+
* // ... use fs operations ...
|
|
148
|
+
* unmount('/mnt/storage');
|
|
149
|
+
* ```
|
|
150
|
+
*/
|
|
151
|
+
export function unmount(path: string): boolean {
|
|
152
|
+
const mounts = getMountRegistry();
|
|
153
|
+
const normalized = normalizePath(path);
|
|
154
|
+
return mounts.delete(normalized);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Find the mount that handles a given path.
|
|
159
|
+
*
|
|
160
|
+
* @param path - The path to look up
|
|
161
|
+
* @returns The mount and relative path within that mount, or null if not mounted
|
|
162
|
+
*/
|
|
163
|
+
export function findMount(path: string): MountMatch | null {
|
|
164
|
+
const mounts = getMountRegistry();
|
|
165
|
+
const normalized = normalizePath(path);
|
|
166
|
+
|
|
167
|
+
for (const [mountPath, mountData] of mounts) {
|
|
168
|
+
if (normalized === mountPath) {
|
|
169
|
+
return { mount: mountData, relativePath: '/' };
|
|
170
|
+
}
|
|
171
|
+
if (normalized.startsWith(`${mountPath}/`)) {
|
|
172
|
+
return {
|
|
173
|
+
mount: mountData,
|
|
174
|
+
relativePath: normalized.slice(mountPath.length),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Check if a path is under any mount.
|
|
184
|
+
*
|
|
185
|
+
* @param path - The path to check
|
|
186
|
+
* @returns True if the path is mounted
|
|
187
|
+
*/
|
|
188
|
+
export function isMounted(path: string): boolean {
|
|
189
|
+
return findMount(path) !== null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Check if currently running within a withMounts() context.
|
|
194
|
+
*
|
|
195
|
+
* @returns True if in a request-scoped context, false if using global registry
|
|
196
|
+
*/
|
|
197
|
+
export function isInMountContext(): boolean {
|
|
198
|
+
return mountStorage.getStore() !== undefined;
|
|
199
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stat information for a file, directory, or symlink.
|
|
3
|
+
*/
|
|
4
|
+
export interface Stat {
|
|
5
|
+
type: 'file' | 'directory' | 'symlink';
|
|
6
|
+
size: number;
|
|
7
|
+
lastModified?: Date;
|
|
8
|
+
created?: Date;
|
|
9
|
+
writable?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A directory entry returned by readdir.
|
|
14
|
+
*/
|
|
15
|
+
export interface DirEntry {
|
|
16
|
+
name: string;
|
|
17
|
+
type: 'file' | 'directory' | 'symlink';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Stream-first filesystem interface that WorkerEntrypoints must implement.
|
|
22
|
+
*
|
|
23
|
+
* This is a minimal core interface - operations like readFile, writeFile,
|
|
24
|
+
* truncate, cp, access, and rename are automatically derived from these
|
|
25
|
+
* core streaming primitives by worker-fs-mount.
|
|
26
|
+
*/
|
|
27
|
+
export interface WorkerFilesystem {
|
|
28
|
+
// === Metadata Operations ===
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Get file/directory metadata.
|
|
32
|
+
* @param path - Path relative to mount point
|
|
33
|
+
* @param options - Options for the operation
|
|
34
|
+
* @returns Stat object or null if not found
|
|
35
|
+
*/
|
|
36
|
+
stat(path: string, options?: { followSymlinks?: boolean }): Promise<Stat | null>;
|
|
37
|
+
|
|
38
|
+
// === Streaming Operations ===
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Create a readable stream for a file.
|
|
42
|
+
* @param path - Path relative to mount point
|
|
43
|
+
* @param options - Stream options (start/end for partial reads)
|
|
44
|
+
* @returns Promise resolving to ReadableStream that yields file chunks
|
|
45
|
+
*/
|
|
46
|
+
createReadStream(
|
|
47
|
+
path: string,
|
|
48
|
+
options?: { start?: number; end?: number }
|
|
49
|
+
): Promise<ReadableStream<Uint8Array>>;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Create a writable stream for a file.
|
|
53
|
+
* @param path - Path relative to mount point
|
|
54
|
+
* @param options - Stream options (start for offset writes, flags for mode)
|
|
55
|
+
* @returns Promise resolving to WritableStream
|
|
56
|
+
*/
|
|
57
|
+
createWriteStream(
|
|
58
|
+
path: string,
|
|
59
|
+
options?: { start?: number; flags?: 'w' | 'a' | 'r+' }
|
|
60
|
+
): Promise<WritableStream<Uint8Array>>;
|
|
61
|
+
|
|
62
|
+
// === Directory Operations ===
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Read directory contents.
|
|
66
|
+
* @param path - Path relative to mount point
|
|
67
|
+
* @param options - Readdir options
|
|
68
|
+
* @returns Array of directory entries
|
|
69
|
+
*/
|
|
70
|
+
readdir(path: string, options?: { recursive?: boolean }): Promise<DirEntry[]>;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Create a directory.
|
|
74
|
+
* @param path - Path relative to mount point
|
|
75
|
+
* @param options - Mkdir options
|
|
76
|
+
* @returns The path of the first created directory, or undefined
|
|
77
|
+
*/
|
|
78
|
+
mkdir(path: string, options?: { recursive?: boolean }): Promise<string | undefined>;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Remove a file or directory.
|
|
82
|
+
* @param path - Path relative to mount point
|
|
83
|
+
* @param options - Remove options
|
|
84
|
+
*/
|
|
85
|
+
rm(path: string, options?: { recursive?: boolean; force?: boolean }): Promise<void>;
|
|
86
|
+
|
|
87
|
+
// === Link Operations ===
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Create a symbolic link.
|
|
91
|
+
* @param linkPath - Path for the new symlink
|
|
92
|
+
* @param targetPath - Path the symlink points to
|
|
93
|
+
*/
|
|
94
|
+
symlink?(linkPath: string, targetPath: string): Promise<void>;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Read the target of a symbolic link.
|
|
98
|
+
* @param path - Path relative to mount point
|
|
99
|
+
* @returns The target path
|
|
100
|
+
*/
|
|
101
|
+
readlink?(path: string): Promise<string>;
|
|
102
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utilities for building WorkerFilesystem implementations.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Error codes used by the filesystem.
|
|
7
|
+
*/
|
|
8
|
+
export type FsErrorCode =
|
|
9
|
+
| 'ENOENT'
|
|
10
|
+
| 'EEXIST'
|
|
11
|
+
| 'EISDIR'
|
|
12
|
+
| 'ENOTDIR'
|
|
13
|
+
| 'ENOTEMPTY'
|
|
14
|
+
| 'EINVAL'
|
|
15
|
+
| 'ELOOP';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Error messages for each error code.
|
|
19
|
+
*/
|
|
20
|
+
const ERROR_MESSAGES: Record<FsErrorCode, string> = {
|
|
21
|
+
ENOENT: 'no such file or directory',
|
|
22
|
+
EEXIST: 'file already exists',
|
|
23
|
+
EISDIR: 'illegal operation on a directory',
|
|
24
|
+
ENOTDIR: 'not a directory',
|
|
25
|
+
ENOTEMPTY: 'directory not empty',
|
|
26
|
+
EINVAL: 'invalid argument',
|
|
27
|
+
ELOOP: 'too many symbolic links',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Create a filesystem error with a POSIX-style error code.
|
|
32
|
+
* @param code - The error code (ENOENT, EEXIST, etc.)
|
|
33
|
+
* @param path - The path that caused the error
|
|
34
|
+
* @returns An Error with the formatted message
|
|
35
|
+
*/
|
|
36
|
+
export function createFsError(code: FsErrorCode, path: string): Error {
|
|
37
|
+
const message = ERROR_MESSAGES[code];
|
|
38
|
+
return new Error(`${code}: ${message}, '${path}'`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Normalize a path by collapsing multiple slashes and removing trailing slashes.
|
|
43
|
+
* @param path - The path to normalize
|
|
44
|
+
* @returns The normalized path, always starting with /
|
|
45
|
+
*/
|
|
46
|
+
export function normalizePath(path: string): string {
|
|
47
|
+
if (!path) return '/';
|
|
48
|
+
// Collapse multiple slashes and resolve . segments
|
|
49
|
+
let normalized = path.replace(/\/+/g, '/').replace(/\/\.\//g, '/');
|
|
50
|
+
// Remove trailing slash unless it's the root
|
|
51
|
+
if (normalized !== '/' && normalized.endsWith('/')) {
|
|
52
|
+
normalized = normalized.slice(0, -1);
|
|
53
|
+
}
|
|
54
|
+
// Ensure path starts with /
|
|
55
|
+
if (!normalized.startsWith('/')) {
|
|
56
|
+
normalized = `/${normalized}`;
|
|
57
|
+
}
|
|
58
|
+
return normalized || '/';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Get the parent directory path.
|
|
63
|
+
* @param path - The path to get the parent of
|
|
64
|
+
* @returns The parent path, or / for root-level paths
|
|
65
|
+
*/
|
|
66
|
+
export function getParentPath(path: string): string {
|
|
67
|
+
const normalized = normalizePath(path);
|
|
68
|
+
const lastSlash = normalized.lastIndexOf('/');
|
|
69
|
+
if (lastSlash <= 0) return '/';
|
|
70
|
+
return normalized.slice(0, lastSlash);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Get the base name (file or directory name) from a path.
|
|
75
|
+
* @param path - The path to get the base name from
|
|
76
|
+
* @returns The base name
|
|
77
|
+
*/
|
|
78
|
+
export function getBaseName(path: string): string {
|
|
79
|
+
const normalized = normalizePath(path);
|
|
80
|
+
const lastSlash = normalized.lastIndexOf('/');
|
|
81
|
+
return normalized.slice(lastSlash + 1);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Resolve a potentially relative path against a base directory.
|
|
86
|
+
* @param basePath - The base directory path
|
|
87
|
+
* @param relativePath - The relative path to resolve
|
|
88
|
+
* @returns The resolved absolute path
|
|
89
|
+
*/
|
|
90
|
+
export function resolvePath(basePath: string, relativePath: string): string {
|
|
91
|
+
if (relativePath.startsWith('/')) {
|
|
92
|
+
return normalizePath(relativePath);
|
|
93
|
+
}
|
|
94
|
+
return normalizePath(`${basePath}/${relativePath}`);
|
|
95
|
+
}
|