Skip to content

feat(node): add @orpc/node package with StaticFileHandlerPlugin - #1819

Open
dinwwwh wants to merge 1 commit into
middleapi:mainfrom
dinwwwh:claude/static-file-handler-interceptors-5b329e
Open

feat(node): add @orpc/node package with StaticFileHandlerPlugin#1819
dinwwwh wants to merge 1 commit into
middleapi:mainfrom
dinwwwh:claude/static-file-handler-interceptors-5b329e

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Aug 8, 2026

Copy link
Copy Markdown
Member

Adds a new @orpc/node package whose first feature is StaticFileHandlerPlugin, a standard handler plugin that serves static files alongside procedures via routingInterceptors. Procedures always win the route; unmatched GET/HEAD requests fall through to files, so one handler can serve an API and its assets (including SPA fallback) together. The plugin reads files with node:fs but speaks only standard-server interfaces, so it works with both the node and fetch adapters on any Node-compatible runtime.

Features

  • Conditional requests: weak ETag + Last-Modified, If-None-Match/If-Modified-Since produce 304; a client Cache-Control: no-cache does not block revalidation (real fetch sends it with its conditional headers, and a 304 is the validation it asks for).
  • Single byte ranges: 206 with Content-Range, If-Range date validation, 416 for unsatisfiable ranges; malformed or multi-range headers are ignored per RFC 9110.
  • Directory handling: trailing-slash 301 redirect (query preserved) and index.html (configurable), plus fallbackFile for SPA routing.
  • Precompressed sidecars (opt-in): .br/.zst/.gz served by Accept-Encoding negotiation with Content-Encoding and Vary (sent on the identity variant too, so caches key correctly).
  • Safety: dot segments are normalized in URL space and clamped at the root (RFC 3986), encoded separators and null bytes are rejected, a resolved-path containment check also covers indexFile/fallbackFile config, dotfiles hidden by default, and content types resolve from a null-prototype map.

Shared code

parseAcceptEncodings moved from the response-compression plugin into @orpc/shared so both plugins use one Accept-Encoding tokenizer.

Performance

A new benches/static-file-handler.bench.ts covers serve/range/304/fall-through paths; skipping percent-decoding for unencoded segments and a precomputed root-prefix containment check improved nested-path and range throughput by ~18-19%, with no behavior change.

Testing

51 tests: supertest suites for headers, conditionals, ranges, directories, mounting (handler prefix + path), precompressed negotiation, traversal attacks (raw, encoded, double-encoded, invalid UTF-8) against a real secret file outside the root, a fetch-adapter smoke test, and a real-socket test whose fetch client reproduces the cache-control: no-cache revalidation case supertest cannot. Docs page added at docs/plugins/static-file and @orpc/node listed in the package READMEs.

@vercel

vercel Bot commented Aug 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
orpc Ready Ready Preview Aug 8, 2026 9:48am

@pkg-pr-new

pkg-pr-new Bot commented Aug 8, 2026

Copy link
Copy Markdown
More templates

@orpc/ai-sdk

npm i https://pkg.pr.new/middleapi/orpc/@orpc/ai-sdk@1819

@orpc/arktype

npm i https://pkg.pr.new/middleapi/orpc/@orpc/arktype@1819

@orpc/bun

npm i https://pkg.pr.new/middleapi/orpc/@orpc/bun@1819

@orpc/client

npm i https://pkg.pr.new/middleapi/orpc/@orpc/client@1819

@orpc/cloudflare

npm i https://pkg.pr.new/middleapi/orpc/@orpc/cloudflare@1819

@orpc/contract

npm i https://pkg.pr.new/middleapi/orpc/@orpc/contract@1819

@orpc/experimental-effect

npm i https://pkg.pr.new/middleapi/orpc/@orpc/experimental-effect@1819

@orpc/evlog

npm i https://pkg.pr.new/middleapi/orpc/@orpc/evlog@1819

@orpc/hibernation

npm i https://pkg.pr.new/middleapi/orpc/@orpc/hibernation@1819

@orpc/json-schema

npm i https://pkg.pr.new/middleapi/orpc/@orpc/json-schema@1819

@orpc/nest

npm i https://pkg.pr.new/middleapi/orpc/@orpc/nest@1819

@orpc/next

npm i https://pkg.pr.new/middleapi/orpc/@orpc/next@1819

@orpc/node

npm i https://pkg.pr.new/middleapi/orpc/@orpc/node@1819

@orpc/openapi

npm i https://pkg.pr.new/middleapi/orpc/@orpc/openapi@1819

@orpc/opentelemetry

npm i https://pkg.pr.new/middleapi/orpc/@orpc/opentelemetry@1819

@orpc/pinia-colada

npm i https://pkg.pr.new/middleapi/orpc/@orpc/pinia-colada@1819

@orpc/pino

npm i https://pkg.pr.new/middleapi/orpc/@orpc/pino@1819

@orpc/publisher

npm i https://pkg.pr.new/middleapi/orpc/@orpc/publisher@1819

@orpc/ratelimit

npm i https://pkg.pr.new/middleapi/orpc/@orpc/ratelimit@1819

@orpc/server

npm i https://pkg.pr.new/middleapi/orpc/@orpc/server@1819

@orpc/shared

npm i https://pkg.pr.new/middleapi/orpc/@orpc/shared@1819

@orpc/swr

npm i https://pkg.pr.new/middleapi/orpc/@orpc/swr@1819

@orpc/tanstack-query

npm i https://pkg.pr.new/middleapi/orpc/@orpc/tanstack-query@1819

@orpc/trpc

npm i https://pkg.pr.new/middleapi/orpc/@orpc/trpc@1819

