@statewalker/webrun-files-composite 0.7.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 +184 -0
- package/dist/cjs/index.cjs +239 -0
- package/dist/composite-files-api.d.ts +28 -0
- package/dist/composite-files-api.d.ts.map +1 -0
- package/dist/esm/index.js +238 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/types.d.ts +10 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +52 -0
- package/src/composite-files-api.ts +286 -0
- package/src/index.ts +2 -0
- package/src/types.ts +10 -0
package/README.md
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# @statewalker/webrun-files-composite
|
|
2
|
+
|
|
3
|
+
A `FilesApi` adapter that composes multiple `FilesApi` instances into a unified virtual filesystem with **mount points** and **access guards**.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Mount multiple backends** at different paths under a single `FilesApi` interface
|
|
8
|
+
- **Base path remapping** — use a subdirectory of any backend as its mount root
|
|
9
|
+
- **Access guards** to enforce per-operation, per-path access control
|
|
10
|
+
- **Cross-mount operations** — copy and move files transparently across different backends
|
|
11
|
+
- **Mount-point protection** — mounted directories cannot be removed
|
|
12
|
+
- **Longest-prefix routing** — deeper mounts take precedence (e.g. `/a/b` before `/a`)
|
|
13
|
+
- **Synthetic directory entries** — mount points appear as directories in listings
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pnpm add @statewalker/webrun-files-composite
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
### Basic composition
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
import { CompositeFilesApi } from "@statewalker/webrun-files-composite";
|
|
27
|
+
import { MemFilesApi } from "@statewalker/webrun-files-mem";
|
|
28
|
+
|
|
29
|
+
const composite = new CompositeFilesApi(new MemFilesApi())
|
|
30
|
+
.mount("/docs", new MemFilesApi())
|
|
31
|
+
.mount("/cache", new MemFilesApi());
|
|
32
|
+
|
|
33
|
+
const encoder = new TextEncoder();
|
|
34
|
+
await composite.write("/readme.txt", [encoder.encode("Hello")]);
|
|
35
|
+
await composite.write("/docs/guide.md", [encoder.encode("# Guide")]);
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Base path remapping
|
|
39
|
+
|
|
40
|
+
Each mount can specify which subdirectory of the backing filesystem to use as its root. The constructor also accepts an optional `rootPath`:
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
import { NodeFilesApi } from "@statewalker/webrun-files-node";
|
|
44
|
+
import { S3FilesApi } from "@statewalker/webrun-files-s3";
|
|
45
|
+
import { MemFilesApi } from "@statewalker/webrun-files-mem";
|
|
46
|
+
|
|
47
|
+
const fsMain = new NodeFilesApi({ ... });
|
|
48
|
+
const fsS3 = new S3FilesApi({ ... });
|
|
49
|
+
const fsMem = new MemFilesApi();
|
|
50
|
+
|
|
51
|
+
// Use "/projects" as the root of the composite file system:
|
|
52
|
+
const composite = new CompositeFilesApi(fsMain, "./projects")
|
|
53
|
+
.mount("/docs", fsS3, "/documentation") // use the "/documentation" folder on S3
|
|
54
|
+
.mount("/cache", fsMem); // use the root of the in-memory FS
|
|
55
|
+
|
|
56
|
+
// Writes to /readme.txt go to fsMain at /projects/readme.txt
|
|
57
|
+
await composite.write("/readme.txt", [encoder.encode("Hello")]);
|
|
58
|
+
|
|
59
|
+
// Writes to /docs/guide.md go to fsS3 at /documentation/guide.md
|
|
60
|
+
await composite.write("/docs/guide.md", [encoder.encode("# Guide")]);
|
|
61
|
+
|
|
62
|
+
// Writes to /cache/tmp.dat go to fsMem at /tmp.dat
|
|
63
|
+
await composite.write("/cache/tmp.dat", [encoder.encode("temp")]);
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Access guards
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
composite
|
|
70
|
+
.guard(
|
|
71
|
+
["write", "remove", "move"],
|
|
72
|
+
(path) => !path.startsWith("/.private/"),
|
|
73
|
+
"Cannot modify private files"
|
|
74
|
+
)
|
|
75
|
+
.guard(
|
|
76
|
+
["write"],
|
|
77
|
+
(path) => !path.includes(".."),
|
|
78
|
+
"Path traversal not allowed"
|
|
79
|
+
);
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Guards are checked before each operation. The first failing guard throws an error with its message.
|
|
83
|
+
|
|
84
|
+
### Cross-mount operations
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
// Copy from one mount to another
|
|
88
|
+
await composite.copy("/docs/file.txt", "/archive/file.txt");
|
|
89
|
+
|
|
90
|
+
// Move across mounts (implemented as copy + delete)
|
|
91
|
+
await composite.move("/cache/temp.txt", "/docs/temp.txt");
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Listing
|
|
95
|
+
|
|
96
|
+
Mount points appear as synthetic directory entries:
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
99
|
+
const entries = [];
|
|
100
|
+
for await (const entry of composite.list("/")) {
|
|
101
|
+
entries.push(entry);
|
|
102
|
+
}
|
|
103
|
+
// Includes: { name: "docs", path: "/docs", kind: "directory" }
|
|
104
|
+
|
|
105
|
+
// Recursive listing spans all mounts
|
|
106
|
+
for await (const entry of composite.list("/", { recursive: true })) {
|
|
107
|
+
// entries from root, /docs, /cache, and all subdirectories
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## API
|
|
112
|
+
|
|
113
|
+
### `CompositeFilesApi`
|
|
114
|
+
|
|
115
|
+
Implements the `FilesApi` interface from `@statewalker/webrun-files`.
|
|
116
|
+
|
|
117
|
+
#### Constructor
|
|
118
|
+
|
|
119
|
+
```typescript
|
|
120
|
+
new CompositeFilesApi(root: FilesApi, rootPath?: string)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Creates a composite filesystem with `root` as the default backend for `/`. If `rootPath` is provided, all root operations are remapped to that subdirectory in the backing filesystem.
|
|
124
|
+
|
|
125
|
+
#### `mount(path: string, api: FilesApi, fsPath?: string): this`
|
|
126
|
+
|
|
127
|
+
Mounts a `FilesApi` backend at the given path. If `fsPath` is provided, operations on this mount are remapped to that subdirectory in the backing filesystem. Paths are normalized (leading `/`, no trailing `/`). Returns `this` for chaining.
|
|
128
|
+
|
|
129
|
+
#### `guard(operations: FileOperation[], check: (path: string) => boolean, message?: string): this`
|
|
130
|
+
|
|
131
|
+
Adds an access guard. Before each matching operation, `check(path)` is called. If it returns `false`, the operation throws an error with the optional `message`. Returns `this` for chaining.
|
|
132
|
+
|
|
133
|
+
#### FilesApi methods
|
|
134
|
+
|
|
135
|
+
| Method | Description |
|
|
136
|
+
|--------|-------------|
|
|
137
|
+
| `read(path, options?)` | Read file content as `AsyncIterable<Uint8Array>` |
|
|
138
|
+
| `write(path, content)` | Write content to a file |
|
|
139
|
+
| `mkdir(path)` | Create a directory |
|
|
140
|
+
| `list(path, options?)` | List directory entries (supports `{ recursive: true }`) |
|
|
141
|
+
| `stats(path)` | Get file/directory stats, returns `undefined` if not found |
|
|
142
|
+
| `exists(path)` | Check if a path exists |
|
|
143
|
+
| `remove(path)` | Remove a file or directory (throws on mount points) |
|
|
144
|
+
| `move(source, target)` | Move a file or directory (cross-mount supported) |
|
|
145
|
+
| `copy(source, target)` | Copy a file or directory (cross-mount supported) |
|
|
146
|
+
|
|
147
|
+
### Types
|
|
148
|
+
|
|
149
|
+
```typescript
|
|
150
|
+
type FileOperation = "read" | "write" | "remove" | "move" | "copy" | "list" | "mkdir";
|
|
151
|
+
|
|
152
|
+
interface FileGuard {
|
|
153
|
+
operations: FileOperation[];
|
|
154
|
+
check: (path: string) => boolean;
|
|
155
|
+
message?: string;
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Path resolution
|
|
160
|
+
|
|
161
|
+
1. All input paths are normalized (forward slashes, leading `/`, no trailing `/`)
|
|
162
|
+
2. The mount with the **longest matching prefix** handles the operation
|
|
163
|
+
3. The mount prefix is stripped and the `fsPath` (base path) is prepended — e.g. `/docs/guide.md` with mount at `/docs` and `fsPath="/documentation"` resolves to `/documentation/guide.md` on the mounted backend
|
|
164
|
+
4. Guards always operate on **composite paths** (before remapping), not backing filesystem paths
|
|
165
|
+
|
|
166
|
+
## Design constraints
|
|
167
|
+
|
|
168
|
+
- The root mount (`/`) is set at construction time and cannot be remounted
|
|
169
|
+
- Mount points are immutable — `remove()` on a mount point throws an error
|
|
170
|
+
- Cross-mount move is implemented as copy + delete (no atomic guarantee)
|
|
171
|
+
- Guards are evaluated in the order they were added; first denial wins
|
|
172
|
+
|
|
173
|
+
## Related packages
|
|
174
|
+
|
|
175
|
+
| Package | Role |
|
|
176
|
+
|---------|------|
|
|
177
|
+
| `@statewalker/webrun-files` | Core `FilesApi` interface and path utilities |
|
|
178
|
+
| `@statewalker/webrun-files-mem` | In-memory `FilesApi` backend |
|
|
179
|
+
| `@statewalker/webrun-files-node` | Node.js filesystem backend |
|
|
180
|
+
| `@statewalker/webrun-files-tests` | Shared parametrized test suites |
|
|
181
|
+
|
|
182
|
+
## License
|
|
183
|
+
|
|
184
|
+
MIT
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
//#region ../webrun-files/dist/esm/index.js
|
|
3
|
+
/**
|
|
4
|
+
* Path manipulation utilities for virtual filesystem paths.
|
|
5
|
+
* All paths use forward slashes and start with "/".
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Normalizes a file path to a consistent format.
|
|
9
|
+
* - Adds leading slash if missing
|
|
10
|
+
* - Removes trailing slash
|
|
11
|
+
* - Removes `.` segments
|
|
12
|
+
* - Collapses multiple slashes
|
|
13
|
+
*/
|
|
14
|
+
function normalizePath(filePath) {
|
|
15
|
+
const segments = filePath.split("/").filter((s) => !!s && s !== ".");
|
|
16
|
+
if (segments.length === 0) return "/";
|
|
17
|
+
return `/${segments.join("/")}`;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Joins path segments with proper normalization.
|
|
21
|
+
*/
|
|
22
|
+
function joinPath(...segments) {
|
|
23
|
+
return normalizePath(segments.join("/"));
|
|
24
|
+
}
|
|
25
|
+
//#endregion
|
|
26
|
+
//#region src/composite-files-api.ts
|
|
27
|
+
var CompositeFilesApi = class {
|
|
28
|
+
mounts;
|
|
29
|
+
guards = [];
|
|
30
|
+
constructor(root, rootPath) {
|
|
31
|
+
this.mounts = [{
|
|
32
|
+
prefix: "/",
|
|
33
|
+
api: root,
|
|
34
|
+
basePath: normalizePath(rootPath ?? "/")
|
|
35
|
+
}];
|
|
36
|
+
}
|
|
37
|
+
mount(path, api, fsPath) {
|
|
38
|
+
const prefix = normalizePath(path);
|
|
39
|
+
if (prefix === "/") throw new Error("Cannot mount at root — root is set via constructor");
|
|
40
|
+
this.mounts.push({
|
|
41
|
+
prefix,
|
|
42
|
+
api,
|
|
43
|
+
basePath: normalizePath(fsPath ?? "/")
|
|
44
|
+
});
|
|
45
|
+
this.mounts.sort((a, b) => b.prefix.length - a.prefix.length);
|
|
46
|
+
return this;
|
|
47
|
+
}
|
|
48
|
+
guard(operations, check, message) {
|
|
49
|
+
this.guards.push({
|
|
50
|
+
operations,
|
|
51
|
+
check,
|
|
52
|
+
message
|
|
53
|
+
});
|
|
54
|
+
return this;
|
|
55
|
+
}
|
|
56
|
+
resolve(path) {
|
|
57
|
+
const normalized = normalizePath(path);
|
|
58
|
+
for (const mount of this.mounts) {
|
|
59
|
+
if (mount.prefix === "/") return {
|
|
60
|
+
api: mount.api,
|
|
61
|
+
resolvedPath: joinPath(mount.basePath, normalized)
|
|
62
|
+
};
|
|
63
|
+
if (normalized === mount.prefix || normalized.startsWith(`${mount.prefix}/`)) {
|
|
64
|
+
const localPath = normalized.slice(mount.prefix.length) || "/";
|
|
65
|
+
return {
|
|
66
|
+
api: mount.api,
|
|
67
|
+
resolvedPath: joinPath(mount.basePath, localPath)
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const rootMount = this.mounts[this.mounts.length - 1];
|
|
72
|
+
return {
|
|
73
|
+
api: rootMount.api,
|
|
74
|
+
resolvedPath: joinPath(rootMount.basePath, normalizePath(path))
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
isMountPoint(path) {
|
|
78
|
+
const normalized = normalizePath(path);
|
|
79
|
+
return this.mounts.some((m) => m.prefix === normalized && m.prefix !== "/");
|
|
80
|
+
}
|
|
81
|
+
/** Returns mount prefixes that are direct children of the given path. */
|
|
82
|
+
childMountPrefixes(parentPath) {
|
|
83
|
+
const normalized = normalizePath(parentPath);
|
|
84
|
+
const prefix = normalized === "/" ? "/" : `${normalized}/`;
|
|
85
|
+
const result = [];
|
|
86
|
+
for (const mount of this.mounts) {
|
|
87
|
+
if (mount.prefix === "/") continue;
|
|
88
|
+
if (!mount.prefix.startsWith(prefix)) continue;
|
|
89
|
+
if (!mount.prefix.slice(prefix.length).includes("/")) result.push(mount.prefix);
|
|
90
|
+
}
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
checkGuard(operation, path) {
|
|
94
|
+
const normalized = normalizePath(path);
|
|
95
|
+
for (const guard of this.guards) {
|
|
96
|
+
if (!guard.operations.includes(operation)) continue;
|
|
97
|
+
if (!guard.check(normalized)) {
|
|
98
|
+
const msg = guard.message ?? "Access denied";
|
|
99
|
+
throw new Error(`${msg}: ${normalized}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
read(path, options) {
|
|
104
|
+
this.checkGuard("read", path);
|
|
105
|
+
const { api, resolvedPath } = this.resolve(path);
|
|
106
|
+
return api.read(resolvedPath, options);
|
|
107
|
+
}
|
|
108
|
+
async write(path, content) {
|
|
109
|
+
this.checkGuard("write", path);
|
|
110
|
+
const { api, resolvedPath } = this.resolve(path);
|
|
111
|
+
return api.write(resolvedPath, content);
|
|
112
|
+
}
|
|
113
|
+
async mkdir(path) {
|
|
114
|
+
this.checkGuard("mkdir", path);
|
|
115
|
+
const { api, resolvedPath } = this.resolve(path);
|
|
116
|
+
return api.mkdir(resolvedPath);
|
|
117
|
+
}
|
|
118
|
+
async *list(path, options) {
|
|
119
|
+
this.checkGuard("list", path);
|
|
120
|
+
const normalized = normalizePath(path);
|
|
121
|
+
const { api, resolvedPath } = this.resolve(path);
|
|
122
|
+
const childMounts = this.childMountPrefixes(normalized);
|
|
123
|
+
const yieldedNames = /* @__PURE__ */ new Set();
|
|
124
|
+
if (options?.recursive) {
|
|
125
|
+
for await (const entry of api.list(resolvedPath, options)) {
|
|
126
|
+
const compositePath = this.remapPath(normalized, resolvedPath, entry.path);
|
|
127
|
+
if (this.isUnderChildMount(compositePath, childMounts)) continue;
|
|
128
|
+
yieldedNames.add(entry.name);
|
|
129
|
+
yield {
|
|
130
|
+
...entry,
|
|
131
|
+
path: compositePath
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
for (const mountPrefix of childMounts) {
|
|
135
|
+
const mount = this.mounts.find((m) => m.prefix === mountPrefix);
|
|
136
|
+
if (!mount) continue;
|
|
137
|
+
yield {
|
|
138
|
+
name: mountPrefix.split("/").pop() ?? "",
|
|
139
|
+
path: mountPrefix,
|
|
140
|
+
kind: "directory"
|
|
141
|
+
};
|
|
142
|
+
for await (const entry of mount.api.list(mount.basePath, { recursive: true })) {
|
|
143
|
+
const localPath = this.stripBasePath(entry.path, mount.basePath);
|
|
144
|
+
yield {
|
|
145
|
+
...entry,
|
|
146
|
+
path: `${mountPrefix}${localPath === "/" ? "" : localPath}`
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
} else {
|
|
151
|
+
for await (const entry of api.list(resolvedPath)) {
|
|
152
|
+
const compositePath = this.remapPath(normalized, resolvedPath, entry.path);
|
|
153
|
+
yieldedNames.add(entry.name);
|
|
154
|
+
yield {
|
|
155
|
+
...entry,
|
|
156
|
+
path: compositePath
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
for (const mountPrefix of childMounts) {
|
|
160
|
+
const mountName = mountPrefix.split("/").pop() ?? "";
|
|
161
|
+
if (!yieldedNames.has(mountName)) yield {
|
|
162
|
+
name: mountName,
|
|
163
|
+
path: mountPrefix,
|
|
164
|
+
kind: "directory"
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
async stats(path) {
|
|
170
|
+
const normalized = normalizePath(path);
|
|
171
|
+
if (this.isMountPoint(normalized)) return { kind: "directory" };
|
|
172
|
+
const { api, resolvedPath } = this.resolve(path);
|
|
173
|
+
return api.stats(resolvedPath);
|
|
174
|
+
}
|
|
175
|
+
async exists(path) {
|
|
176
|
+
const normalized = normalizePath(path);
|
|
177
|
+
if (this.isMountPoint(normalized)) return true;
|
|
178
|
+
const { api, resolvedPath } = this.resolve(path);
|
|
179
|
+
return api.exists(resolvedPath);
|
|
180
|
+
}
|
|
181
|
+
async remove(path) {
|
|
182
|
+
const normalized = normalizePath(path);
|
|
183
|
+
if (this.isMountPoint(normalized)) throw new Error(`Cannot remove mount point: ${normalized}`);
|
|
184
|
+
this.checkGuard("remove", path);
|
|
185
|
+
const { api, resolvedPath } = this.resolve(path);
|
|
186
|
+
return api.remove(resolvedPath);
|
|
187
|
+
}
|
|
188
|
+
async move(source, target) {
|
|
189
|
+
this.checkGuard("move", source);
|
|
190
|
+
this.checkGuard("move", target);
|
|
191
|
+
const src = this.resolve(source);
|
|
192
|
+
const tgt = this.resolve(target);
|
|
193
|
+
if (src.api === tgt.api) return src.api.move(src.resolvedPath, tgt.resolvedPath);
|
|
194
|
+
const copied = await this.crossCopy(src.api, src.resolvedPath, tgt.api, tgt.resolvedPath);
|
|
195
|
+
if (copied) await src.api.remove(src.resolvedPath);
|
|
196
|
+
return copied;
|
|
197
|
+
}
|
|
198
|
+
async copy(source, target) {
|
|
199
|
+
this.checkGuard("copy", source);
|
|
200
|
+
this.checkGuard("copy", target);
|
|
201
|
+
const src = this.resolve(source);
|
|
202
|
+
const tgt = this.resolve(target);
|
|
203
|
+
if (src.api === tgt.api) return src.api.copy(src.resolvedPath, tgt.resolvedPath);
|
|
204
|
+
return this.crossCopy(src.api, src.resolvedPath, tgt.api, tgt.resolvedPath);
|
|
205
|
+
}
|
|
206
|
+
stripBasePath(path, basePath) {
|
|
207
|
+
if (basePath === "/") return path;
|
|
208
|
+
if (path === basePath) return "/";
|
|
209
|
+
if (path.startsWith(`${basePath}/`)) return path.slice(basePath.length);
|
|
210
|
+
return path;
|
|
211
|
+
}
|
|
212
|
+
async crossCopy(srcApi, srcPath, tgtApi, tgtPath) {
|
|
213
|
+
const srcStats = await srcApi.stats(srcPath);
|
|
214
|
+
if (!srcStats) return false;
|
|
215
|
+
if (srcStats.kind === "file") {
|
|
216
|
+
await tgtApi.write(tgtPath, srcApi.read(srcPath));
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
await tgtApi.mkdir(tgtPath);
|
|
220
|
+
for await (const entry of srcApi.list(srcPath)) {
|
|
221
|
+
const childSrc = srcPath === "/" ? `/${entry.name}` : `${srcPath}/${entry.name}`;
|
|
222
|
+
const childTgt = tgtPath === "/" ? `/${entry.name}` : `${tgtPath}/${entry.name}`;
|
|
223
|
+
if (entry.kind === "file") await tgtApi.write(childTgt, srcApi.read(childSrc));
|
|
224
|
+
else await this.crossCopy(srcApi, childSrc, tgtApi, childTgt);
|
|
225
|
+
}
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
remapPath(compositeParent, resolvedParent, resolvedChild) {
|
|
229
|
+
const relative = resolvedChild.startsWith(resolvedParent) ? resolvedChild.slice(resolvedParent.length) : resolvedChild;
|
|
230
|
+
if (compositeParent === "/") return relative.startsWith("/") ? relative : `/${relative}`;
|
|
231
|
+
return `${compositeParent}${relative.startsWith("/") ? relative : `/${relative}`}`;
|
|
232
|
+
}
|
|
233
|
+
isUnderChildMount(compositePath, childMounts) {
|
|
234
|
+
for (const mount of childMounts) if (compositePath === mount || compositePath.startsWith(`${mount}/`)) return true;
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
//#endregion
|
|
239
|
+
exports.CompositeFilesApi = CompositeFilesApi;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { FileInfo, FileStats, FilesApi, ListOptions, ReadOptions } from "@statewalker/webrun-files";
|
|
2
|
+
import type { FileOperation } from "./types.js";
|
|
3
|
+
export declare class CompositeFilesApi implements FilesApi {
|
|
4
|
+
private mounts;
|
|
5
|
+
private guards;
|
|
6
|
+
constructor(root: FilesApi, rootPath?: string);
|
|
7
|
+
mount(path: string, api: FilesApi, fsPath?: string): this;
|
|
8
|
+
guard(operations: FileOperation[], check: (path: string) => boolean, message?: string): this;
|
|
9
|
+
private resolve;
|
|
10
|
+
private isMountPoint;
|
|
11
|
+
/** Returns mount prefixes that are direct children of the given path. */
|
|
12
|
+
private childMountPrefixes;
|
|
13
|
+
private checkGuard;
|
|
14
|
+
read(path: string, options?: ReadOptions): AsyncIterable<Uint8Array>;
|
|
15
|
+
write(path: string, content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>): Promise<void>;
|
|
16
|
+
mkdir(path: string): Promise<void>;
|
|
17
|
+
list(path: string, options?: ListOptions): AsyncIterable<FileInfo>;
|
|
18
|
+
stats(path: string): Promise<FileStats | undefined>;
|
|
19
|
+
exists(path: string): Promise<boolean>;
|
|
20
|
+
remove(path: string): Promise<boolean>;
|
|
21
|
+
move(source: string, target: string): Promise<boolean>;
|
|
22
|
+
copy(source: string, target: string): Promise<boolean>;
|
|
23
|
+
private stripBasePath;
|
|
24
|
+
private crossCopy;
|
|
25
|
+
private remapPath;
|
|
26
|
+
private isUnderChildMount;
|
|
27
|
+
}
|
|
28
|
+
//# sourceMappingURL=composite-files-api.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"composite-files-api.d.ts","sourceRoot":"","sources":["../src/composite-files-api.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,QAAQ,EACR,SAAS,EACT,QAAQ,EACR,WAAW,EACX,WAAW,EACZ,MAAM,2BAA2B,CAAC;AAEnC,OAAO,KAAK,EAAa,aAAa,EAAE,MAAM,YAAY,CAAC;AAQ3D,qBAAa,iBAAkB,YAAW,QAAQ;IAChD,OAAO,CAAC,MAAM,CAAe;IAC7B,OAAO,CAAC,MAAM,CAAmB;gBAErB,IAAI,EAAE,QAAQ,EAAE,QAAQ,CAAC,EAAE,MAAM;IAI7C,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,QAAQ,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI;IAWzD,KAAK,CAAC,UAAU,EAAE,aAAa,EAAE,EAAE,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI;IAO5F,OAAO,CAAC,OAAO;IAgBf,OAAO,CAAC,YAAY;IAKpB,yEAAyE;IACzE,OAAO,CAAC,kBAAkB;IAkB1B,OAAO,CAAC,UAAU;IAalB,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,aAAa,CAAC,UAAU,CAAC;IAM9D,KAAK,CACT,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,aAAa,CAAC,UAAU,CAAC,GACxD,OAAO,CAAC,IAAI,CAAC;IAMV,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAMjC,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,aAAa,CAAC,QAAQ,CAAC;IA+CnE,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC;IASnD,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAStC,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAUtC,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAmBtD,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAiB5D,OAAO,CAAC,aAAa;YAOP,SAAS;IA4BvB,OAAO,CAAC,SAAS;IAejB,OAAO,CAAC,iBAAiB;CAQ1B"}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
//#region ../webrun-files/dist/esm/index.js
|
|
2
|
+
/**
|
|
3
|
+
* Path manipulation utilities for virtual filesystem paths.
|
|
4
|
+
* All paths use forward slashes and start with "/".
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Normalizes a file path to a consistent format.
|
|
8
|
+
* - Adds leading slash if missing
|
|
9
|
+
* - Removes trailing slash
|
|
10
|
+
* - Removes `.` segments
|
|
11
|
+
* - Collapses multiple slashes
|
|
12
|
+
*/
|
|
13
|
+
function normalizePath(filePath) {
|
|
14
|
+
const segments = filePath.split("/").filter((s) => !!s && s !== ".");
|
|
15
|
+
if (segments.length === 0) return "/";
|
|
16
|
+
return `/${segments.join("/")}`;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Joins path segments with proper normalization.
|
|
20
|
+
*/
|
|
21
|
+
function joinPath(...segments) {
|
|
22
|
+
return normalizePath(segments.join("/"));
|
|
23
|
+
}
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region src/composite-files-api.ts
|
|
26
|
+
var CompositeFilesApi = class {
|
|
27
|
+
mounts;
|
|
28
|
+
guards = [];
|
|
29
|
+
constructor(root, rootPath) {
|
|
30
|
+
this.mounts = [{
|
|
31
|
+
prefix: "/",
|
|
32
|
+
api: root,
|
|
33
|
+
basePath: normalizePath(rootPath ?? "/")
|
|
34
|
+
}];
|
|
35
|
+
}
|
|
36
|
+
mount(path, api, fsPath) {
|
|
37
|
+
const prefix = normalizePath(path);
|
|
38
|
+
if (prefix === "/") throw new Error("Cannot mount at root — root is set via constructor");
|
|
39
|
+
this.mounts.push({
|
|
40
|
+
prefix,
|
|
41
|
+
api,
|
|
42
|
+
basePath: normalizePath(fsPath ?? "/")
|
|
43
|
+
});
|
|
44
|
+
this.mounts.sort((a, b) => b.prefix.length - a.prefix.length);
|
|
45
|
+
return this;
|
|
46
|
+
}
|
|
47
|
+
guard(operations, check, message) {
|
|
48
|
+
this.guards.push({
|
|
49
|
+
operations,
|
|
50
|
+
check,
|
|
51
|
+
message
|
|
52
|
+
});
|
|
53
|
+
return this;
|
|
54
|
+
}
|
|
55
|
+
resolve(path) {
|
|
56
|
+
const normalized = normalizePath(path);
|
|
57
|
+
for (const mount of this.mounts) {
|
|
58
|
+
if (mount.prefix === "/") return {
|
|
59
|
+
api: mount.api,
|
|
60
|
+
resolvedPath: joinPath(mount.basePath, normalized)
|
|
61
|
+
};
|
|
62
|
+
if (normalized === mount.prefix || normalized.startsWith(`${mount.prefix}/`)) {
|
|
63
|
+
const localPath = normalized.slice(mount.prefix.length) || "/";
|
|
64
|
+
return {
|
|
65
|
+
api: mount.api,
|
|
66
|
+
resolvedPath: joinPath(mount.basePath, localPath)
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const rootMount = this.mounts[this.mounts.length - 1];
|
|
71
|
+
return {
|
|
72
|
+
api: rootMount.api,
|
|
73
|
+
resolvedPath: joinPath(rootMount.basePath, normalizePath(path))
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
isMountPoint(path) {
|
|
77
|
+
const normalized = normalizePath(path);
|
|
78
|
+
return this.mounts.some((m) => m.prefix === normalized && m.prefix !== "/");
|
|
79
|
+
}
|
|
80
|
+
/** Returns mount prefixes that are direct children of the given path. */
|
|
81
|
+
childMountPrefixes(parentPath) {
|
|
82
|
+
const normalized = normalizePath(parentPath);
|
|
83
|
+
const prefix = normalized === "/" ? "/" : `${normalized}/`;
|
|
84
|
+
const result = [];
|
|
85
|
+
for (const mount of this.mounts) {
|
|
86
|
+
if (mount.prefix === "/") continue;
|
|
87
|
+
if (!mount.prefix.startsWith(prefix)) continue;
|
|
88
|
+
if (!mount.prefix.slice(prefix.length).includes("/")) result.push(mount.prefix);
|
|
89
|
+
}
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
checkGuard(operation, path) {
|
|
93
|
+
const normalized = normalizePath(path);
|
|
94
|
+
for (const guard of this.guards) {
|
|
95
|
+
if (!guard.operations.includes(operation)) continue;
|
|
96
|
+
if (!guard.check(normalized)) {
|
|
97
|
+
const msg = guard.message ?? "Access denied";
|
|
98
|
+
throw new Error(`${msg}: ${normalized}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
read(path, options) {
|
|
103
|
+
this.checkGuard("read", path);
|
|
104
|
+
const { api, resolvedPath } = this.resolve(path);
|
|
105
|
+
return api.read(resolvedPath, options);
|
|
106
|
+
}
|
|
107
|
+
async write(path, content) {
|
|
108
|
+
this.checkGuard("write", path);
|
|
109
|
+
const { api, resolvedPath } = this.resolve(path);
|
|
110
|
+
return api.write(resolvedPath, content);
|
|
111
|
+
}
|
|
112
|
+
async mkdir(path) {
|
|
113
|
+
this.checkGuard("mkdir", path);
|
|
114
|
+
const { api, resolvedPath } = this.resolve(path);
|
|
115
|
+
return api.mkdir(resolvedPath);
|
|
116
|
+
}
|
|
117
|
+
async *list(path, options) {
|
|
118
|
+
this.checkGuard("list", path);
|
|
119
|
+
const normalized = normalizePath(path);
|
|
120
|
+
const { api, resolvedPath } = this.resolve(path);
|
|
121
|
+
const childMounts = this.childMountPrefixes(normalized);
|
|
122
|
+
const yieldedNames = /* @__PURE__ */ new Set();
|
|
123
|
+
if (options?.recursive) {
|
|
124
|
+
for await (const entry of api.list(resolvedPath, options)) {
|
|
125
|
+
const compositePath = this.remapPath(normalized, resolvedPath, entry.path);
|
|
126
|
+
if (this.isUnderChildMount(compositePath, childMounts)) continue;
|
|
127
|
+
yieldedNames.add(entry.name);
|
|
128
|
+
yield {
|
|
129
|
+
...entry,
|
|
130
|
+
path: compositePath
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
for (const mountPrefix of childMounts) {
|
|
134
|
+
const mount = this.mounts.find((m) => m.prefix === mountPrefix);
|
|
135
|
+
if (!mount) continue;
|
|
136
|
+
yield {
|
|
137
|
+
name: mountPrefix.split("/").pop() ?? "",
|
|
138
|
+
path: mountPrefix,
|
|
139
|
+
kind: "directory"
|
|
140
|
+
};
|
|
141
|
+
for await (const entry of mount.api.list(mount.basePath, { recursive: true })) {
|
|
142
|
+
const localPath = this.stripBasePath(entry.path, mount.basePath);
|
|
143
|
+
yield {
|
|
144
|
+
...entry,
|
|
145
|
+
path: `${mountPrefix}${localPath === "/" ? "" : localPath}`
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
} else {
|
|
150
|
+
for await (const entry of api.list(resolvedPath)) {
|
|
151
|
+
const compositePath = this.remapPath(normalized, resolvedPath, entry.path);
|
|
152
|
+
yieldedNames.add(entry.name);
|
|
153
|
+
yield {
|
|
154
|
+
...entry,
|
|
155
|
+
path: compositePath
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
for (const mountPrefix of childMounts) {
|
|
159
|
+
const mountName = mountPrefix.split("/").pop() ?? "";
|
|
160
|
+
if (!yieldedNames.has(mountName)) yield {
|
|
161
|
+
name: mountName,
|
|
162
|
+
path: mountPrefix,
|
|
163
|
+
kind: "directory"
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
async stats(path) {
|
|
169
|
+
const normalized = normalizePath(path);
|
|
170
|
+
if (this.isMountPoint(normalized)) return { kind: "directory" };
|
|
171
|
+
const { api, resolvedPath } = this.resolve(path);
|
|
172
|
+
return api.stats(resolvedPath);
|
|
173
|
+
}
|
|
174
|
+
async exists(path) {
|
|
175
|
+
const normalized = normalizePath(path);
|
|
176
|
+
if (this.isMountPoint(normalized)) return true;
|
|
177
|
+
const { api, resolvedPath } = this.resolve(path);
|
|
178
|
+
return api.exists(resolvedPath);
|
|
179
|
+
}
|
|
180
|
+
async remove(path) {
|
|
181
|
+
const normalized = normalizePath(path);
|
|
182
|
+
if (this.isMountPoint(normalized)) throw new Error(`Cannot remove mount point: ${normalized}`);
|
|
183
|
+
this.checkGuard("remove", path);
|
|
184
|
+
const { api, resolvedPath } = this.resolve(path);
|
|
185
|
+
return api.remove(resolvedPath);
|
|
186
|
+
}
|
|
187
|
+
async move(source, target) {
|
|
188
|
+
this.checkGuard("move", source);
|
|
189
|
+
this.checkGuard("move", target);
|
|
190
|
+
const src = this.resolve(source);
|
|
191
|
+
const tgt = this.resolve(target);
|
|
192
|
+
if (src.api === tgt.api) return src.api.move(src.resolvedPath, tgt.resolvedPath);
|
|
193
|
+
const copied = await this.crossCopy(src.api, src.resolvedPath, tgt.api, tgt.resolvedPath);
|
|
194
|
+
if (copied) await src.api.remove(src.resolvedPath);
|
|
195
|
+
return copied;
|
|
196
|
+
}
|
|
197
|
+
async copy(source, target) {
|
|
198
|
+
this.checkGuard("copy", source);
|
|
199
|
+
this.checkGuard("copy", target);
|
|
200
|
+
const src = this.resolve(source);
|
|
201
|
+
const tgt = this.resolve(target);
|
|
202
|
+
if (src.api === tgt.api) return src.api.copy(src.resolvedPath, tgt.resolvedPath);
|
|
203
|
+
return this.crossCopy(src.api, src.resolvedPath, tgt.api, tgt.resolvedPath);
|
|
204
|
+
}
|
|
205
|
+
stripBasePath(path, basePath) {
|
|
206
|
+
if (basePath === "/") return path;
|
|
207
|
+
if (path === basePath) return "/";
|
|
208
|
+
if (path.startsWith(`${basePath}/`)) return path.slice(basePath.length);
|
|
209
|
+
return path;
|
|
210
|
+
}
|
|
211
|
+
async crossCopy(srcApi, srcPath, tgtApi, tgtPath) {
|
|
212
|
+
const srcStats = await srcApi.stats(srcPath);
|
|
213
|
+
if (!srcStats) return false;
|
|
214
|
+
if (srcStats.kind === "file") {
|
|
215
|
+
await tgtApi.write(tgtPath, srcApi.read(srcPath));
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
await tgtApi.mkdir(tgtPath);
|
|
219
|
+
for await (const entry of srcApi.list(srcPath)) {
|
|
220
|
+
const childSrc = srcPath === "/" ? `/${entry.name}` : `${srcPath}/${entry.name}`;
|
|
221
|
+
const childTgt = tgtPath === "/" ? `/${entry.name}` : `${tgtPath}/${entry.name}`;
|
|
222
|
+
if (entry.kind === "file") await tgtApi.write(childTgt, srcApi.read(childSrc));
|
|
223
|
+
else await this.crossCopy(srcApi, childSrc, tgtApi, childTgt);
|
|
224
|
+
}
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
remapPath(compositeParent, resolvedParent, resolvedChild) {
|
|
228
|
+
const relative = resolvedChild.startsWith(resolvedParent) ? resolvedChild.slice(resolvedParent.length) : resolvedChild;
|
|
229
|
+
if (compositeParent === "/") return relative.startsWith("/") ? relative : `/${relative}`;
|
|
230
|
+
return `${compositeParent}${relative.startsWith("/") ? relative : `/${relative}`}`;
|
|
231
|
+
}
|
|
232
|
+
isUnderChildMount(compositePath, childMounts) {
|
|
233
|
+
for (const mount of childMounts) if (compositePath === mount || compositePath.startsWith(`${mount}/`)) return true;
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
//#endregion
|
|
238
|
+
export { CompositeFilesApi };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAC7D,YAAY,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type FileOperation = "read" | "write" | "remove" | "move" | "copy" | "list" | "mkdir";
|
|
2
|
+
export interface FileGuard {
|
|
3
|
+
/** Which filesystem operations this guard intercepts. */
|
|
4
|
+
operations: FileOperation[];
|
|
5
|
+
/** Returns true to allow, false to deny. */
|
|
6
|
+
check: (path: string) => boolean;
|
|
7
|
+
/** Error message when access is denied. */
|
|
8
|
+
message?: string;
|
|
9
|
+
}
|
|
10
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC;AAE7F,MAAM,WAAW,SAAS;IACxB,yDAAyD;IACzD,UAAU,EAAE,aAAa,EAAE,CAAC;IAC5B,4CAA4C;IAC5C,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC;IACjC,2CAA2C;IAC3C,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB"}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@statewalker/webrun-files-composite",
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Composite FilesApi with mount points and access guards",
|
|
7
|
+
"homepage": "https://github.com/statewalker/webrun-files",
|
|
8
|
+
"author": {
|
|
9
|
+
"name": "Mikhail Kotelnikov",
|
|
10
|
+
"email": "mikhail.kotelnikov@gmail.com"
|
|
11
|
+
},
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git@github.com:statewalker/webrun-files.git"
|
|
16
|
+
},
|
|
17
|
+
"main": "./dist/cjs/index.cjs",
|
|
18
|
+
"module": "./dist/esm/index.js",
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"import": "./dist/esm/index.js",
|
|
24
|
+
"require": "./dist/cjs/index.cjs"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist",
|
|
29
|
+
"src"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "rimraf dist && rolldown -c && tsc --emitDeclarationOnly --declaration",
|
|
33
|
+
"test": "vitest run",
|
|
34
|
+
"lint": "biome lint src tests"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@statewalker/webrun-files": "workspace:*"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@statewalker/webrun-files-tests": "workspace:*",
|
|
41
|
+
"@statewalker/webrun-files-mem": "workspace:*",
|
|
42
|
+
"@types/node": "catalog:",
|
|
43
|
+
"rimraf": "catalog:",
|
|
44
|
+
"rolldown": "catalog:",
|
|
45
|
+
"typescript": "catalog:",
|
|
46
|
+
"vitest": "catalog:"
|
|
47
|
+
},
|
|
48
|
+
"sideEffects": false,
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
FileInfo,
|
|
3
|
+
FileStats,
|
|
4
|
+
FilesApi,
|
|
5
|
+
ListOptions,
|
|
6
|
+
ReadOptions,
|
|
7
|
+
} from "@statewalker/webrun-files";
|
|
8
|
+
import { joinPath, normalizePath } from "@statewalker/webrun-files";
|
|
9
|
+
import type { FileGuard, FileOperation } from "./types.js";
|
|
10
|
+
|
|
11
|
+
interface MountEntry {
|
|
12
|
+
prefix: string;
|
|
13
|
+
api: FilesApi;
|
|
14
|
+
basePath: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class CompositeFilesApi implements FilesApi {
|
|
18
|
+
private mounts: MountEntry[];
|
|
19
|
+
private guards: FileGuard[] = [];
|
|
20
|
+
|
|
21
|
+
constructor(root: FilesApi, rootPath?: string) {
|
|
22
|
+
this.mounts = [{ prefix: "/", api: root, basePath: normalizePath(rootPath ?? "/") }];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
mount(path: string, api: FilesApi, fsPath?: string): this {
|
|
26
|
+
const prefix = normalizePath(path);
|
|
27
|
+
if (prefix === "/") {
|
|
28
|
+
throw new Error("Cannot mount at root — root is set via constructor");
|
|
29
|
+
}
|
|
30
|
+
this.mounts.push({ prefix, api, basePath: normalizePath(fsPath ?? "/") });
|
|
31
|
+
// Sort by prefix length descending so longest match comes first
|
|
32
|
+
this.mounts.sort((a, b) => b.prefix.length - a.prefix.length);
|
|
33
|
+
return this;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
guard(operations: FileOperation[], check: (path: string) => boolean, message?: string): this {
|
|
37
|
+
this.guards.push({ operations, check, message });
|
|
38
|
+
return this;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// --- Mount resolution ---
|
|
42
|
+
|
|
43
|
+
private resolve(path: string): { api: FilesApi; resolvedPath: string } {
|
|
44
|
+
const normalized = normalizePath(path);
|
|
45
|
+
for (const mount of this.mounts) {
|
|
46
|
+
if (mount.prefix === "/") {
|
|
47
|
+
return { api: mount.api, resolvedPath: joinPath(mount.basePath, normalized) };
|
|
48
|
+
}
|
|
49
|
+
if (normalized === mount.prefix || normalized.startsWith(`${mount.prefix}/`)) {
|
|
50
|
+
const localPath = normalized.slice(mount.prefix.length) || "/";
|
|
51
|
+
return { api: mount.api, resolvedPath: joinPath(mount.basePath, localPath) };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// Fallback to root (always last after sort)
|
|
55
|
+
const rootMount = this.mounts[this.mounts.length - 1];
|
|
56
|
+
return { api: rootMount.api, resolvedPath: joinPath(rootMount.basePath, normalizePath(path)) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private isMountPoint(path: string): boolean {
|
|
60
|
+
const normalized = normalizePath(path);
|
|
61
|
+
return this.mounts.some((m) => m.prefix === normalized && m.prefix !== "/");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Returns mount prefixes that are direct children of the given path. */
|
|
65
|
+
private childMountPrefixes(parentPath: string): string[] {
|
|
66
|
+
const normalized = normalizePath(parentPath);
|
|
67
|
+
const prefix = normalized === "/" ? "/" : `${normalized}/`;
|
|
68
|
+
const result: string[] = [];
|
|
69
|
+
for (const mount of this.mounts) {
|
|
70
|
+
if (mount.prefix === "/") continue;
|
|
71
|
+
if (!mount.prefix.startsWith(prefix)) continue;
|
|
72
|
+
// Check if this mount is a direct child (no further slashes after the prefix)
|
|
73
|
+
const relative = mount.prefix.slice(prefix.length);
|
|
74
|
+
if (!relative.includes("/")) {
|
|
75
|
+
result.push(mount.prefix);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// --- Guard checking ---
|
|
82
|
+
|
|
83
|
+
private checkGuard(operation: FileOperation, path: string): void {
|
|
84
|
+
const normalized = normalizePath(path);
|
|
85
|
+
for (const guard of this.guards) {
|
|
86
|
+
if (!guard.operations.includes(operation)) continue;
|
|
87
|
+
if (!guard.check(normalized)) {
|
|
88
|
+
const msg = guard.message ?? "Access denied";
|
|
89
|
+
throw new Error(`${msg}: ${normalized}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// --- FilesApi implementation ---
|
|
95
|
+
|
|
96
|
+
read(path: string, options?: ReadOptions): AsyncIterable<Uint8Array> {
|
|
97
|
+
this.checkGuard("read", path);
|
|
98
|
+
const { api, resolvedPath } = this.resolve(path);
|
|
99
|
+
return api.read(resolvedPath, options);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async write(
|
|
103
|
+
path: string,
|
|
104
|
+
content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>,
|
|
105
|
+
): Promise<void> {
|
|
106
|
+
this.checkGuard("write", path);
|
|
107
|
+
const { api, resolvedPath } = this.resolve(path);
|
|
108
|
+
return api.write(resolvedPath, content);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async mkdir(path: string): Promise<void> {
|
|
112
|
+
this.checkGuard("mkdir", path);
|
|
113
|
+
const { api, resolvedPath } = this.resolve(path);
|
|
114
|
+
return api.mkdir(resolvedPath);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async *list(path: string, options?: ListOptions): AsyncIterable<FileInfo> {
|
|
118
|
+
this.checkGuard("list", path);
|
|
119
|
+
const normalized = normalizePath(path);
|
|
120
|
+
const { api, resolvedPath } = this.resolve(path);
|
|
121
|
+
|
|
122
|
+
const childMounts = this.childMountPrefixes(normalized);
|
|
123
|
+
const yieldedNames = new Set<string>();
|
|
124
|
+
|
|
125
|
+
if (options?.recursive) {
|
|
126
|
+
// Yield entries from the primary mount
|
|
127
|
+
for await (const entry of api.list(resolvedPath, options)) {
|
|
128
|
+
// Remap path back to composite namespace
|
|
129
|
+
const compositePath = this.remapPath(normalized, resolvedPath, entry.path);
|
|
130
|
+
// Skip if this path falls under a child mount
|
|
131
|
+
if (this.isUnderChildMount(compositePath, childMounts)) continue;
|
|
132
|
+
yieldedNames.add(entry.name);
|
|
133
|
+
yield { ...entry, path: compositePath };
|
|
134
|
+
}
|
|
135
|
+
// Recursively yield from child mounts
|
|
136
|
+
for (const mountPrefix of childMounts) {
|
|
137
|
+
const mount = this.mounts.find((m) => m.prefix === mountPrefix);
|
|
138
|
+
if (!mount) continue;
|
|
139
|
+
const mountName = mountPrefix.split("/").pop() ?? "";
|
|
140
|
+
// Yield the mount directory entry itself
|
|
141
|
+
yield { name: mountName, path: mountPrefix, kind: "directory" };
|
|
142
|
+
for await (const entry of mount.api.list(mount.basePath, { recursive: true })) {
|
|
143
|
+
const localPath = this.stripBasePath(entry.path, mount.basePath);
|
|
144
|
+
yield { ...entry, path: `${mountPrefix}${localPath === "/" ? "" : localPath}` };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
} else {
|
|
148
|
+
// Non-recursive: yield direct children from the primary mount
|
|
149
|
+
for await (const entry of api.list(resolvedPath)) {
|
|
150
|
+
const compositePath = this.remapPath(normalized, resolvedPath, entry.path);
|
|
151
|
+
yieldedNames.add(entry.name);
|
|
152
|
+
yield { ...entry, path: compositePath };
|
|
153
|
+
}
|
|
154
|
+
// Add synthetic directory entries for child mounts not already present
|
|
155
|
+
for (const mountPrefix of childMounts) {
|
|
156
|
+
const mountName = mountPrefix.split("/").pop() ?? "";
|
|
157
|
+
if (!yieldedNames.has(mountName)) {
|
|
158
|
+
yield { name: mountName, path: mountPrefix, kind: "directory" };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async stats(path: string): Promise<FileStats | undefined> {
|
|
165
|
+
const normalized = normalizePath(path);
|
|
166
|
+
if (this.isMountPoint(normalized)) {
|
|
167
|
+
return { kind: "directory" };
|
|
168
|
+
}
|
|
169
|
+
const { api, resolvedPath } = this.resolve(path);
|
|
170
|
+
return api.stats(resolvedPath);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async exists(path: string): Promise<boolean> {
|
|
174
|
+
const normalized = normalizePath(path);
|
|
175
|
+
if (this.isMountPoint(normalized)) {
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
const { api, resolvedPath } = this.resolve(path);
|
|
179
|
+
return api.exists(resolvedPath);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async remove(path: string): Promise<boolean> {
|
|
183
|
+
const normalized = normalizePath(path);
|
|
184
|
+
if (this.isMountPoint(normalized)) {
|
|
185
|
+
throw new Error(`Cannot remove mount point: ${normalized}`);
|
|
186
|
+
}
|
|
187
|
+
this.checkGuard("remove", path);
|
|
188
|
+
const { api, resolvedPath } = this.resolve(path);
|
|
189
|
+
return api.remove(resolvedPath);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async move(source: string, target: string): Promise<boolean> {
|
|
193
|
+
this.checkGuard("move", source);
|
|
194
|
+
this.checkGuard("move", target);
|
|
195
|
+
const src = this.resolve(source);
|
|
196
|
+
const tgt = this.resolve(target);
|
|
197
|
+
|
|
198
|
+
// Same mount: delegate directly
|
|
199
|
+
if (src.api === tgt.api) {
|
|
200
|
+
return src.api.move(src.resolvedPath, tgt.resolvedPath);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Cross-mount: copy then remove
|
|
204
|
+
const copied = await this.crossCopy(src.api, src.resolvedPath, tgt.api, tgt.resolvedPath);
|
|
205
|
+
if (copied) {
|
|
206
|
+
await src.api.remove(src.resolvedPath);
|
|
207
|
+
}
|
|
208
|
+
return copied;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async copy(source: string, target: string): Promise<boolean> {
|
|
212
|
+
this.checkGuard("copy", source);
|
|
213
|
+
this.checkGuard("copy", target);
|
|
214
|
+
const src = this.resolve(source);
|
|
215
|
+
const tgt = this.resolve(target);
|
|
216
|
+
|
|
217
|
+
// Same mount: delegate directly
|
|
218
|
+
if (src.api === tgt.api) {
|
|
219
|
+
return src.api.copy(src.resolvedPath, tgt.resolvedPath);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Cross-mount copy
|
|
223
|
+
return this.crossCopy(src.api, src.resolvedPath, tgt.api, tgt.resolvedPath);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// --- Helpers ---
|
|
227
|
+
|
|
228
|
+
private stripBasePath(path: string, basePath: string): string {
|
|
229
|
+
if (basePath === "/") return path;
|
|
230
|
+
if (path === basePath) return "/";
|
|
231
|
+
if (path.startsWith(`${basePath}/`)) return path.slice(basePath.length);
|
|
232
|
+
return path;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private async crossCopy(
|
|
236
|
+
srcApi: FilesApi,
|
|
237
|
+
srcPath: string,
|
|
238
|
+
tgtApi: FilesApi,
|
|
239
|
+
tgtPath: string,
|
|
240
|
+
): Promise<boolean> {
|
|
241
|
+
const srcStats = await srcApi.stats(srcPath);
|
|
242
|
+
if (!srcStats) return false;
|
|
243
|
+
|
|
244
|
+
if (srcStats.kind === "file") {
|
|
245
|
+
await tgtApi.write(tgtPath, srcApi.read(srcPath));
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Directory: recursive copy
|
|
250
|
+
await tgtApi.mkdir(tgtPath);
|
|
251
|
+
for await (const entry of srcApi.list(srcPath)) {
|
|
252
|
+
const childSrc = srcPath === "/" ? `/${entry.name}` : `${srcPath}/${entry.name}`;
|
|
253
|
+
const childTgt = tgtPath === "/" ? `/${entry.name}` : `${tgtPath}/${entry.name}`;
|
|
254
|
+
if (entry.kind === "file") {
|
|
255
|
+
await tgtApi.write(childTgt, srcApi.read(childSrc));
|
|
256
|
+
} else {
|
|
257
|
+
await this.crossCopy(srcApi, childSrc, tgtApi, childTgt);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private remapPath(
|
|
264
|
+
compositeParent: string,
|
|
265
|
+
resolvedParent: string,
|
|
266
|
+
resolvedChild: string,
|
|
267
|
+
): string {
|
|
268
|
+
// Convert a resolved path back into composite namespace
|
|
269
|
+
const relative = resolvedChild.startsWith(resolvedParent)
|
|
270
|
+
? resolvedChild.slice(resolvedParent.length)
|
|
271
|
+
: resolvedChild;
|
|
272
|
+
if (compositeParent === "/") {
|
|
273
|
+
return relative.startsWith("/") ? relative : `/${relative}`;
|
|
274
|
+
}
|
|
275
|
+
return `${compositeParent}${relative.startsWith("/") ? relative : `/${relative}`}`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private isUnderChildMount(compositePath: string, childMounts: string[]): boolean {
|
|
279
|
+
for (const mount of childMounts) {
|
|
280
|
+
if (compositePath === mount || compositePath.startsWith(`${mount}/`)) {
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
}
|
package/src/index.ts
ADDED
package/src/types.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type FileOperation = "read" | "write" | "remove" | "move" | "copy" | "list" | "mkdir";
|
|
2
|
+
|
|
3
|
+
export interface FileGuard {
|
|
4
|
+
/** Which filesystem operations this guard intercepts. */
|
|
5
|
+
operations: FileOperation[];
|
|
6
|
+
/** Returns true to allow, false to deny. */
|
|
7
|
+
check: (path: string) => boolean;
|
|
8
|
+
/** Error message when access is denied. */
|
|
9
|
+
message?: string;
|
|
10
|
+
}
|