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/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';
@@ -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
+ }