@orpc/valibot

npm i https://pkg.pr.new/middleapi/orpc/@orpc/valibot@1819

@orpc/zod

npm i https://pkg.pr.new/middleapi/orpc/@orpc/zod@1819

commit: 7fc1da8

@codecov

codecov Bot commented Aug 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.50000% with 4 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
packages/node/src/static-file-handler-plugin.ts 97.41% 4 Missing ⚠️

📢 Thoughts on this report? Let us know!

@codspeed-hq

codspeed-hq Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will improve performance by 32.6%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 2 improved benchmarks
✅ 23 untouched benchmarks
🆕 5 new benchmarks

Performance Changes

Benchmark BASE HEAD Efficiency
full (middlewares + validated + interceptors) 211.7 µs 158.2 µs +33.77%
middlewares 198.2 µs 150.8 µs +31.44%
🆕 not found fall through N/A 466.8 µs N/A
🆕 not modified (304) N/A 311.8 µs N/A
🆕 range request N/A 903.7 µs N/A
🆕 serve 10kb file N/A 3.3 ms N/A
🆕 serve deeply nested encoded path N/A 929.6 µs N/A

Tip

Curious why this is faster? Comment @codspeedbot explain why this is faster on this PR, or directly use the CodSpeed MCP with your agent.


Comparing dinwwwh:claude/static-file-handler-interceptors-5b329e (7fc1da8) with main (e584b49)

Open in CodSpeed

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Important

The plugin advertises — in its JSDoc, source comments, and the docs page — that "a request can never read outside rootDir", but the containment check never resolves real paths while stat/createReadStream follow symlinks, so an in-root symlink pointing outside defeats that guarantee. One inline comment details it.

Reviewed changes

  • New @orpc/node packageStaticFileHandlerPlugin serving files as a routing-interceptor fallback so unmatched GET/HEAD requests fall through to disk after procedures, with node:fs streaming exposed only through standard-server interfaces; the package is wired into the root manifest, all package READMEs, the lockfile, and builds via unbuild.
  • Conditional requests — weak ETag + Last-Modified; If-None-Match (weak compare incl. * wildcard) and If-Modified-Since (second-truncated compare) produce 304, and a client Cache-Control: no-cache is deliberately not allowed to block revalidation.
  • Single-byte range support206 with Content-Range, If-Range date validation, 416 for unsatisfiable ranges, malformed/multi-range/inverted headers ignored; HEAD never honors Range.
  • Directories & SPA — trailing-slash 301 (query preserved), configurable indexFile (or disabled), and optional fallbackFile.
  • Precompressed sidecars (opt-in).br/.zst/.gz negotiated by Accept-Encoding with Content-Encoding and Vary (also sent on the identity variant).
  • Traversal defense — URL-space dot-segment normalization with root clamping, per-segment decode-then-reject of \0///\, a realpath-free rootDirPrefix containment check covering index/fallback, dotfiles hidden by default, and a null-prototype MIME map.
  • Shared-code refactorparseAcceptEncodings moved from @orpc/server response-compression into @orpc/shared (verbatim).
  • Tests/bench/docs — 51 tests (supertest suites, a fetch-adapter smoke test, and a real-socket test reproducing the fetch cache-control: no-cache revalidation), a benches/static-file-handler.bench.ts, and a docs/plugins/static-file page.

The traversal/range/conditional logic itself is locally sound — the dot-segment loop clamps .. in URL space, decode-before-check closes the double-decode window, parseByteRange and isRequestFresh/isRangeApplicable handle zero/negative sizes and weak-vs-strong etag comparison conservatively, and the tests against a real secret file outside the root exercise raw, encoded, double-encoded, and invalid-UTF-8 variants.

ℹ️ Nitpicks

  • parseAcceptEncodings ignores q-values, so Accept-Encoding: br;q=0 still selects the .br sidecar even though the client declared br unacceptable. This is inherited shared behavior with the existing response-compression plugin, but for precompressed sidecars it serves an explicitly-rejected representation; a one-line note on the precompressed option would make the tradeoff explicit.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

return undefined
}

const stats = await stat(filePath).catch(() => undefined)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

resolveWithinRoot is a realpath-unaware containment test, but stat (followed by createReadStream below) follows symlinks. A symlink inside rootDir pointing outside it — e.g. rootDir/uploads -> /srv/shared or a build output that symlinks node_modules — lets a request like GET /secret serve bytes from outside the root, contradicting the guarantee in the lookup doc comment, the class JSDoc, and the docs page ("a request can never read outside rootDir").

Technical details
# Symlink escape defeats the root containment guarantee

## Affected sites
- packages/node/src/static-file-handler-plugin.ts:194-202 — resolveWithinRoot string-containment check (never resolves realpath)
- packages/node/src/static-file-handler-plugin.ts:224 — stat() follows symlinks
- packages/node/src/static-file-handler-plugin.ts:367 — createReadStream() follows symlinks
- packages/node/src/static-file-handler-plugin.ts:215 — comment asserting "nothing outside the root is ever touched"

## Required outcome
- Either ensure the served bytes cannot leave the root, or stop claiming they cannot. The docs/comment contract and the implementation must agree.

## Suggested approach (optional)
- Resolve the real path of both rootDir and the target and require the target's realpath to be within the root's realpath, or lstat to reject symlinks outright, then keep the existing Etag/Last-Modified/range logic operating on that real target.

## Open questions for the human (optional)
- Is serving through in-root symlinks a supported use case (as in express.static/serve-static, which follow symlinks by default)? If yes, soften the documented guarantee; if no, resolve realpath and add a test with a symlink escaping rootDir.

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.

1 participant