Skip to content

feat(static): add opt-in directory listing via dirListing - #277

Open
pi0x wants to merge 9 commits into
mainfrom
feat/dir-list
Open

feat(static): add opt-in directory listing via dirListing#277
pi0x wants to merge 9 commits into
mainfrom
feat/dir-list

Conversation

@pi0x

@pi0x pi0x commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a dirListing option to serveStatic that serves a minimal HTML directory listing as a 404 fallback when a request names a directory with no index file (index.html). Off by default; the srvx CLI enables it in dev mode only.

serveStatic({ dir: "./public", dirListing: true });

Behaviour

The resolution order is static files → your handler → listing:

  • A real route always wins — when no static file matches, next() runs first, and only a 404 response is replaced by the listing. A server route named like a directory in the static dir (e.g. /api) keeps working.
  • Custom 404 pages keep working — a downstream 404 is replaced only when the path actually names a listable directory; everywhere else it passes through untouched, body and all.
  • Index always wins — a directory with an index.html serves the index, never a listing (the index candidate is already probed by the existing lookup loop).
  • Triggers on a request naming a directory: the root, a trailing-slash path (/files/), or an extension-less path (/files).
  • Absolute hrefs — entry links are built from the request pathname with a guaranteed trailing slash, so links resolve identically whether the directory was requested with a trailing slash or without.
  • Directories first, then alphabetical; directory entries get a trailing /. A ../ parent link appears everywhere but the root. HEAD returns the headers without a body.

Security

The listing re-asserts the same boundaries a file request gets, so it never names anything a direct request would refuse to serve:

  • Lexical containment, then symlink-resolved containment (a link inside dir could point the directory out of the root).
  • The dot-path deny check (dotfiles) on both the requested path and each entry — so .env/.git are hidden while .well-known shows, matching what is actually servable.
  • Only names are revealed, never file contents. Filenames are encodeURIComponent-encoded then HTML-escaped before interpolation.

CLI

Enabled automatically in dev, off in prod:

serveStatic({ dir: cliOpts.static, dirListing: !cliOpts.prod })

Static-only mode (no server entry) now answers a miss with an ordinary 404 instead of a 501 "Server Entry Not Found" page — the correct status for that mode, and the one the fallback keys on.

Tests

New directory listing (dirListing) suite covering: off-by-default fall-through, non-404 downstream responses winning over the listing, custom-404 passthrough for non-directory paths, listing render, dot-segment hiding, index precedence (including root), extension-less routes, HEAD, symlink-escape denial, and missing directories. Full suite (1253 tests) passes; lint, format, and typecheck clean.

Verified end-to-end via the CLI: a /api route beats a same-name static directory, an entry 404 falls back to the listing, a custom 404 page passes through, and prod keeps the listing off.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added an opt-in HTML directory listing fallback for static requests when no index exists.
    • Added CLI flags --dir-listing / --no-dir-listing, supporting dev defaults and explicit production override.
  • Bug Fixes
    • Improved fallback behavior for missing server entries and tuned directory listing behavior in production vs dev.
    • Hardened listings (hidden/dot paths, symlink escape handling) with correct headers and HEAD handling.
  • Documentation
    • Updated CLI and middleware guides to describe directory listing behavior and options.
  • Tests
    • Expanded unit and integration coverage for listings, security, headers, and production behavior.

Serve a minimal HTML directory listing when a request resolves to a
directory with no index file. Off by default (it exposes the directory
structure); the CLI enables it in dev mode only.

The listing re-asserts the same boundaries a file request gets — lexical
and symlink-resolved containment, plus the dot-path deny check on both
the requested path and each entry — so it never names anything a direct
request would refuse to serve. Entry hrefs are absolute, so links resolve
identically whether the directory was requested with a trailing slash or
without.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@pi0x
pi0x requested a review from pi0 as a code owner July 17, 2026 22:50
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5d00ebeb-61ac-4f8b-924c-be793d88b11a

📥 Commits

Reviewing files that changed from the base of the PR and between 0813146 and c7254f8.

📒 Files selected for processing (3)
  • docs/1.guide/4.middleware.md
  • src/static.ts
  • test/static.test.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • test/static.test.ts
  • src/static.ts
  • docs/1.guide/4.middleware.md

📝 Walkthrough

Walkthrough

Directory listing support is added to static serving with secure symlink and dotfile filtering, escaped HTML output, CLI controls, production-aware defaults, documentation, and coverage for routing, headers, HEAD requests, and fallback behavior.

Changes

Directory listing

