feat(node): add @orpc/node package with StaticFileHandlerPlugin - #1819
feat(node): add @orpc/node package with StaticFileHandlerPlugin#1819dinwwwh wants to merge 1 commit into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
More templates
@orpc/ai-sdk
@orpc/arktype
@orpc/bun
@orpc/client
@orpc/cloudflare
@orpc/contract
@orpc/experimental-effect
@orpc/evlog
@orpc/hibernation
@orpc/json-schema
@orpc/nest
@orpc/next
@orpc/node
@orpc/openapi
@orpc/opentelemetry
@orpc/pinia-colada
@orpc/pino
@orpc/publisher
@orpc/ratelimit
@orpc/server
@orpc/shared
@orpc/swr
@orpc/tanstack-query
@orpc/trpc
@orpc/valibot
@orpc/zod
commit: |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Merging this PR will improve performance by 32.6%
|
| 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)
There was a problem hiding this comment.
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/nodepackage —StaticFileHandlerPluginserving files as a routing-interceptor fallback so unmatched GET/HEAD requests fall through to disk after procedures, withnode:fsstreaming exposed only through standard-server interfaces; the package is wired into the root manifest, all package READMEs, the lockfile, and builds viaunbuild. - Conditional requests — weak
ETag+Last-Modified;If-None-Match(weak compare incl.*wildcard) andIf-Modified-Since(second-truncated compare) produce304, and a clientCache-Control: no-cacheis deliberately not allowed to block revalidation. - Single-byte range support —
206withContent-Range,If-Rangedate validation,416for unsatisfiable ranges, malformed/multi-range/inverted headers ignored;HEADnever honorsRange. - Directories & SPA — trailing-slash
301(query preserved), configurableindexFile(or disabled), and optionalfallbackFile. - Precompressed sidecars (opt-in) —
.br/.zst/.gznegotiated byAccept-EncodingwithContent-EncodingandVary(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-freerootDirPrefixcontainment check covering index/fallback, dotfiles hidden by default, and a null-prototype MIME map. - Shared-code refactor —
parseAcceptEncodingsmoved from@orpc/serverresponse-compression into@orpc/shared(verbatim). - Tests/bench/docs — 51 tests (supertest suites, a fetch-adapter smoke test, and a real-socket test reproducing the
fetchcache-control: no-cacherevalidation), abenches/static-file-handler.bench.ts, and adocs/plugins/static-filepage.
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
parseAcceptEncodingsignores q-values, soAccept-Encoding: br;q=0still selects the.brsidecar even though the client declaredbrunacceptable. 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 theprecompressedoption would make the tradeoff explicit.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
| return undefined | ||
| } | ||
|
|
||
| const stats = await stat(filePath).catch(() => undefined) |
There was a problem hiding this comment.
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.
Adds a new
@orpc/nodepackage whose first feature isStaticFileHandlerPlugin, a standard handler plugin that serves static files alongside procedures viaroutingInterceptors. 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 withnode:fsbut speaks only standard-server interfaces, so it works with both the node and fetch adapters on any Node-compatible runtime.Features
ETag+Last-Modified,If-None-Match/If-Modified-Sinceproduce304; a clientCache-Control: no-cachedoes not block revalidation (realfetchsends it with its conditional headers, and a304is the validation it asks for).206withContent-Range,If-Rangedate validation,416for unsatisfiable ranges; malformed or multi-range headers are ignored per RFC 9110.301redirect (query preserved) andindex.html(configurable), plusfallbackFilefor SPA routing..br/.zst/.gzserved byAccept-Encodingnegotiation withContent-EncodingandVary(sent on the identity variant too, so caches key correctly).indexFile/fallbackFileconfig, dotfiles hidden by default, and content types resolve from a null-prototype map.Shared code
parseAcceptEncodingsmoved from the response-compression plugin into@orpc/sharedso both plugins use one Accept-Encoding tokenizer.Performance
A new
benches/static-file-handler.bench.tscovers 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 whosefetchclient reproduces thecache-control: no-cacherevalidation case supertest cannot. Docs page added atdocs/plugins/static-fileand@orpc/nodelisted in the package READMEs.