@statewalker/webrun-files-composite 0.7.1 → 0.8.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 CHANGED
@@ -1,183 +1,258 @@
1
1
  # @statewalker/webrun-files-composite
2
2
 
3
- A `FilesApi` adapter that composes multiple `FilesApi` instances into a unified virtual filesystem with **mount points** and **access guards**.
3
+ ## What it is
4
4
 
5
- ## Features
5
+ A small toolkit of `FilesApi` decorators that lets you build a layered
6
+ virtual filesystem on top of any existing `FilesApi` implementation. It
7
+ ships three building blocks:
6
8
 
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
9
+ - **`CompositeFilesApi`** — mounts multiple `FilesApi` backends at different
10
+ composite-namespace prefixes (longest-prefix wins), with optional
11
+ per-mount sub-directory remapping.
12
+ - **`GuardedFilesApi`**runs every call through an ordered list of
13
+ per-operation policies that can deny access by throwing.
14
+ - **`FilteredFilesApi`**hides selected paths so that the wrapped API
15
+ behaves as if they did not exist.
14
16
 
15
- ## Installation
17
+ ## Why it exists
16
18
 
17
- ```bash
18
- pnpm add @statewalker/webrun-files-composite
19
- ```
19
+ Real filesystems are rarely flat. Workbench-style apps need to combine
20
+ storage backends (local FS for projects, in-memory FS for transient data,
21
+ remote FS for shared documents), forbid writes to system folders, and hide
22
+ implementation-detail paths from end users. Implementing all of this inside
23
+ each storage backend leaks concerns and makes backends hard to swap.
20
24
 
21
- ## Usage
25
+ This package keeps each backend pure and pushes composition / access /
26
+ visibility into orthogonal decorators that can be stacked in any order. The
27
+ three concerns are deliberately split across separate classes:
22
28
 
23
- ### Basic composition
29
+ - **mounting** is structural and never throws — `CompositeFilesApi`.
30
+ - **access control** must throw to stay safe by default — `GuardedFilesApi`.
31
+ - **visibility** must silently lie to keep the consumer model simple —
32
+ `FilteredFilesApi`.
24
33
 
25
- ```typescript
26
- import { CompositeFilesApi } from "@statewalker/webrun-files-composite";
27
- import { MemFilesApi } from "@statewalker/webrun-files-mem";
34
+ ## How to use
28
35
 
29
- const composite = new CompositeFilesApi(new MemFilesApi())
30
- .mount("/docs", new MemFilesApi())
31
- .mount("/cache", new MemFilesApi());
36
+ ```bash
37
+ pnpm add @statewalker/webrun-files-composite
38
+ ```
32
39
 
33
- const encoder = new TextEncoder();
34
- await composite.write("/readme.txt", [encoder.encode("Hello")]);
35
- await composite.write("/docs/guide.md", [encoder.encode("# Guide")]);
40
+ The decorators all implement `FilesApi`, so they compose freely. A typical
41
+ stack: a composite root that mounts a few backends, wrapped first with a
42
+ filter to hide internals, then with guards to forbid writes to system
43
+ folders.
44
+
45
+ ```ts
46
+ import {
47
+ CompositeFilesApi,
48
+ FilteredFilesApi,
49
+ GuardedFilesApi,
50
+ newPathFilter,
51
+ } from "@statewalker/webrun-files-composite";
52
+
53
+ const composite = new CompositeFilesApi(localFs, "/projects")
54
+ .mount("/docs", s3Fs, "/documentation")
55
+ .mount("/cache", memFs);
56
+
57
+ const visible = new FilteredFilesApi(composite, newPathFilter("/.git"));
58
+
59
+ const safe = new GuardedFilesApi(visible, [
60
+ {
61
+ operations: ["write", "remove", "move", "mkdir"],
62
+ check: (p) => !p.startsWith("/.system/"),
63
+ message: "system folder is read-only",
64
+ },
65
+ ]);
36
66
  ```
37
67
 
38
- ### Base path remapping
68
+ ## Examples
39
69
 
40
- Each mount can specify which subdirectory of the backing filesystem to use as its root. The constructor also accepts an optional `rootPath`:
70
+ ### Mount multiple backends
41
71
 
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";
72
+ ```ts
73
+ import { CompositeFilesApi } from "@statewalker/webrun-files-composite";
46
74
 
47
- const fsMain = new NodeFilesApi({ ... });
48
- const fsS3 = new S3FilesApi({ ... });
49
- const fsMem = new MemFilesApi();
75
+ const fs = new CompositeFilesApi(rootFs)
76
+ .mount("/docs", docsFs)
77
+ .mount("/cache", memFs);
50
78
 
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
79
+ await fs.write("/readme.txt", [encoder.encode("Hello")]); // rootFs:/readme.txt
80
+ await fs.write("/docs/guide.md", [encoder.encode("# Guide")]); // → docsFs:/guide.md
81
+ await fs.write("/cache/tmp.dat", [encoder.encode("temp")]); // memFs:/tmp.dat
82
+ ```
55
83
 
56
- // Writes to /readme.txt go to fsMain at /projects/readme.txt
57
- await composite.write("/readme.txt", [encoder.encode("Hello")]);
84
+ ### Remap to a sub-directory of the backend
58
85
 
59
- // Writes to /docs/guide.md go to fsS3 at /documentation/guide.md
60
- await composite.write("/docs/guide.md", [encoder.encode("# Guide")]);
86
+ ```ts
87
+ const fs = new CompositeFilesApi(rootFs, "/projects")
88
+ .mount("/docs", s3Fs, "/documentation");
61
89
 
