@statewalker/webrun-files-composite 0.7.0 → 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 +219 -144
- package/dist/cjs/index.cjs +454 -28
- package/dist/composite-files-api.d.ts +50 -4
- package/dist/composite-files-api.d.ts.map +1 -1
- package/dist/esm/index.js +449 -29
- package/dist/filtered-files-api.d.ts +167 -0
- package/dist/filtered-files-api.d.ts.map +1 -0
- package/dist/glob-to-regexp.d.ts +56 -0
- package/dist/glob-to-regexp.d.ts.map +1 -0
- package/dist/guarded-files-api.d.ts +64 -0
- package/dist/guarded-files-api.d.ts.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/types.d.ts +50 -3
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/composite-files-api.ts +50 -29
- package/src/filtered-files-api.ts +257 -0
- package/src/glob-to-regexp.ts +182 -0
- package/src/guarded-files-api.ts +131 -0
- package/src/index.ts +10 -0
- package/src/types.ts +52 -3
package/README.md
CHANGED
|
@@ -1,183 +1,258 @@
|
|
|
1
1
|
# @statewalker/webrun-files-composite
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## What it is
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
-
|
|
8
|
-
-
|
|
9
|
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
|
|
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
|
-
##
|
|
17
|
+
## Why it exists
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
import { CompositeFilesApi } from "@statewalker/webrun-files-composite";
|
|
27
|
-
import { MemFilesApi } from "@statewalker/webrun-files-mem";
|
|
34
|
+
## How to use
|
|
28
35
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
36
|
+
```bash
|
|
37
|
+
pnpm add @statewalker/webrun-files-composite
|
|
38
|
+
```
|
|
32
39
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
68
|
+
## Examples
|
|
39
69
|
|
|
40
|
-
|
|
70
|
+
### Mount multiple backends
|
|
41
71
|
|
|
42
|
-
```
|
|
43
|
-
import {
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
75
|
+
const fs = new CompositeFilesApi(rootFs)
|
|
76
|
+
.mount("/docs", docsFs)
|
|
77
|
+
.mount("/cache", memFs);
|
|
50
78
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
57
|
-
await composite.write("/readme.txt", [encoder.encode("Hello")]);
|
|
84
|
+
### Remap to a sub-directory of the backend
|
|
58
85
|
|
|
59
|
-
|
|
60
|
-
|
|
86
|
+
```ts
|
|
87
|
+
const fs = new CompositeFilesApi(rootFs, "/projects")
|
|
88
|
+
.mount("/docs", s3Fs, "/documentation");
|
|
61
89
|
|
|
62
|
-
|
|
63
|
-
await
|
|
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
|
-
###
|
|
67
|
-
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
160
|
+
### Cross-mount move/copy
|
|
97
161
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
##
|
|
170
|
+
## Internals
|
|
112
171
|
|
|
113
|
-
###
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
|
176
|
-
|
|
177
|
-
|
|
|
178
|
-
|
|
|
179
|
-
|
|
|
180
|
-
|
|
|
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
|
|