Layer / File(s) Summary
Listing option and CLI wiring
src/static.ts, src/cli/*, docs/1.guide/*
Adds dirListing, CLI flags, production-aware defaults, server fallback handling, help text, and documentation.
Safe directory enumeration
src/static.ts, test/static.test.ts
Enumerates entries while enforcing containment, symlink-target, and dotfile rules.
Listing response and validation
src/static.ts, test/static.test.ts, test/cli.test.ts
Renders escaped listings with defensive headers and HEAD handling, with tests for precedence, routing, security, and CLI integration.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

  • h3js/srvx#168: Shares CLI server-entry and fetch fallback behavior.

Suggested reviewers: pi0

Poem

I hop through folders, neat and bright,
Escaping names in HTML light.
Hidden dots stay out of view,
Safe paths guide each link anew.
HEAD gets headers, no body to bear—
A tidy listing blooms there!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding an opt-in static directory listing via dirListing.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/dir-list

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pkg-pr-new

pkg-pr-new Bot commented Jul 17, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/srvx@277

commit: c7254f8

pi0 and others added 4 commits July 17, 2026 22:54
A plain `prefers-color-scheme: dark` palette swap on the listing page —
no toggle, no stored preference.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Refresh the listing style — card-style rows with hover, a monospace
heading, and folder/file icons — driven by CSS variables so the
dark-theme swap is a single palette override. Add a `noindex, nofollow`
robots meta tag and matching `X-Robots-Tag` header, since a generated
listing is not content to index.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The listing interpolates filenames into HTML, so add defense-in-depth
headers. The page is fully self-contained (inline CSS, no scripts,
images, or fonts), so a strict CSP pins it to exactly that — even a
hypothetical escaping slip could neither run a script nor reach an
external origin. Add `X-Content-Type-Options: nosniff` and
`Referrer-Policy: no-referrer` alongside.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the `dirListing` option to the serveStatic reference with a
paragraph covering behaviour, dotfile hiding, absolute links, dark
mode, and the noindex/CSP headers. Note the dev-only listing in the CLI
static-files section and cross-link the two.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/static.ts`:
- Around line 364-366: Update the directory-entry mapping in the static listing
flow to resolve each child’s canonical target, verify it remains under root, and
apply dot-path filtering to the resolved path before including it. Classify
included entries using the resolved target rather than the symlink’s directory
flag, and add coverage for symlinks escaping root or resolving to denied dot
paths.
- Line 693: Update the URL generation around the base-path normalization in the
static request handling flow so extension-less nested paths such as /docs/api
resolve the parent URL to /docs/ rather than /. Derive an absolute parent path
from the normalized base and apply the same correction to the corresponding
logic also used around lines 748-751.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 52b8d831-f879-45d2-8659-66e96cd7c33a

📥 Commits

Reviewing files that changed from the base of the PR and between 0e77d9f and 9739a0c.

📒 Files selected for processing (3)
  • src/cli/serve.ts
  • src/static.ts
  • test/static.test.ts

Comment thread src/static.ts Outdated
Comment thread src/static.ts Outdated
pi0 and others added 4 commits July 17, 2026 23:05
Make the directory listing explicitly controllable: `--dir-listing`
forces it on (e.g. under --prod) and `--no-dir-listing` forces it off
(e.g. in dev), overriding the dev-on/prod-off default. `parseArgs` has
no native negation, so the opt-out is its own flag; the two collapse
into a tri-state `dirListing` that falls back to the default when unset.

Document both flags in the usage help and CLI guide, and cover the prod
opt-in with an end-to-end test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address two review findings on the directory listing:

- Resolve each symlink entry's canonical target and re-check it against
  the root and dot-path rules before listing it, classifying by the
  target. A symlink escaping the root or aliasing a denied dot path is
  now hidden — matching what a direct request would refuse — and a
  contained symlink to a directory lists as one.
- Build the parent link as an absolute path derived from the base, so an
  extension-less nested request served without a trailing slash
  (`/docs/api`) points `../` to `/docs/` rather than `/`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The listing no longer pre-empts the rest of the app: `next()` runs
first, and only a 404 response for a path naming a listable directory
is replaced by the listing. A real route beats a listing even when a
same-name directory exists in the static dir, and a custom 404 page
keeps working for any path that is not a listable directory.

Static-only CLI mode now answers a miss with an ordinary 404 instead
of a 501 "Server Entry Not Found" page — the correct status for that
mode, and the one the fallback keys on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… listings

The per-entry symlink containment check compared the canonical target
against the root without the trailing separator treatment the directory
check gets, so a link whose target is exactly the served root
(`self -> .`) vanished from listings while still serving. Apply
`asPrefix` before the prefix test, matching the directory-level check.

Also send `Cache-Control: no-store` on listings — they mirror live
directory state, and heuristic caching could otherwise show a stale
listing after files change. Pin the request-path dot-deny with a test
(`/.secret-dir/` stays unlistable) and correct "dev mode only" wording
to "by default" now that `--dir-listing` can force it on in prod.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants