refactor(vfs): reuse combined directory metadata
CI / format (pull_request) Successful in 2m21s
CI / lint (pull_request) Successful in 2m35s
CI / install (pull_request) Successful in 9m0s
CI / typetest (pull_request) Successful in 2m35s
CI / node-tests (pull_request) Successful in 3m4s
CI / typecheck (pull_request) Successful in 3m13s
CI / browser-tests (pull_request) Successful in 4m46s
CI / format (pull_request) Successful in 2m21s
CI / lint (pull_request) Successful in 2m35s
CI / install (pull_request) Successful in 9m0s
CI / typetest (pull_request) Successful in 2m35s
CI / node-tests (pull_request) Successful in 3m4s
CI / typecheck (pull_request) Successful in 3m13s
CI / browser-tests (pull_request) Successful in 4m46s
Co-Authored-By: gpt-5.6-sol <noreply@openai.com>
This commit is contained in:
@@ -137,3 +137,4 @@ The test suite for `packages/http` was mostly generated by Claude Code, which al
|
||||
- **`@webnet/taildrive` — share-bearing peer discovery**: `gpt-5.6-sol` added a Webnet-only `listDrivePeersWithShares(ipn)` helper that probes the existing candidates through the shared WebDAV client, positively retains peers exporting at least one share, bounds concurrent and malformed responses, and exposes a reusable per-peer `DAVClient` factory for a later whole-tailnet `AsyncVFS` hierarchy. Focused tests cover populated, empty, invalid, unreachable, and ordered peer results.
|
||||
- **`@webnet/taildrive` — peer-probe review hardening**: `gpt-5.6-sol` addressed autonomous review findings by covering the IPN dial phase with the probe deadline, closing a connection that arrives after timeout, and requiring a valid root-directory response before accepting a nonempty WebDAV listing. Regression coverage includes a permanently stalled dial and a malformed 207 response that omits the requested root.
|
||||
- **`@webnet/taildrive` — discovery API review follow-up**: `gpt-5.6-sol` renamed the narrower discovery helper to `listDrivePeersWithShares` to distinguish it from `IpnClient.listDrivePeers`, and added the conventional package-root export alongside the existing client and server subpaths.
|
||||
- **`@webnet/vfs` — combined stat and directory listing**: `gpt-5.6-sol` added the optional `AsyncVFS.statAndReaddir()` operation, with optimized Memory, File System Access, WebDAV, and SMB2 implementations. Drive PROPFIND/COPY/MOVE and delete handling, FTP server listings, SMB2 recursive operations, and Taildrive peer discovery reuse combined metadata where available while retaining inline fallbacks for other VFS implementations. Conformance, browser, protocol, malformed-response, request-count, and integration coverage preserve existing `readdir()` semantics and verify files return their own stat with no entries.
|
||||
|
||||
@@ -659,7 +659,20 @@ suite("DAV server HTTP", () => {
|
||||
const vfs = new MemoryVFS()
|
||||
await vfs.mkdir("/src")
|
||||
await vfs.writeFile("/src/f.txt", streamOf("x"))
|
||||
const { fetch, close } = makeHttpPair(minimalVfs(vfs))
|
||||
const statPaths: string[] = []
|
||||
const combinedPaths: string[] = []
|
||||
const fallback: AsyncVFS = {
|
||||
...minimalVfs(vfs),
|
||||
stat: (path) => {
|
||||
statPaths.push(path)
|
||||
return vfs.stat(path)
|
||||
},
|
||||
statAndReaddir: (path) => {
|
||||
combinedPaths.push(path)
|
||||
return vfs.statAndReaddir(path)
|
||||
},
|
||||
}
|
||||
const { fetch, close } = makeHttpPair(fallback)
|
||||
try {
|
||||
const res = await fetch("http://localhost/src", {
|
||||
method: "COPY",
|
||||
@@ -668,6 +681,8 @@ suite("DAV server HTTP", () => {
|
||||
assert.equal(res.status, 201)
|
||||
if (res.hasBody) await res.bytes()
|
||||
assert.equal(await readAllText(await vfs.readFile("/dst/f.txt")), "x")
|
||||
assert.deepEqual(combinedPaths, ["/src"])
|
||||
assert.deepEqual(statPaths, ["/dst"])
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
|
||||
@@ -1,26 +1,38 @@
|
||||
import type { Context } from "@webnet/http/server"
|
||||
import type { AsyncVFS, Stat } from "@webnet/vfs"
|
||||
import type { AsyncVFS, Stat, StatAndReaddirResult } from "@webnet/vfs"
|
||||
import { VFSError } from "@webnet/vfs"
|
||||
import { hrefToVfsPath } from "../../common/utils.js"
|
||||
import type { DAVServerOptions } from "../types.js"
|
||||
|
||||
async function statAndReaddir(vfs: AsyncVFS, path: string): Promise<StatAndReaddirResult> {
|
||||
if (vfs.statAndReaddir) return vfs.statAndReaddir(path)
|
||||
const self = await vfs.stat(path)
|
||||
return { self, entries: self.isDirectory ? await vfs.readdir(path) : [] }
|
||||
}
|
||||
|
||||
async function copyRecursive(
|
||||
vfs: AsyncVFS,
|
||||
src: string,
|
||||
dest: string,
|
||||
overwrite: boolean,
|
||||
stat: Stat,
|
||||
knownStat?: Stat,
|
||||
): Promise<void> {
|
||||
let stat = knownStat
|
||||
let entries: Stat[] | undefined
|
||||
if (!stat) {
|
||||
const result = await statAndReaddir(vfs, src)
|
||||
stat = result.self
|
||||
entries = result.entries
|
||||
}
|
||||
if (stat.isDirectory) {
|
||||
try {
|
||||
await vfs.mkdir(dest)
|
||||
} catch (e) {
|
||||
if (!(e instanceof VFSError && e.code === "already-exists")) throw e
|
||||
}
|
||||
const children = await vfs.readdir(src)
|
||||
const children = entries ?? (await vfs.readdir(src))
|
||||
for (const child of children) {
|
||||
const childDest = dest + "/" + child.name
|
||||
await copyRecursive(vfs, child.path, childDest, overwrite, child)
|
||||
await copyRecursive(vfs, child.path, childDest, child)
|
||||
}
|
||||
} else {
|
||||
const stream = await vfs.readFile(src)
|
||||
@@ -73,19 +85,17 @@ export async function handleCopyMove(
|
||||
await vfs.delete(destVfsPath, true)
|
||||
}
|
||||
|
||||
const srcStat = await vfs.stat(vfsPath)
|
||||
|
||||
if (method === "COPY") {
|
||||
if (vfs.copy) {
|
||||
await vfs.copy(vfsPath, destVfsPath, { overwrite })
|
||||
} else {
|
||||
await copyRecursive(vfs, vfsPath, destVfsPath, overwrite, srcStat)
|
||||
await copyRecursive(vfs, vfsPath, destVfsPath)
|
||||
}
|
||||
} else {
|
||||
if (vfs.move) {
|
||||
await vfs.move(vfsPath, destVfsPath, { overwrite })
|
||||
} else {
|
||||
await copyRecursive(vfs, vfsPath, destVfsPath, overwrite, srcStat)
|
||||
await copyRecursive(vfs, vfsPath, destVfsPath)
|
||||
await vfs.delete(vfsPath, true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -522,7 +522,7 @@ export class SMB2Client implements AsyncVFS, StateTransferable<SMB2TransferState
|
||||
}
|
||||
|
||||
async copy(src: string, dest: string, opts?: { overwrite?: boolean }): Promise<void> {
|
||||
const st = await this.stat(src)
|
||||
const { self: st, entries } = await this.statAndReaddir(src)
|
||||
const srcKey = smbPath(src)
|
||||
const destKey = smbPath(dest)
|
||||
if (destKey === srcKey || destKey.startsWith(srcKey + "\\"))
|
||||
@@ -540,7 +540,7 @@ export class SMB2Client implements AsyncVFS, StateTransferable<SMB2TransferState
|
||||
}
|
||||
if (st.isDirectory) {
|
||||
await this.mkdir(dest)
|
||||
for (const entry of await this.readdir(src)) {
|
||||
for (const entry of entries) {
|
||||
await this.copy(entry.path, joinPath(dest, entry.name), opts)
|
||||
}
|
||||
return
|
||||
|
||||
@@ -1131,6 +1131,28 @@ suite("smb2 client e2e", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("recursive copy combines source directory metadata and enumeration", async () => {
|
||||
const { client, server, stop } = setup()
|
||||
try {
|
||||
await client.mkdir("/source")
|
||||
await client.mkdir("/source/sub")
|
||||
await client.writeFile("/source/sub/file.txt", streamOf(new TextEncoder().encode("payload")))
|
||||
const before = server.commands.length
|
||||
|
||||
await client.copy("/source", "/destination")
|
||||
|
||||
const commands = server.commands.slice(before)
|
||||
assert.equal(commands.filter((command) => command === Command.CREATE).length, 10)
|
||||
assert.equal(commands.filter((command) => command === Command.QUERY_DIRECTORY).length, 4)
|
||||
assert.equal(
|
||||
new TextDecoder().decode(await readAll(await client.readFile("/destination/sub/file.txt"))),
|
||||
"payload",
|
||||
)
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("copy with overwrite replaces a destination of a different type", async () => {
|
||||
const { client, stop } = setup()
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user