@statewalker/webrun-files-composite 0.7.1 → 0.8.1
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/LICENSE +21 -0
- package/README.md +219 -144
- package/dist/cjs/index.cjs +770 -28
- package/dist/composite-files-api.d.ts +50 -4
- package/dist/composite-files-api.d.ts.map +1 -1
- package/dist/cow-files-api.d.ts +36 -0
- package/dist/cow-files-api.d.ts.map +1 -0
- package/dist/esm/index.js +762 -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 +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/overlay-files-api.d.ts +20 -0
- package/dist/overlay-files-api.d.ts.map +1 -0
- package/dist/read-only-files-api.d.ts +21 -0
- package/dist/read-only-files-api.d.ts.map +1 -0
- package/dist/types.d.ts +50 -3
- package/dist/types.d.ts.map +1 -1
- package/package.json +15 -15
- package/src/composite-files-api.ts +50 -29
- package/src/cow-files-api.ts +244 -0
- 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 +14 -0
- package/src/overlay-files-api.ts +121 -0
- package/src/read-only-files-api.ts +30 -0
- package/src/types.ts +52 -3
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
FileInfo,
|
|
3
|
+
FileStats,
|
|
4
|
+
FilesApi,
|
|
5
|
+
ListOptions,
|
|
6
|
+
ReadOptions,
|
|
7
|
+
} from "@statewalker/webrun-files";
|
|
8
|
+
import { basename, dirname, joinPath, normalizePath } from "@statewalker/webrun-files";
|
|
9
|
+
|
|
10
|
+
/** Options for {@link cow}. */
|
|
11
|
+
export interface CowOptions {
|
|
12
|
+
/**
|
|
13
|
+
* Filename prefix for per-path whiteout markers, stored in the writable
|
|
14
|
+
* layer. A whiteout for `/dir/name` lives at `/dir/<prefix>name`.
|
|
15
|
+
* Defaults to `".wh."`.
|
|
16
|
+
*/
|
|
17
|
+
whiteoutPrefix?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Filename for the opaque-directory marker, stored as a child of a
|
|
20
|
+
* directory whose base subtree has been deleted. Defaults to `".wh..opq"`.
|
|
21
|
+
*/
|
|
22
|
+
opaqueName?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type Layer = "writable" | "base" | "absent";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Copy-on-write view: a writable layer over a read-only `base`. The `base`
|
|
29
|
+
* is **never** mutated — every change (write, delete, move) is recorded in
|
|
30
|
+
* the `writable` layer, using marker files to record deletions.
|
|
31
|
+
*
|
|
32
|
+
* Resolution precedence for any path:
|
|
33
|
+
* 1. a covering whiteout / opaque marker ⇒ the path is absent;
|
|
34
|
+
* 2. otherwise the `writable` layer, if it has the path;
|
|
35
|
+
* 3. otherwise fall through to `base`.
|
|
36
|
+
*
|
|
37
|
+
* Deletions are persisted as marker files inside the writable layer, so they
|
|
38
|
+
* survive across process restarts and work over any `FilesApi` backend.
|
|
39
|
+
* Markers are hidden from `read` / `list` / `stats` / `exists`.
|
|
40
|
+
*
|
|
41
|
+
* Use {@link cow} to construct one.
|
|
42
|
+
*/
|
|
43
|
+
class CowFilesApi implements FilesApi {
|
|
44
|
+
private readonly base: FilesApi;
|
|
45
|
+
private readonly writable: FilesApi;
|
|
46
|
+
private readonly whiteoutPrefix: string;
|
|
47
|
+
private readonly opaqueName: string;
|
|
48
|
+
|
|
49
|
+
constructor(base: FilesApi, writable: FilesApi, opts?: CowOptions) {
|
|
50
|
+
this.base = base;
|
|
51
|
+
this.writable = writable;
|
|
52
|
+
this.whiteoutPrefix = opts?.whiteoutPrefix ?? ".wh.";
|
|
53
|
+
this.opaqueName = opts?.opaqueName ?? ".wh..opq";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// --- marker helpers ---
|
|
57
|
+
|
|
58
|
+
private whiteoutPath(path: string): string {
|
|
59
|
+
const p = normalizePath(path);
|
|
60
|
+
return joinPath(dirname(p), `${this.whiteoutPrefix}${basename(p)}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private opaquePath(dir: string): string {
|
|
64
|
+
return joinPath(normalizePath(dir), this.opaqueName);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private isMarker(name: string): boolean {
|
|
68
|
+
return name.startsWith(this.whiteoutPrefix) || name === this.opaqueName;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private *ancestorsInclusive(dir: string): Iterable<string> {
|
|
72
|
+
let cur = normalizePath(dir);
|
|
73
|
+
yield cur;
|
|
74
|
+
while (cur !== "/") {
|
|
75
|
+
cur = dirname(cur);
|
|
76
|
+
yield cur;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private async hasDirectWhiteout(path: string): Promise<boolean> {
|
|
81
|
+
return this.writable.exists(this.whiteoutPath(path));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** True when an opaque marker on `dir` or any ancestor hides base content. */
|
|
85
|
+
private async isBaseContentHidden(dir: string): Promise<boolean> {
|
|
86
|
+
for (const ancestor of this.ancestorsInclusive(dir)) {
|
|
87
|
+
if (await this.writable.exists(this.opaquePath(ancestor))) return true;
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** True when the base version of `path` is hidden by a marker. */
|
|
93
|
+
private async isBaseHidden(path: string): Promise<boolean> {
|
|
94
|
+
if (await this.hasDirectWhiteout(path)) return true;
|
|
95
|
+
return this.isBaseContentHidden(dirname(normalizePath(path)));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private async resolveLayer(path: string): Promise<Layer> {
|
|
99
|
+
if (await this.writable.exists(path)) return "writable";
|
|
100
|
+
if (await this.isBaseHidden(path)) return "absent";
|
|
101
|
+
if (await this.base.exists(path)) return "base";
|
|
102
|
+
return "absent";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Removes a covering whiteout marker so a write/mkdir resurrects the path. */
|
|
106
|
+
private async clearWhiteout(path: string): Promise<void> {
|
|
107
|
+
const marker = this.whiteoutPath(path);
|
|
108
|
+
if (await this.writable.exists(marker)) {
|
|
109
|
+
await this.writable.remove(marker);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// --- FilesApi ---
|
|
114
|
+
|
|
115
|
+
async *read(path: string, options?: ReadOptions): AsyncIterable<Uint8Array> {
|
|
116
|
+
const layer = await this.resolveLayer(path);
|
|
117
|
+
if (layer === "writable") yield* this.writable.read(path, options);
|
|
118
|
+
else if (layer === "base") yield* this.base.read(path, options);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async write(
|
|
122
|
+
path: string,
|
|
123
|
+
content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>,
|
|
124
|
+
): Promise<void> {
|
|
125
|
+
await this.clearWhiteout(path);
|
|
126
|
+
await this.writable.write(path, content);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async mkdir(path: string): Promise<void> {
|
|
130
|
+
await this.clearWhiteout(path);
|
|
131
|
+
await this.writable.mkdir(path);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async stats(path: string): Promise<FileStats | undefined> {
|
|
135
|
+
const layer = await this.resolveLayer(path);
|
|
136
|
+
if (layer === "writable") return this.writable.stats(path);
|
|
137
|
+
if (layer === "base") return this.base.stats(path);
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async exists(path: string): Promise<boolean> {
|
|
142
|
+
return (await this.resolveLayer(path)) !== "absent";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async *list(path: string, options?: ListOptions): AsyncIterable<FileInfo> {
|
|
146
|
+
yield* this.listDir(normalizePath(path), options?.recursive ?? false);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private async *listDir(dir: string, recursive: boolean): AsyncIterable<FileInfo> {
|
|
150
|
+
const stats = await this.stats(dir);
|
|
151
|
+
if (stats?.kind !== "directory") return;
|
|
152
|
+
for (const entry of await this.mergeChildren(dir)) {
|
|
153
|
+
yield entry;
|
|
154
|
+
if (recursive && entry.kind === "directory") {
|
|
155
|
+
yield* this.listDir(entry.path, true);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Direct children of `dir`: writable entries win, base entries fill in. */
|
|
161
|
+
private async mergeChildren(dir: string): Promise<FileInfo[]> {
|
|
162
|
+
const merged = new Map<string, FileInfo>();
|
|
163
|
+
if ((await this.writable.stats(dir))?.kind === "directory") {
|
|
164
|
+
for await (const entry of this.writable.list(dir)) {
|
|
165
|
+
if (this.isMarker(entry.name)) continue;
|
|
166
|
+
merged.set(entry.name, entry);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const baseHidden = await this.isBaseContentHidden(dir);
|
|
170
|
+
if (!baseHidden && (await this.base.stats(dir))?.kind === "directory") {
|
|
171
|
+
for await (const entry of this.base.list(dir)) {
|
|
172
|
+
if (merged.has(entry.name)) continue;
|
|
173
|
+
if (await this.hasDirectWhiteout(entry.path)) continue;
|
|
174
|
+
merged.set(entry.name, entry);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return [...merged.values()];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async remove(path: string): Promise<boolean> {
|
|
181
|
+
if ((await this.resolveLayer(path)) === "absent") return false;
|
|
182
|
+
if (await this.writable.exists(path)) {
|
|
183
|
+
await this.writable.remove(path);
|
|
184
|
+
}
|
|
185
|
+
// Record a whiteout only when the base still carries the path.
|
|
186
|
+
const baseStats = await this.base.stats(path);
|
|
187
|
+
if (baseStats) {
|
|
188
|
+
const marker =
|
|
189
|
+
baseStats.kind === "directory" ? this.opaquePath(path) : this.whiteoutPath(path);
|
|
190
|
+
await this.writable.write(marker, []);
|
|
191
|
+
}
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async move(source: string, target: string): Promise<boolean> {
|
|
196
|
+
const stats = await this.stats(source);
|
|
197
|
+
if (!stats) return false;
|
|
198
|
+
await this.copyInto(source, target, stats);
|
|
199
|
+
await this.remove(source);
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async copy(source: string, target: string): Promise<boolean> {
|
|
204
|
+
const stats = await this.stats(source);
|
|
205
|
+
if (!stats) return false;
|
|
206
|
+
await this.copyInto(source, target, stats);
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Copies the composite view of `src` into the writable layer at `tgt`. */
|
|
211
|
+
private async copyInto(src: string, tgt: string, stats: FileStats): Promise<void> {
|
|
212
|
+
if (stats.kind === "file") {
|
|
213
|
+
await this.write(tgt, this.read(src));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
await this.mkdir(tgt);
|
|
217
|
+
for await (const entry of this.list(src)) {
|
|
218
|
+
await this.copyInto(entry.path, joinPath(tgt, entry.name), entry);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Builds a copy-on-write `FilesApi`: a `writable` layer over a read-only
|
|
225
|
+
* `base`. Reads fall through to `base`; every write goes to `writable`;
|
|
226
|
+
* `base` is never mutated. Deletions are persisted as marker files in
|
|
227
|
+
* `writable` (a per-path whiteout for files, one opaque marker for a deleted
|
|
228
|
+
* base directory), so they survive over any backend and across restarts.
|
|
229
|
+
*
|
|
230
|
+
* @param base The read-only lower layer. Never mutated.
|
|
231
|
+
* @param writable The upper layer that captures all changes and markers.
|
|
232
|
+
* @param opts Marker naming overrides (see {@link CowOptions}).
|
|
233
|
+
* @returns A read/write `FilesApi` composing the two layers.
|
|
234
|
+
*
|
|
235
|
+
* @example
|
|
236
|
+
* ```ts
|
|
237
|
+
* const fs = cow(releaseFiles, new MemFilesApi());
|
|
238
|
+
* await fs.write("/a.txt", data); // captured in the writable layer
|
|
239
|
+
* await fs.remove("/base-only"); // whiteout marker; base untouched
|
|
240
|
+
* ```
|
|
241
|
+
*/
|
|
242
|
+
export function cow(base: FilesApi, writable: FilesApi, opts?: CowOptions): FilesApi {
|
|
243
|
+
return new CowFilesApi(base, writable, opts);
|
|
244
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
FileInfo,
|
|
3
|
+
FileStats,
|
|
4
|
+
FilesApi,
|
|
5
|
+
ListOptions,
|
|
6
|
+
ReadOptions,
|
|
7
|
+
} from "@statewalker/webrun-files";
|
|
8
|
+
import { normalizePath } from "@statewalker/webrun-files";
|
|
9
|
+
import { globToRegExp } from "./glob-to-regexp.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Predicate evaluated against a **normalized** path (single leading slash,
|
|
13
|
+
* no trailing slash) by `FilteredFilesApi` to decide visibility.
|
|
14
|
+
*
|
|
15
|
+
* - `true` → the path is visible (the wrapped operation runs).
|
|
16
|
+
* - `false` → the path is hidden (the wrapper short-circuits as if the path
|
|
17
|
+
* did not exist).
|
|
18
|
+
*
|
|
19
|
+
* The predicate may be synchronous or return a `Promise<boolean>`. When
|
|
20
|
+
* async, it is awaited on each call — so prefer pure / cheap checks.
|
|
21
|
+
*/
|
|
22
|
+
export type PathFilter = (path: string) => boolean | Promise<boolean>;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Builds a {@link PathFilter} that hides any path whose normalized form
|
|
26
|
+
* equals one of the provided prefixes or lives under `${prefix}/`.
|
|
27
|
+
*
|
|
28
|
+
* Prefixes are normalized through `normalizePath` (so `"foo"`, `"/foo"`, and
|
|
29
|
+
* `"/foo/"` are equivalent). Empty / root entries are dropped — they would
|
|
30
|
+
* otherwise hide every path.
|
|
31
|
+
*
|
|
32
|
+
* Matching is **boundary-aware**: the prefix `"/priv"` does not match the
|
|
33
|
+
* path `"/private"` because there is no `/` boundary between them.
|
|
34
|
+
*
|
|
35
|
+
* @param pathPrefixes Path prefixes whose contents (and the prefix itself)
|
|
36
|
+
* should be hidden. Pass none to hide nothing.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```ts
|
|
40
|
+
* const filter = newPathFilter("/.git", "/node_modules");
|
|
41
|
+
* filter("/src/index.ts"); // true
|
|
42
|
+
* filter("/.git"); // false
|
|
43
|
+
* filter("/.git/HEAD"); // false
|
|
44
|
+
* filter("/notgit"); // true (boundary-aware, no false match)
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export function newPathFilter(...pathPrefixes: string[]): PathFilter {
|
|
48
|
+
const normalized = pathPrefixes.map((p) => normalizePath(p)).filter((p) => p !== "/");
|
|
49
|
+
return (path: string) => {
|
|
50
|
+
const target = normalizePath(path);
|
|
51
|
+
for (const prefix of normalized) {
|
|
52
|
+
if (target === prefix) return false;
|
|
53
|
+
if (target.startsWith(`${prefix}/`)) return false;
|
|
54
|
+
}
|
|
55
|
+
return true;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Builds a {@link PathFilter} that hides any path whose normalized form
|
|
61
|
+
* matches at least one of the provided regular expressions.
|
|
62
|
+
*
|
|
63
|
+
* The path is normalized through `normalizePath` before matching, so a
|
|
64
|
+
* regexp anchored on `^/` always sees a leading slash and never a trailing
|
|
65
|
+
* one. The regexp's `lastIndex` is irrelevant — the filter calls `test`
|
|
66
|
+
* via a fresh evaluation each time, but stateful (`/g`, `/y`) regexps
|
|
67
|
+
* still mutate `lastIndex` across calls; pass non-stateful regexps unless
|
|
68
|
+
* you know what you are doing.
|
|
69
|
+
*
|
|
70
|
+
* @param pathRegexps Regular expressions whose match means "hide this
|
|
71
|
+
* path". Pass none to hide nothing.
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* ```ts
|
|
75
|
+
* // Hide every dotfile and every *.log file
|
|
76
|
+
* const filter = newRegexpPathFilter(/\/\.[^/]+$/, /\.log$/);
|
|
77
|
+
* filter("/src/index.ts"); // true
|
|
78
|
+
* filter("/.env"); // false (matches /\/\.[^/]+$/)
|
|
79
|
+
* filter("/build.log"); // false (matches /\.log$/)
|
|
80
|
+
* ```
|
|
81
|
+
*/
|
|
82
|
+
export function newRegexpPathFilter(...pathRegexps: RegExp[]): PathFilter {
|
|
83
|
+
return (path: string) => {
|
|
84
|
+
const target = normalizePath(path);
|
|
85
|
+
for (const regexp of pathRegexps) {
|
|
86
|
+
if (regexp.test(target)) return false;
|
|
87
|
+
}
|
|
88
|
+
return true;
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Builds a {@link PathFilter} that hides any path whose normalized form
|
|
94
|
+
* matches at least one of the provided glob patterns.
|
|
95
|
+
*
|
|
96
|
+
* Each glob is compiled with `extended: true` and `globstar: true`, the
|
|
97
|
+
* standard "filesystem-style" mode:
|
|
98
|
+
*
|
|
99
|
+
* - `*` matches any number of characters within a single path segment
|
|
100
|
+
* (does **not** cross `/`).
|
|
101
|
+
* - `**` between slashes matches zero or more whole path segments.
|
|
102
|
+
* - `?` matches exactly one character.
|
|
103
|
+
* - `[abc]` / `[a-z]` matches a single character in the set / range.
|
|
104
|
+
* - `{a,b,c}` matches one of the alternatives.
|
|
105
|
+
*
|
|
106
|
+
* Because matching is done on the **normalized** path (which always starts
|
|
107
|
+
* with `/`), patterns that should match anywhere in the tree need a
|
|
108
|
+
* leading `**/`, e.g. `**/*.log` to hide every `.log` file at any depth.
|
|
109
|
+
*
|
|
110
|
+
* Gotcha: `/foo/**` matches descendants of `/foo` but **not** `/foo`
|
|
111
|
+
* itself, because the glob requires a `/` after `foo` before `**` can
|
|
112
|
+
* match. To hide both the directory and its contents, list both prefixes:
|
|
113
|
+
* `newGlobPathFilter("/foo", "/foo/**")`. {@link newPathFilter} doesn't
|
|
114
|
+
* have this problem and may be a better fit for prefix-only hiding.
|
|
115
|
+
*
|
|
116
|
+
* @param pathGlobs Glob patterns whose match means "hide this path". Pass
|
|
117
|
+
* none to hide nothing.
|
|
118
|
+
*
|
|
119
|
+
* @example
|
|
120
|
+
* ```ts
|
|
121
|
+
* const filter = newGlobPathFilter("**/*.log", "/.git", "/.git/**");
|
|
122
|
+
* filter("/src/index.ts"); // true
|
|
123
|
+
* filter("/build.log"); // false (matches **/*.log)
|
|
124
|
+
* filter("/.git"); // false (matches /.git)
|
|
125
|
+
* filter("/.git/HEAD"); // false (matches /.git/**)
|
|
126
|
+
* ```
|
|
127
|
+
*/
|
|
128
|
+
export function newGlobPathFilter(...pathGlobs: string[]): PathFilter {
|
|
129
|
+
const regexps = pathGlobs.map((glob) => globToRegExp(glob, { extended: true, globstar: true }));
|
|
130
|
+
return newRegexpPathFilter(...regexps);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* `FilesApi` decorator that hides every path the supplied {@link PathFilter}
|
|
135
|
+
* rejects. Hidden paths are treated as if they do not exist:
|
|
136
|
+
*
|
|
137
|
+
* - `read` / `list` yield empty iterables.
|
|
138
|
+
* - `stats` returns `undefined`; `exists` returns `false`.
|
|
139
|
+
* - `remove` returns `false` (no error, nothing changed).
|
|
140
|
+
* - `move` / `copy` return `false` if either endpoint is hidden.
|
|
141
|
+
* - `write` / `mkdir` reject with an `Error` (since silently dropping a
|
|
142
|
+
* write would lose data).
|
|
143
|
+
* - `list` recursively skips entries whose paths are hidden, so iterating a
|
|
144
|
+
* visible parent never reveals a hidden child.
|
|
145
|
+
*
|
|
146
|
+
* Wrap any `FilesApi` to scope its visibility without changing the
|
|
147
|
+
* underlying storage; the wrapped instance still holds the data, it is just
|
|
148
|
+
* not reachable through this decorator.
|
|
149
|
+
*
|
|
150
|
+
* Pair with one of the built-in {@link PathFilter} factories
|
|
151
|
+
* ({@link newPathFilter}, {@link newRegexpPathFilter},
|
|
152
|
+
* {@link newGlobPathFilter}) or pass any predicate of shape
|
|
153
|
+
* `(path) => boolean | Promise<boolean>`.
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* ```ts
|
|
157
|
+
* import {
|
|
158
|
+
* FilteredFilesApi,
|
|
159
|
+
* newGlobPathFilter,
|
|
160
|
+
* newPathFilter,
|
|
161
|
+
* newRegexpPathFilter,
|
|
162
|
+
* } from "@statewalker/webrun-files-composite";
|
|
163
|
+
*
|
|
164
|
+
* // Hide by path prefix
|
|
165
|
+
* const noVcs = new FilteredFilesApi(sourceFiles, newPathFilter("/.git", "/.cache"));
|
|
166
|
+
* await noVcs.exists("/.git"); // false
|
|
167
|
+
* await noVcs.write("/.git/x", data); // throws "Path is hidden"
|
|
168
|
+
*
|
|
169
|
+
* // Hide by regexp
|
|
170
|
+
* const noLogs = new FilteredFilesApi(sourceFiles, newRegexpPathFilter(/\.log$/));
|
|
171
|
+
*
|
|
172
|
+
* // Hide by glob (extended + globstar mode)
|
|
173
|
+
* const noJunk = new FilteredFilesApi(
|
|
174
|
+
* sourceFiles,
|
|
175
|
+
* newGlobPathFilter("**/*.log", "/.git", "/.git/**"),
|
|
176
|
+
* );
|
|
177
|
+
* ```
|
|
178
|
+
*/
|
|
179
|
+
export class FilteredFilesApi implements FilesApi {
|
|
180
|
+
private readonly source: FilesApi;
|
|
181
|
+
private readonly pathFilter: PathFilter;
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* @param source The underlying `FilesApi` whose paths will be selectively
|
|
185
|
+
* hidden. Operations always delegate to this instance; the decorator
|
|
186
|
+
* only adds the visibility check.
|
|
187
|
+
* @param pathFilter Predicate that decides per-call whether a normalized
|
|
188
|
+
* path is visible. See {@link PathFilter}.
|
|
189
|
+
*/
|
|
190
|
+
constructor(source: FilesApi, pathFilter: PathFilter) {
|
|
191
|
+
this.source = source;
|
|
192
|
+
this.pathFilter = pathFilter;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
protected async isHidden(path: string): Promise<boolean> {
|
|
196
|
+
return (await this.pathFilter(normalizePath(path))) === false;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async *read(path: string, options?: ReadOptions): AsyncIterable<Uint8Array> {
|
|
200
|
+
if (await this.isHidden(path)) return;
|
|
201
|
+
yield* this.source.read(path, options);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async write(
|
|
205
|
+
path: string,
|
|
206
|
+
content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>,
|
|
207
|
+
): Promise<void> {
|
|
208
|
+
if (await this.isHidden(path)) {
|
|
209
|
+
throw new Error(`Path is hidden: ${path}`);
|
|
210
|
+
}
|
|
211
|
+
await this.source.write(path, content);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async mkdir(path: string): Promise<void> {
|
|
215
|
+
if (await this.isHidden(path)) {
|
|
216
|
+
throw new Error(`Path is hidden: ${path}`);
|
|
217
|
+
}
|
|
218
|
+
await this.source.mkdir(path);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async *list(path: string, options?: ListOptions): AsyncIterable<FileInfo> {
|
|
222
|
+
if (await this.isHidden(path)) return;
|
|
223
|
+
for await (const entry of this.source.list(path, options)) {
|
|
224
|
+
if (await this.isHidden(entry.path)) continue;
|
|
225
|
+
yield entry;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async stats(path: string): Promise<FileStats | undefined> {
|
|
230
|
+
if (await this.isHidden(path)) return undefined;
|
|
231
|
+
return this.source.stats(path);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async exists(path: string): Promise<boolean> {
|
|
235
|
+
if (await this.isHidden(path)) return false;
|
|
236
|
+
return this.source.exists(path);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async remove(path: string): Promise<boolean> {
|
|
240
|
+
if (await this.isHidden(path)) return false;
|
|
241
|
+
return this.source.remove(path);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async move(source: string, target: string): Promise<boolean> {
|
|
245
|
+
if ((await this.isHidden(source)) || (await this.isHidden(target))) {
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
return this.source.move(source, target);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async copy(source: string, target: string): Promise<boolean> {
|
|
252
|
+
if ((await this.isHidden(source)) || (await this.isHidden(target))) {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
return this.source.copy(source, target);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Glob → RegExp compiler.
|
|
3
|
+
*
|
|
4
|
+
* TypeScript port of `glob-to-regexp` by Nick Fitzgerald.
|
|
5
|
+
*
|
|
6
|
+
* Upstream:
|
|
7
|
+
* - https://github.com/fitzgen/glob-to-regexp
|
|
8
|
+
* - source: https://raw.githubusercontent.com/fitzgen/glob-to-regexp/master/index.js
|
|
9
|
+
* - tests: https://raw.githubusercontent.com/fitzgen/glob-to-regexp/master/test.js
|
|
10
|
+
*
|
|
11
|
+
* The original is published under the BSD 2-Clause license — see the
|
|
12
|
+
* upstream repository for the full text. This port preserves the original
|
|
13
|
+
* semantics; only the surface API has been re-typed for TypeScript.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface GlobToRegExpOptions {
|
|
17
|
+
/**
|
|
18
|
+
* Enable bash-style extended globs. When `true`:
|
|
19
|
+
*
|
|
20
|
+
* - `?` matches exactly one character.
|
|
21
|
+
* - `[abc]` / `[a-z]` matches a single character in the set / range.
|
|
22
|
+
* - `{foo,bar}` matches one of the alternatives.
|
|
23
|
+
*
|
|
24
|
+
* When `false` (default), each of these characters is treated literally
|
|
25
|
+
* (they are escaped in the output regexp).
|
|
26
|
+
*/
|
|
27
|
+
extended?: boolean;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Enable bash-style globstar semantics for `*` and `**`.
|
|
31
|
+
*
|
|
32
|
+
* - With `globstar: false` (default), every run of `*`s is translated to
|
|
33
|
+
* `.*` — so `*` matches any number of characters, including `/`.
|
|
34
|
+
* - With `globstar: true`, a single `*` only matches within one path
|
|
35
|
+
* segment (`[^/]*`), and a `**` segment (`**` between `/`s, or at the
|
|
36
|
+
* start/end of the pattern) matches zero or more whole segments.
|
|
37
|
+
*/
|
|
38
|
+
globstar?: boolean;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* RegExp flags passed to the `RegExp` constructor. When `flags` includes
|
|
42
|
+
* `"g"`, the produced regexp is **not** anchored with `^…$`, so the glob
|
|
43
|
+
* matches anywhere in the string instead of the whole string.
|
|
44
|
+
*/
|
|
45
|
+
flags?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Compiles a glob pattern into a `RegExp`.
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```ts
|
|
53
|
+
* globToRegExp("*.js"); // /^.*\.js$/
|
|
54
|
+
* globToRegExp("*.js", { globstar: true }); // /^([^/]*)\.js$/
|
|
55
|
+
* globToRegExp("/foo/**", { globstar: true }) // /^\/foo\/((?:[^/]*(?:\/|$))*)$/
|
|
56
|
+
* globToRegExp("foo{bar,baz}", { extended: true }); // /^foo(bar|baz)$/
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
export function globToRegExp(glob: string, opts: GlobToRegExpOptions = {}): RegExp {
|
|
60
|
+
if (typeof glob !== "string") {
|
|
61
|
+
throw new TypeError("Expected a string");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const str = String(glob);
|
|
65
|
+
|
|
66
|
+
// The regexp we are building, as a string.
|
|
67
|
+
let reStr = "";
|
|
68
|
+
|
|
69
|
+
// Whether we are matching so called "extended" globs (like bash) and
|
|
70
|
+
// should support single character matching, matching ranges of
|
|
71
|
+
// characters, group matching, etc.
|
|
72
|
+
const extended = !!opts.extended;
|
|
73
|
+
|
|
74
|
+
// Globstar semantics for `*` / `**`.
|
|
75
|
+
// - false: '/foo/*' becomes '^/foo/.*$' (matches '/foo/bar' AND '/foo/bar/baz')
|
|
76
|
+
// - true: '/foo/*' becomes '^/foo/[^/]*$' (matches '/foo/bar' only)
|
|
77
|
+
// '/foo/**' (with globstar=true) means "any depth under /foo".
|
|
78
|
+
const globstar = !!opts.globstar;
|
|
79
|
+
|
|
80
|
+
// True while inside an extended `{a,b}` group. Used to translate `,`.
|
|
81
|
+
let inGroup = false;
|
|
82
|
+
|
|
83
|
+
// RegExp flags passed straight through to the RegExp constructor.
|
|
84
|
+
const flags = typeof opts.flags === "string" ? opts.flags : "";
|
|
85
|
+
|
|
86
|
+
for (let i = 0; i < str.length; i++) {
|
|
87
|
+
const c = str[i];
|
|
88
|
+
|
|
89
|
+
switch (c) {
|
|
90
|
+
case "/":
|
|
91
|
+
case "$":
|
|
92
|
+
case "^":
|
|
93
|
+
case "+":
|
|
94
|
+
case ".":
|
|
95
|
+
case "(":
|
|
96
|
+
case ")":
|
|
97
|
+
case "=":
|
|
98
|
+
case "!":
|
|
99
|
+
case "|":
|
|
100
|
+
reStr += `\\${c}`;
|
|
101
|
+
break;
|
|
102
|
+
|
|
103
|
+
case "?":
|
|
104
|
+
reStr += extended ? "." : `\\${c}`;
|
|
105
|
+
break;
|
|
106
|
+
|
|
107
|
+
case "[":
|
|
108
|
+
case "]":
|
|
109
|
+
reStr += extended ? c : `\\${c}`;
|
|
110
|
+
break;
|
|
111
|
+
|
|
112
|
+
case "{":
|
|
113
|
+
if (extended) {
|
|
114
|
+
inGroup = true;
|
|
115
|
+
reStr += "(";
|
|
116
|
+
} else {
|
|
117
|
+
reStr += `\\${c}`;
|
|
118
|
+
}
|
|
119
|
+
break;
|
|
120
|
+
|
|
121
|
+
case "}":
|
|
122
|
+
if (extended) {
|
|
123
|
+
inGroup = false;
|
|
124
|
+
reStr += ")";
|
|
125
|
+
} else {
|
|
126
|
+
reStr += `\\${c}`;
|
|
127
|
+
}
|
|
128
|
+
break;
|
|
129
|
+
|
|
130
|
+
case ",":
|
|
131
|
+
if (inGroup) {
|
|
132
|
+
reStr += "|";
|
|
133
|
+
} else {
|
|
134
|
+
reStr += `\\${c}`;
|
|
135
|
+
}
|
|
136
|
+
break;
|
|
137
|
+
|
|
138
|
+
case "*": {
|
|
139
|
+
// Coalesce consecutive "*"s and remember the surrounding chars so
|
|
140
|
+
// we can decide whether this run is a "globstar" segment.
|
|
141
|
+
const prevChar = str[i - 1];
|
|
142
|
+
let starCount = 1;
|
|
143
|
+
while (str[i + 1] === "*") {
|
|
144
|
+
starCount++;
|
|
145
|
+
i++;
|
|
146
|
+
}
|
|
147
|
+
const nextChar = str[i + 1];
|
|
148
|
+
|
|
149
|
+
if (!globstar) {
|
|
150
|
+
// globstar disabled: any number of "*" maps to ".*".
|
|
151
|
+
reStr += ".*";
|
|
152
|
+
} else {
|
|
153
|
+
const isGlobstar =
|
|
154
|
+
starCount > 1 && // multiple "*"s
|
|
155
|
+
(prevChar === "/" || prevChar === undefined) && // segment start
|
|
156
|
+
(nextChar === "/" || nextChar === undefined); // segment end
|
|
157
|
+
|
|
158
|
+
if (isGlobstar) {
|
|
159
|
+
// Match zero or more whole path segments.
|
|
160
|
+
reStr += "((?:[^/]*(?:\\/|$))*)";
|
|
161
|
+
i++; // consume the trailing "/"
|
|
162
|
+
} else {
|
|
163
|
+
// Match within one path segment only.
|
|
164
|
+
reStr += "([^/]*)";
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
default:
|
|
171
|
+
reStr += c;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// When the "g" flag is set, leave the regexp un-anchored so the caller
|
|
176
|
+
// can match the glob anywhere within a longer string.
|
|
177
|
+
if (!flags?.includes("g")) {
|
|
178
|
+
reStr = `^${reStr}$`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return new RegExp(reStr, flags);
|
|
182
|
+
}
|