62
- // Writes to /cache/tmp.dat go to fsMem at /tmp.dat
63
- await composite.write("/cache/tmp.dat", [encoder.encode("temp")]);
90
+ await fs.write("/readme.md", data); // rootFs:/projects/readme.md
91
+ await fs.write("/docs/api.md", data); // → s3Fs:/documentation/api.md
64
92
  ```
65
93
 
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
- );
94
+ ### Hide paths
95
+
96
+ ```ts
97
+ import {
98
+ FilteredFilesApi,
99
+ newGlobPathFilter,
100
+ newPathFilter,
101
+ newRegexpPathFilter,
102
+ } from "@statewalker/webrun-files-composite";
103
+
104
+ // Prefix-based
105
+ const visible = new FilteredFilesApi(rawFs, newPathFilter("/.git", "/node_modules"));
106
+ await visible.exists("/.git"); // false (even if it exists in rawFs)
107
+ await visible.write("/.git/HEAD", x); // throws "Path is hidden: /.git/HEAD"
108
+
109
+ // Regexp-based: hide every dotfile and every *.log file
110
+ const noLogs = new FilteredFilesApi(rawFs, newRegexpPathFilter(/\/\.[^/]+$/, /\.log$/));
111
+
112
+ // Glob-based: hide every .log anywhere, plus the entire .git tree
113
+ const noJunk = new FilteredFilesApi(
114
+ rawFs,
115
+ newGlobPathFilter("**/*.log", "/.git", "/.git/**"),
116
+ );
80
117
  ```
81
118
 
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");
119
+ - `newPathFilter(...prefixes)` hides any path equal to one of the given
120
+ prefixes or living under `${prefix}/`. Matching is boundary-aware:
121
+ `"/priv"` does **not** match `"/private"`.
122
+ - `newRegexpPathFilter(...regexps)` hides any path matched by at least one
123
+ regexp; the path is normalized before testing (single leading slash, no
124
+ trailing slash).
125
+ - `newGlobPathFilter(...globs)` hides any path matched by at least one glob.
126
+ Globs are compiled in `extended` + `globstar` mode: `*` stays inside one
127
+ path segment, `**` spans any number of segments, `?` / `[abc]` / `{a,b}`
128
+ do what bash does. Note that `/foo/**` matches descendants of `/foo` but
129
+ **not** `/foo` itself — list both `"/foo"` and `"/foo/**"` to cover
130
+ both, or just use `newPathFilter("/foo")` for a prefix-only filter.
131
+
132
+ For ad-hoc logic, pass any `(path) => boolean | Promise<boolean>` predicate
133
+ directly (returning `true` for visible, `false` for hidden).
134
+
135
+ ### Guard operations
136
+
137
+ ```ts
138
+ import { GuardedFilesApi } from "@statewalker/webrun-files-composite";
139
+
140
+ const guarded = new GuardedFilesApi(fs, [
141
+ {
142
+ operations: ["write", "remove", "move", "mkdir"],
143
+ check: (p) => !p.startsWith("/.settings/"),
144
+ message: "settings folder is read-only",
145
+ },
146
+ {
147
+ operations: ["write"],
148
+ check: (p) => !p.includes(".."),
149
+ message: "no path traversal",
150
+ },
151
+ ]);
92
152
  ```
93
153
 
94
- ### Listing
154
+ Guards are evaluated in the order they were passed. The first denying
155
+ guard throws `Error("<message>: <normalized-path>")`. A guard that lists
156
+ `"read"` also fires on the source side of `move`/`copy` and on every
157
+ `exists` call; one that lists `"write"` fires on the target side of
158
+ `move`/`copy`; one that lists `"list"` also fires on `stats`.
95
159
 
96
- Mount points appear as synthetic directory entries:
160
+ ### Cross-mount move/copy
97
161
 
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" }
162
+ `CompositeFilesApi` resolves `move` and `copy` across mounts by performing
163
+ a recursive copy and (for `move`) deleting the source. Use guards/filters
164
+ to gate these flows by composite path:
104
165
 
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
- }
166
+ ```ts
167
+ await fs.move("/cache/draft.md", "/docs/draft.md"); // memFs s3Fs
109
168
  ```
110
169
 
111
- ## API
170
+ ## Internals
112
171
 
113
- ### `CompositeFilesApi`
172
+ ### Architecture
114
173
 
115
- Implements the `FilesApi` interface from `@statewalker/webrun-files`.
116
-
117
- #### Constructor
118
-
119
- ```typescript
120
- new CompositeFilesApi(root: FilesApi, rootPath?: string)
121
174
  ```
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; mount points return `{ kind: "directory" }` |
142
- | `exists(path)` | Check if a path exists; returns `true` for mount points |
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
- }
175
+ +---------------------+
176
+ | GuardedFilesApi | policy: throw on denied operations
177
+ +---------------------+
178
+ | FilteredFilesApi | visibility: pretend hidden paths don't exist
179
+ +---------------------+
180
+ | CompositeFilesApi | routing: longest-prefix mount, sub-dir remap
181
+ +---------------------+
182
+ | backend FilesApi | storage: mem / node / s3 / browser
183
+ +---------------------+
157
184
  ```
158
185
 
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 |
186
+ The decorators implement `FilesApi`, so any of them can wrap any other in
187
+ any order. The diagram above is the typical stack but not the only valid
188
+ one (e.g. you can put a `FilteredFilesApi` *behind* a mount to scope its
189
+ filter to that mount only).
190
+
191
+ ### CompositeFilesApi path resolution
192
+
193
+ 1. Input paths are normalized (forward slashes, leading `/`, no trailing `/`).
194
+ 2. The mount with the **longest matching prefix** wins. The implicit `/`
195
+ mount set by the constructor is always last.
196
+ 3. The matched prefix is stripped and the mount's `fsPath` is prepended.
197
+ 4. Mount points themselves appear as synthetic directories in `list()` and
198
+ `stats()`, and `remove()` on a mount point throws.
199
+
200
+ ### GuardedFilesApi — effective operation matrix
201
+
202
+ | API method | Operations checked |
203
+ | ------------ | --------------------------------------------------- |
204
+ | `read` | `read` |
205
+ | `write` | `write` |
206
+ | `mkdir` | `mkdir` |
207
+ | `remove` | `remove` |
208
+ | `list` | `list` on the path AND on each directory entry |
209
+ | `stats` | `list` |
210
+ | `exists` | `read` |
211
+ | `move(s, t)` | `move`+`read` on `s`; `move`+`write` on `t` |
212
+ | `copy(s, t)` | `copy`+`read` on `s`; `copy`+`write` on `t` |
213
+
214
+ This expansion makes `read`/`write`-scoped guards apply naturally to
215
+ `move`/`copy` as well, so callers don't need to repeat the same prefix in
216
+ multiple operation lists.
217
+
218
+ ### FilteredFilesApi — silent vs loud failures
219
+
220
+ | Hidden path on call | Behaviour |
221
+ | ------------------- | ------------------------ |
222
+ | `read` / `list` | empty iterable |
223
+ | `stats` | `undefined` |
224
+ | `exists` | `false` |
225
+ | `remove` | `false` |
226
+ | `move` / `copy` | `false` (no side effect) |
227
+ | `write` / `mkdir` | throws `"Path is hidden"` (silent drop would lose data) |
228
+
229
+ ### Constraints
230
+
231
+ - The root mount (`/`) is fixed at construction; you cannot remount it.
232
+ - Mount points are immutable — `remove()` on a mount point throws.
233
+ - Cross-mount `move` is copy + delete; not atomic.
234
+ - `newPathFilter()` (no args) hides nothing; entries that normalize to
235
+ `"/"` (empty string, `"/"`) are dropped because they would otherwise hide
236
+ the whole tree.
237
+ - `newRegexpPathFilter()` (no args) hides nothing. Avoid stateful flags
238
+ (`/g`, `/y`) — `RegExp.prototype.test` mutates `lastIndex` between calls
239
+ and will give surprising results.
240
+ - `newGlobPathFilter()` (no args) hides nothing. Globs are anchored
241
+ (compiled without the RegExp `g` flag) and run in `globstar` mode, so
242
+ `*` does not cross `/`; use `**` to span segments. `/foo/**` matches
243
+ descendants of `/foo` but not `/foo` itself.
244
+
245
+ ### Dependencies
246
+
247
+ - `@statewalker/webrun-files` — core `FilesApi` interface and path utilities.
248
+
249
+ ### Credits
250
+
251
+ - `globToRegExp` (in [`src/glob-to-regexp.ts`](src/glob-to-regexp.ts)) is a
252
+ TypeScript port of [`glob-to-regexp`](https://github.com/fitzgen/glob-to-regexp)
253
+ by Nick Fitzgerald (BSD 2-Clause). The port preserves the original
254
+ semantics; only the surface API has been re-typed for TypeScript. See the
255
+ upstream repository for the full license text.
181
256
 
182
257
  ## License
183
258