fix(http-static): use HTTP paths in listings
CI / lint (pull_request) Successful in 1m45s
CI / format (pull_request) Successful in 2m27s
CI / install (pull_request) Successful in 5m53s
CI / typetest (pull_request) Successful in 2m3s
CI / node-tests (pull_request) Successful in 2m23s
CI / typecheck (pull_request) Successful in 2m29s
CI / browser-tests (pull_request) Successful in 4m3s

Co-Authored-By: GPT-5.6 Luna <noreply@openai.com>
This commit was merged in pull request #100.
This commit is contained in:
2026-07-14 00:47:56 +00:00
co-authored by Codex
parent d1803a37de
commit a1eca114ce
3 changed files with 34 additions and 7 deletions
+1
View File
@@ -10,6 +10,7 @@ This project was set up with the assistance of [Claude Code](https://claude.ai/c
- **`@webnet/http-static` — VFS-backed static HTTP handler**: GPT-5.6 Luna added a new workspace package exposing `createStaticHandler`, with request/context path resolution, prefix and suffix lookup, index files, HTML/JSON directory listings, fallback paths, metadata and conditional responses, HEAD support, and single-byte range streaming. Added focused unit coverage and package build/test/typecheck scaffolding.
- **`@webnet/http-static` — review fixes**: GPT-5.6 Luna addressed review findings by rejecting requests outside a configured prefix, returning 416 for ranges on empty files, normalizing method matching, and honoring `If-None-Match: *`.
- **`@webnet/http-static` — precedence test vectors**: GPT-5.6 Luna added a seeded `MemoryVFS` matrix covering every requested path across default/custom/disabled indexes, suffix probing, and fallback configurations.
- **`@webnet/http-static` — HTTP-relative directory listings**: GPT-5.6 Luna updated generated JSON paths and HTML links to use the request URL pathname, including configured HTTP prefixes, and added prefixed listing coverage.
- The `tailscale` submodule fork and its Go-side `tsconnect` patches (the `webnet` branch)
- The `packages/tsconnect` TypeScript SDK
+18 -1
View File
@@ -297,7 +297,7 @@ suite("createStaticHandler", () => {
assert.equal(entries.length, 1)
assert.deepEqual(entries[0], {
name: "<read me>.txt",
path: "/docs/<read me>.txt",
path: "/docs/%3Cread%20me%3E.txt",
isDirectory: false,
size: "1",
modifiedAt: entries[0].modifiedAt,
@@ -305,6 +305,23 @@ suite("createStaticHandler", () => {
})
})
test("uses the HTTP path, including prefixes, in directory listings", async () => {
const vfs = new MemoryVFS()
await vfs.mkdir("/app")
await write(vfs, "/app/<read me>.txt", "x")
const handler = createStaticHandler(vfs, { prefix: "/static" })
const html = makeContext("GET", "/static/app/", { accept: "text/html" })
await handler(html)
const htmlBody = await readBody(html.res.body)
assert.match(htmlBody, /href="\/static\/app\/%3Cread%20me%3E\.txt"/)
const json = makeContext("GET", "/static/app/", { accept: "application/json" })
await handler(json)
const entries = JSON.parse(await readBody(json.res.body))
assert.equal(entries[0].path, "/static/app/%3Cread%20me%3E.txt")
})
test("prefers index files, supports suffixes, and uses fallback", async () => {
const vfs = new MemoryVFS()
await vfs.mkdir("/site")
+15 -6
View File
@@ -179,12 +179,22 @@ function escapeHtml(value: string): string {
)
}
function listingBody(path: string, entries: Stat[], type: "html" | "json"): string {
function httpDirectoryPath(pathname: string): string {
if (pathname === "/") return "/"
return "/" + pathname.replace(/^\/+|\/+$/g, "")
}
function httpEntryPath(path: string, name: string, isDirectory: boolean): string {
const base = path === "/" ? "/" : path + "/"
return base + encodeURIComponent(name) + (isDirectory ? "/" : "")
}
function listingBody(httpPath: string, entries: Stat[], type: "html" | "json"): string {
if (type === "json") {
return JSON.stringify(
entries.map((entry) => ({
name: entry.name,
path: entry.path,
path: httpEntryPath(httpPath, entry.name, entry.isDirectory),
isDirectory: entry.isDirectory,
size: entry.size.toString(),
modifiedAt: entry.modifiedAt?.toISOString(),
@@ -193,15 +203,14 @@ function listingBody(path: string, entries: Stat[], type: "html" | "json"): stri
})),
)
}
const base = path === "/" ? "/" : path + "/"
const rows = entries
.map((entry) => {
const name = entry.name + (entry.isDirectory ? "/" : "")
const href = base + encodeURIComponent(entry.name) + (entry.isDirectory ? "/" : "")
const href = httpEntryPath(httpPath, entry.name, entry.isDirectory)
return `<li><a href="${escapeHtml(href)}">${escapeHtml(name)}</a></li>`
})
.join("")
return `<!doctype html><html><head><title>Index of ${escapeHtml(path)}</title></head><body><h1>Index of ${escapeHtml(path)}</h1><ul>${rows}</ul></body></html>`
return `<!doctype html><html><head><title>Index of ${escapeHtml(httpPath)}</title></head><body><h1>Index of ${escapeHtml(httpPath)}</h1><ul>${rows}</ul></body></html>`
}
async function statFile(vfs: AsyncVFS, path: string): Promise<Lookup | null> {
@@ -283,7 +292,7 @@ async function serveDirectory(
const type = listingType(ctx)
if (!type) return false
const entries = await vfs.readdir(lookup.path)
const body = listingBody(lookup.path, entries, type)
const body = listingBody(httpDirectoryPath(ctx.req.url.pathname), entries, type)
ctx.res.setStatus(200, "OK")
ctx.res.setHeader(
"Content-Type",