feat(taildrive): discover peers with shares
CI / lint (pull_request) Successful in 1m49s
CI / format (pull_request) Successful in 2m0s
CI / typecheck (pull_request) Canceled after 0s
CI / typetest (pull_request) Canceled after 0s
CI / node-tests (pull_request) Canceled after 0s
CI / browser-tests (pull_request) Canceled after 0s
CI / install (pull_request) Canceled after 5m12s
CI / lint (pull_request) Successful in 1m49s
CI / format (pull_request) Successful in 2m0s
CI / typecheck (pull_request) Canceled after 0s
CI / typetest (pull_request) Canceled after 0s
CI / node-tests (pull_request) Canceled after 0s
CI / browser-tests (pull_request) Canceled after 0s
CI / install (pull_request) Canceled after 5m12s
Co-Authored-By: gpt-5.6-sol <noreply@openai.com>
This commit is contained in:
@@ -134,3 +134,4 @@ The test suite for `packages/http` was mostly generated by Claude Code, which al
|
||||
- **CI — bounded Node heap (issue #136)**: Claude Code (Claude Opus 5) added a workflow-level `NODE_OPTIONS: --max-old-space-size=4096` to `.gitea/workflows/ci.yml`, so every CI job that runs Node (including the per-file workers `node --test` spawns and Turbo-invoked package scripts, which inherit the variable) fails with a legible V8 heap-limit error instead of growing until the runner's own memory limit kills the task and any jobs sharing the machine.
|
||||
- **`@webnet/tsconnect` — minimal build output (issue #154)**: `gpt-5.6-sol` changed the Tailscale package build to use a temporary staging directory and copy only `main.wasm`, `build-info.json`, `wasm_exec.js`, and `cacert.pem` into `dist/`, excluding unused upstream demo bundles, source maps, styles, and package metadata from published packages and Turbo caches.
|
||||
- **turbo setup - cache issue**: written collaboratively between human codinget and agent claude-opus-5 who double checked and debugged my work.
|
||||
- **`@webnet/taildrive` — share-bearing peer discovery**: `gpt-5.6-sol` added a Webnet-only `listDrivePeers(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.
|
||||
|
||||
Generated
+3
@@ -10495,11 +10495,14 @@
|
||||
"name": "@webnet/taildrive",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@webnet/drive": "*",
|
||||
"@webnet/http": "*",
|
||||
"@webnet/transport": "*",
|
||||
"@webnet/tsconnect": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.6.0",
|
||||
"@webnet/vfs": "*",
|
||||
"c8": "^11.0.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.2"
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
"description": "Taildrive utilities: WebDAV bridge for WASM tsconnect nodes and client helpers",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./client": {
|
||||
"types": "./dist/client/index.d.ts",
|
||||
"default": "./dist/client/index.js"
|
||||
},
|
||||
"./server": {
|
||||
"types": "./dist/server/index.d.ts",
|
||||
"default": "./dist/server/index.js"
|
||||
@@ -16,11 +20,14 @@
|
||||
"typecheck": "tsc --project tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@webnet/drive": "*",
|
||||
"@webnet/http": "*",
|
||||
"@webnet/transport": "*",
|
||||
"@webnet/tsconnect": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.6.0",
|
||||
"@webnet/vfs": "*",
|
||||
"c8": "^11.0.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.2"
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { suite, test } from "node:test"
|
||||
import { createDAVHandler } from "@webnet/drive"
|
||||
import { Server, type Handler } from "@webnet/http/server"
|
||||
import type { IpnClient, IPNDrivePeer } from "@webnet/tsconnect"
|
||||
import { loopbackListener } from "@webnet/transport/loopback"
|
||||
import type { RawDialer } from "@webnet/transport"
|
||||
import { MemoryVFS } from "@webnet/vfs/memory"
|
||||
import { createTaildriveClient, listDrivePeers } from "./index.js"
|
||||
|
||||
type TestServer = {
|
||||
dialer: RawDialer
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
function serve(handler: Handler): TestServer {
|
||||
const [listener, dialer] = loopbackListener()
|
||||
const stopped = new Server(handler).listen(listener, { onError: () => {} })
|
||||
return {
|
||||
dialer,
|
||||
async close() {
|
||||
listener.close()
|
||||
await stopped
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function serveDrive(shares: string[]): Promise<TestServer> {
|
||||
const vfs = new MemoryVFS()
|
||||
for (const share of shares) await vfs.mkdir(`/${share}`)
|
||||
return serve(createDAVHandler(vfs, { prefix: "/v0/drive" }))
|
||||
}
|
||||
|
||||
function peer(name: string): IPNDrivePeer {
|
||||
return { name, peerAPIURL: `http://${name}`, stableNodeID: `${name}-id`, online: true }
|
||||
}
|
||||
|
||||
function fakeIpn(peers: IPNDrivePeer[], servers: Map<string, RawDialer>): IpnClient {
|
||||
return {
|
||||
listDrivePeers: async () => peers,
|
||||
dial: async (_network: "tcp" | "tcp4" | "tcp6", address: string) => {
|
||||
const separator = address.lastIndexOf(":")
|
||||
const host = address.slice(0, separator)
|
||||
const port = Number(address.slice(separator + 1))
|
||||
const dialer = servers.get(host)
|
||||
if (!dialer) throw new Error("unreachable")
|
||||
return dialer.dial(host, port)
|
||||
},
|
||||
} as IpnClient
|
||||
}
|
||||
|
||||
suite("Taildrive client", () => {
|
||||
test("filters out empty, non-WebDAV, and unreachable peers while preserving order", async () => {
|
||||
const first = await serveDrive(["documents"])
|
||||
const empty = await serveDrive([])
|
||||
const second = await serveDrive(["photos", "music"])
|
||||
const invalid = serve(async (ctx) => {
|
||||
ctx.res.body = "not WebDAV"
|
||||
})
|
||||
const servers = new Map<string, RawDialer>([
|
||||
["first", first.dialer],
|
||||
["empty", empty.dialer],
|
||||
["second", second.dialer],
|
||||
["invalid", invalid.dialer],
|
||||
])
|
||||
const ipn = fakeIpn(
|
||||
[peer("first"), peer("empty"), peer("unreachable"), peer("invalid"), peer("second")],
|
||||
servers,
|
||||
)
|
||||
|
||||
try {
|
||||
assert.deepEqual(
|
||||
(await listDrivePeers(ipn)).map(({ name }) => name),
|
||||
["first", "second"],
|
||||
)
|
||||
} finally {
|
||||
await Promise.all([first.close(), empty.close(), second.close(), invalid.close()])
|
||||
}
|
||||
})
|
||||
|
||||
test("creates a reusable AsyncVFS client rooted at a peer's shares", async () => {
|
||||
const server = await serveDrive(["documents"])
|
||||
const ipn = fakeIpn([peer("files")], new Map([["files", server.dialer]]))
|
||||
|
||||
try {
|
||||
const client = createTaildriveClient(ipn, peer("files"))
|
||||
assert.deepEqual(
|
||||
(await client.readdir("/")).map(({ name }) => name),
|
||||
["documents"],
|
||||
)
|
||||
await client.transferState()
|
||||
} finally {
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { DAVClient } from "@webnet/drive/client"
|
||||
import { PooledDialer } from "@webnet/http/client"
|
||||
import type { IpnClient, IPNDrivePeer } from "@webnet/tsconnect"
|
||||
import type { RawDialer } from "@webnet/transport"
|
||||
|
||||
const DRIVE_PATH = "/v0/drive"
|
||||
const PROBE_TIMEOUT = 5_000
|
||||
const MAX_PROBES = 8
|
||||
const MAX_RESPONSE_SIZE = 1024 * 1024
|
||||
|
||||
function addr(host: string, port: number): string {
|
||||
return `${host.includes(":") && !host.startsWith("[") ? `[${host}]` : host}:${port}`
|
||||
}
|
||||
|
||||
function ipnDialer(ipn: IpnClient): RawDialer {
|
||||
return {
|
||||
dial: (host, port) => ipn.dial("tcp", addr(host, port)),
|
||||
dialTls: (host, port) =>
|
||||
ipn.dialTLS(addr(host, port), {
|
||||
serverName: host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function driveBase(peer: IPNDrivePeer): string {
|
||||
return peer.peerAPIURL.replace(/\/+$/, "") + DRIVE_PATH
|
||||
}
|
||||
|
||||
/** Create an AsyncVFS client for all Taildrive shares exported by one peer. */
|
||||
export function createTaildriveClient(ipn: IpnClient, peer: IPNDrivePeer): DAVClient {
|
||||
return new DAVClient({ dialer: ipnDialer(ipn), base: driveBase(peer) })
|
||||
}
|
||||
|
||||
/**
|
||||
* List peers that positively answer as WebDAV servers and export at least one share.
|
||||
* Unreachable, invalid, and empty peers are omitted without failing the whole listing.
|
||||
*/
|
||||
export async function listDrivePeers(ipn: IpnClient): Promise<IPNDrivePeer[]> {
|
||||
const peers = await ipn.listDrivePeers()
|
||||
const pool = new PooledDialer(ipnDialer(ipn), {
|
||||
keepAlive: false,
|
||||
maxPerOrigin: 1,
|
||||
max: MAX_PROBES,
|
||||
headersTimeout: PROBE_TIMEOUT,
|
||||
bodyTimeout: PROBE_TIMEOUT,
|
||||
maxBodyLength: MAX_RESPONSE_SIZE,
|
||||
})
|
||||
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
peers.map(async (peer) => {
|
||||
try {
|
||||
const client = new DAVClient({ dialer: pool, base: driveBase(peer) })
|
||||
return (await client.readdir("/")).length > 0 ? peer : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}),
|
||||
)
|
||||
return results.filter((peer): peer is IPNDrivePeer => peer !== null)
|
||||
} finally {
|
||||
await pool.shutdown()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user