@muhkoo/connect 0.12.2-alpha.0 → 0.13.1-alpha.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/dist/browser/index.js +149 -19
- package/dist/browser/index.js.map +1 -1
- package/dist/connect.d.ts +1 -0
- package/dist/server/index.js +149 -19
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
package/dist/connect.d.ts
CHANGED
|
@@ -5725,6 +5725,7 @@ declare class VfsNamespace {
|
|
|
5725
5725
|
stat(path: string): Promise<VfsStat>;
|
|
5726
5726
|
/** Immediate children of a directory. One record fetch. */
|
|
5727
5727
|
list(path?: string): Promise<VfsStat[]>;
|
|
5728
|
+
private warn;
|
|
5728
5729
|
/**
|
|
5729
5730
|
* Every file path under a prefix, depth-first.
|
|
5730
5731
|
*
|
package/dist/server/index.js
CHANGED
|
@@ -3235,7 +3235,7 @@ function base64UrlDecode(s) {
|
|
|
3235
3235
|
* expect (and isn't serving a stale cached bundle). Keep this in sync with
|
|
3236
3236
|
* `package.json` `version`.
|
|
3237
3237
|
*/
|
|
3238
|
-
const VERSION = "0.
|
|
3238
|
+
const VERSION = "0.13.1-alpha.0";
|
|
3239
3239
|
|
|
3240
3240
|
/**
|
|
3241
3241
|
* `HttpClient` — the one place the unified {@link Client} talks to the
|
|
@@ -11761,13 +11761,40 @@ function isUnder(path, dir) {
|
|
|
11761
11761
|
* directory on the next lookup — a file literally named `..` could never be
|
|
11762
11762
|
* addressed again.
|
|
11763
11763
|
*/
|
|
11764
|
+
/** The longest a single entry name may be. Matches the common filesystem limit. */
|
|
11765
|
+
const MAX_NAME_BYTES = 255;
|
|
11766
|
+
/**
|
|
11767
|
+
* Is this a name we are willing to store, and to hand back as a path segment?
|
|
11768
|
+
*
|
|
11769
|
+
* This is the READ-side check as much as the write-side one, and the read side
|
|
11770
|
+
* is the one that matters: a directory record is sealed with a key that any
|
|
11771
|
+
* holder of the master seed can derive, so its contents are not necessarily
|
|
11772
|
+
* something this SDK wrote. A name arriving from a record is input.
|
|
11773
|
+
*
|
|
11774
|
+
* What each rule is for:
|
|
11775
|
+
* - `/` and backslash would become path structure. Backslash is a separator on
|
|
11776
|
+
* Windows, which makes it traversal there and an odd filename elsewhere.
|
|
11777
|
+
* - `.` and `..` navigate rather than name.
|
|
11778
|
+
* - NUL terminates a path in every syscall that takes one.
|
|
11779
|
+
* - Other control characters and bidi overrides let a name print as something
|
|
11780
|
+
* other than what it is - the classic way a file passes review.
|
|
11781
|
+
* - A length cap, because nothing else imposes one.
|
|
11782
|
+
*/
|
|
11783
|
+
function isSafeName(name) {
|
|
11784
|
+
if (!name || name === "." || name === "..")
|
|
11785
|
+
return false;
|
|
11786
|
+
if (name.includes("/") || name.includes(String.fromCharCode(92)))
|
|
11787
|
+
return false;
|
|
11788
|
+
const CONTROL_OR_BIDI = new RegExp("[\\u0000-\\u001f\\u007f-\\u009f\\u202a-\\u202e\\u2066-\\u2069]");
|
|
11789
|
+
if (CONTROL_OR_BIDI.test(name))
|
|
11790
|
+
return false;
|
|
11791
|
+
return new TextEncoder().encode(name).length <= MAX_NAME_BYTES;
|
|
11792
|
+
}
|
|
11764
11793
|
function assertValidName(name) {
|
|
11765
11794
|
if (!name)
|
|
11766
11795
|
throw new Error("VFS: name cannot be empty");
|
|
11767
|
-
if (name
|
|
11768
|
-
throw new Error(`VFS:
|
|
11769
|
-
if (name.includes("/"))
|
|
11770
|
-
throw new Error(`VFS: name cannot contain "/" (got ${JSON.stringify(name)})`);
|
|
11796
|
+
if (!isSafeName(name))
|
|
11797
|
+
throw new Error(`VFS: ${JSON.stringify(name)} is not a usable name`);
|
|
11771
11798
|
}
|
|
11772
11799
|
/**
|
|
11773
11800
|
* MIME type for a filename.
|
|
@@ -11879,6 +11906,40 @@ function escapeLiteral(s) {
|
|
|
11879
11906
|
* cost storage until a future sweep, which is the trade this shape makes in
|
|
11880
11907
|
* exchange for single-record writes.
|
|
11881
11908
|
*/
|
|
11909
|
+
/**
|
|
11910
|
+
* How deep a tree may be before we stop walking it.
|
|
11911
|
+
*
|
|
11912
|
+
* Well past anything a real project needs, and low enough that a malformed or
|
|
11913
|
+
* hostile record cannot spend the caller's memory before the cycle guard sees a
|
|
11914
|
+
* repeat.
|
|
11915
|
+
*/
|
|
11916
|
+
const MAX_DEPTH = 64;
|
|
11917
|
+
/**
|
|
11918
|
+
* How many directories `walk` lists at once.
|
|
11919
|
+
*
|
|
11920
|
+
* Enough to hide per-request latency on a wide tree; low enough that a big
|
|
11921
|
+
* project does not open a request per directory. Browsers cap concurrency per
|
|
11922
|
+
* origin anyway, so a larger number mostly moves the queue.
|
|
11923
|
+
*/
|
|
11924
|
+
const WALK_CONCURRENCY = 8;
|
|
11925
|
+
/**
|
|
11926
|
+
* `Promise.all` with a ceiling, preserving input order.
|
|
11927
|
+
*
|
|
11928
|
+
* Results are written back BY INDEX rather than pushed as they settle, so the
|
|
11929
|
+
* output order matches the input regardless of which finishes first — which is
|
|
11930
|
+
* what lets `walk` parallelise without changing what it returns.
|
|
11931
|
+
*/
|
|
11932
|
+
async function mapLimit(items, limit, fn) {
|
|
11933
|
+
if (items.length <= 1)
|
|
11934
|
+
return items.length ? [await fn(items[0])] : [];
|
|
11935
|
+
const out = new Array(items.length);
|
|
11936
|
+
let next = 0;
|
|
11937
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
11938
|
+
for (let i = next++; i < items.length; i = next++)
|
|
11939
|
+
out[i] = await fn(items[i]);
|
|
11940
|
+
}));
|
|
11941
|
+
return out;
|
|
11942
|
+
}
|
|
11882
11943
|
const DEFAULT_HISTORY_LIMIT = 20;
|
|
11883
11944
|
class VfsNamespace {
|
|
11884
11945
|
deps;
|
|
@@ -12007,9 +12068,29 @@ class VfsNamespace {
|
|
|
12007
12068
|
const dir = await this.openDir(path);
|
|
12008
12069
|
const base = this.resolve(path);
|
|
12009
12070
|
return Object.entries(dir.node.entries)
|
|
12071
|
+
// Validate the name HERE, where it is read.
|
|
12072
|
+
//
|
|
12073
|
+
// A directory record is sealed under a key derivable from the master
|
|
12074
|
+
// seed, so its contents are not necessarily something this SDK
|
|
12075
|
+
// wrote, and the write-side check never sees them. An unusable name
|
|
12076
|
+
// is dropped rather than thrown on: one bad entry must not make the
|
|
12077
|
+
// whole directory unlistable, and callers walk this output straight
|
|
12078
|
+
// into filesystem paths.
|
|
12079
|
+
.filter(([name]) => {
|
|
12080
|
+
if (isSafeName(name))
|
|
12081
|
+
return true;
|
|
12082
|
+
this.warn(`ignoring entry with an unusable name in ${base}`);
|
|
12083
|
+
return false;
|
|
12084
|
+
})
|
|
12010
12085
|
.map(([name, entry]) => toStat(join(base, name), name, entry))
|
|
12011
12086
|
.sort(byDirsThenName);
|
|
12012
12087
|
}
|
|
12088
|
+
warn(message) {
|
|
12089
|
+
// Kept quiet by default in a browser console that the user cannot act
|
|
12090
|
+
// on, but never silent: a dropped entry is a real difference between
|
|
12091
|
+
// what is stored and what is shown.
|
|
12092
|
+
console.warn(`[muhkoo/vfs] ${message}`);
|
|
12093
|
+
}
|
|
12013
12094
|
/**
|
|
12014
12095
|
* Every file path under a prefix, depth-first.
|
|
12015
12096
|
*
|
|
@@ -12017,17 +12098,40 @@ class VfsNamespace {
|
|
|
12017
12098
|
* one fetch per directory. Fine for a project, wrong as a default.
|
|
12018
12099
|
*/
|
|
12019
12100
|
async walk(path = ".") {
|
|
12020
|
-
|
|
12021
|
-
|
|
12022
|
-
|
|
12023
|
-
|
|
12024
|
-
|
|
12025
|
-
|
|
12026
|
-
|
|
12027
|
-
|
|
12101
|
+
/**
|
|
12102
|
+
* Sibling directories are listed CONCURRENTLY.
|
|
12103
|
+
*
|
|
12104
|
+
* Each `list` is a round trip for one directory record, and walking them
|
|
12105
|
+
* one after another cost the sum of those latencies: measured against a
|
|
12106
|
+
* real project, the walk alone was 1–2.1s before a single file had been
|
|
12107
|
+
* read. Directories are independent, so there was never a reason to
|
|
12108
|
+
* queue them.
|
|
12109
|
+
*
|
|
12110
|
+
* Order is preserved exactly. Each entry maps to its own slice of the
|
|
12111
|
+
* result and the slices are concatenated in listing order, so the output
|
|
12112
|
+
* is identical to the depth-first version — callers and tests that
|
|
12113
|
+
* depend on it are unaffected, and only the timing changes.
|
|
12114
|
+
*/
|
|
12115
|
+
const seen = new Set();
|
|
12116
|
+
const visit = async (dirPath, depth) => {
|
|
12117
|
+
if (depth > MAX_DEPTH) {
|
|
12118
|
+
this.warn(`stopping at ${dirPath}: deeper than ${MAX_DEPTH} levels`);
|
|
12119
|
+
return [];
|
|
12120
|
+
}
|
|
12121
|
+
// Claimed BEFORE the first await, so two concurrent visits to the
|
|
12122
|
+
// same directory cannot both get past this. A record whose entry
|
|
12123
|
+
// points back at an ancestor would otherwise recurse forever, and
|
|
12124
|
+
// nothing in the format prevents one.
|
|
12125
|
+
if (seen.has(dirPath)) {
|
|
12126
|
+
this.warn(`stopping at ${dirPath}: already visited (the tree contains a cycle)`);
|
|
12127
|
+
return [];
|
|
12128
|
+
}
|
|
12129
|
+
seen.add(dirPath);
|
|
12130
|
+
const entries = await this.list(dirPath);
|
|
12131
|
+
const slices = await mapLimit(entries, WALK_CONCURRENCY, async (entry) => entry.kind === "dir" ? visit(entry.path, depth + 1) : [entry.path]);
|
|
12132
|
+
return slices.flat();
|
|
12028
12133
|
};
|
|
12029
|
-
|
|
12030
|
-
return out;
|
|
12134
|
+
return visit(this.resolve(path), 0);
|
|
12031
12135
|
}
|
|
12032
12136
|
async readFile(path) {
|
|
12033
12137
|
const entry = await this.expectFile(path);
|
|
@@ -12299,6 +12403,12 @@ class VfsNamespace {
|
|
|
12299
12403
|
const root = await this.root();
|
|
12300
12404
|
const reachable = new Set();
|
|
12301
12405
|
const visit = async (dir) => {
|
|
12406
|
+
// `reachable` was already the visited set - it just was not being
|
|
12407
|
+
// TESTED before recursing, so a cycle looped forever. Here the
|
|
12408
|
+
// consequence would have been worse than a hang: sweep decides what
|
|
12409
|
+
// to delete, and it never got as far as deciding.
|
|
12410
|
+
if (reachable.has(dirKey(dir.id)))
|
|
12411
|
+
return;
|
|
12302
12412
|
reachable.add(dirKey(dir.id));
|
|
12303
12413
|
for (const entry of Object.values(dir.node.entries)) {
|
|
12304
12414
|
if (entry.kind === "dir") {
|
|
@@ -12456,10 +12566,16 @@ class VfsNamespace {
|
|
|
12456
12566
|
});
|
|
12457
12567
|
}
|
|
12458
12568
|
/** Recursively remove a subtree's records. Best-effort: unreachable ≠ fatal. */
|
|
12459
|
-
async purge(dir) {
|
|
12569
|
+
async purge(dir, seen = new Set()) {
|
|
12570
|
+
// A record that references an ancestor would otherwise recurse forever:
|
|
12571
|
+
// the id is only removed from the cache AFTER the loop, so re-entering
|
|
12572
|
+
// it hits the same cached record every time.
|
|
12573
|
+
if (seen.has(dir.id))
|
|
12574
|
+
return;
|
|
12575
|
+
seen.add(dir.id);
|
|
12460
12576
|
for (const entry of Object.values(dir.node.entries)) {
|
|
12461
12577
|
if (entry.kind === "dir") {
|
|
12462
|
-
await this.purge(await this.openDirById(entry.id, fromBase64(entry.key)));
|
|
12578
|
+
await this.purge(await this.openDirById(entry.id, fromBase64(entry.key)), seen);
|
|
12463
12579
|
}
|
|
12464
12580
|
else {
|
|
12465
12581
|
await this.releaseFile(dir, entry);
|
|
@@ -12473,18 +12589,32 @@ class VfsNamespace {
|
|
|
12473
12589
|
* directory gets a fresh id and key, so the copy is independent of the
|
|
12474
12590
|
* original — writing into one must never appear in the other.
|
|
12475
12591
|
*/
|
|
12476
|
-
async cloneEntry(entry) {
|
|
12592
|
+
async cloneEntry(entry, seen = new Set()) {
|
|
12477
12593
|
if (entry.kind === "file") {
|
|
12478
12594
|
// Same bytes, second owner: take a reference so deleting either one
|
|
12479
12595
|
// leaves the other readable.
|
|
12480
12596
|
await this.deps.content.retain(entry.manifest);
|
|
12481
12597
|
return { kind: "file", id: newId(), manifest: entry.manifest, size: entry.size, mtime: Date.now(), versions: 0 };
|
|
12482
12598
|
}
|
|
12599
|
+
// The most expensive walker to leave unguarded: it mints a NEW id, key
|
|
12600
|
+
// and stored record at every level, so a cyclic tree does not merely
|
|
12601
|
+
// hang - it writes records to the store, and to the user's bill, in an
|
|
12602
|
+
// unbounded loop.
|
|
12603
|
+
if (seen.has(entry.id) || seen.size > MAX_DEPTH) {
|
|
12604
|
+
throw new VfsConflictError("VFS: refusing to copy a directory that contains a cycle");
|
|
12605
|
+
}
|
|
12606
|
+
seen.add(entry.id);
|
|
12483
12607
|
const source = await this.openDirById(entry.id, fromBase64(entry.key));
|
|
12484
12608
|
const clone = { kind: "dir", id: newId(), key: toBase64(newDirKey()), mtime: Date.now() };
|
|
12485
12609
|
const node = { v: 1, entries: {}, mtime: Date.now() };
|
|
12486
12610
|
for (const [name, child] of Object.entries(source.node.entries)) {
|
|
12487
|
-
|
|
12611
|
+
// Names are validated on read everywhere else; a copy must not be
|
|
12612
|
+
// the way a bad one is carried into a fresh record.
|
|
12613
|
+
if (!isSafeName(name)) {
|
|
12614
|
+
this.warn(`skipping entry with an unusable name while copying`);
|
|
12615
|
+
continue;
|
|
12616
|
+
}
|
|
12617
|
+
node.entries[name] = await this.cloneEntry(child, seen);
|
|
12488
12618
|
}
|
|
12489
12619
|
const open = { id: clone.id, key: fromBase64(clone.key), node };
|
|
12490
12620
|
await this.deps.store.put(dirKey(open.id), await seal(open.key, node));
|