Skip to content

Commit 70db448

Browse files
authored
feat(node): add @orpc/node package with StaticFileHandlerPlugin (#1819)
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.
1 parent 9192aff commit 70db448

42 files changed

Lines changed: 2429 additions & 13 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ You can read the documentation [here](https://orpc.dev).
5656
- [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/).
5757
- [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/).
5858
- [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/).
59+
- [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins, helpers, like serving static files.
5960
- [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/).
6061
- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/).
6162
- [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC.
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
---
2+
title: "Static File Plugin"
3+
description: "Serve static files alongside your procedures, with ETag caching, range requests, single page application fallback, and directory traversal protection."
4+
sidebar:
5+
label: "Static File"
6+
---
7+
8+
## Installation
9+
10+
```package-install
11+
npm install @orpc/node@beta
12+
```
13+
14+
## Setup
15+
16+
Use `StaticFileHandlerPlugin` to serve a directory alongside your procedures. Files are only served when no procedure matches a `GET` or `HEAD` request, so procedures always take precedence.
17+
18+
```ts
19+
import { StaticFileHandlerPlugin } from '@orpc/node'
20+
import { RPCHandler } from '@orpc/server/node'
21+
22+
const handler = new RPCHandler(router, {
23+
plugins: [
24+
new StaticFileHandlerPlugin({
25+
/**
26+
* The directory files are served from. Resolved against the working
27+
* directory when relative.
28+
*/
29+
rootDir: './public',
30+
31+
/**
32+
* The URL path files are served under, appended to the handler prefix
33+
* when one is set.
34+
*
35+
* @default '/'
36+
*/
37+
path: '/',
38+
39+
/**
40+
* The file served when the request path resolves to a directory.
41+
* Set to `false` to disable directory index files.
42+
*
43+
* @default 'index.html'
44+
*/
45+
indexFile: 'index.html',
46+
47+
/**
48+
* A file served with status 200 when no file matches the request path,
49+
* relative to `rootDir`. Useful for single page application routing.
50+
*
51+
* @default undefined
52+
*/
53+
fallbackFile: 'index.html',
54+
55+
/**
56+
* The `Cache-Control` response header value. Set to `false` to omit the header.
57+
*
58+
* @default 'public, max-age=0'
59+
*/
60+
cacheControl: 'public, max-age=0',
61+
62+
/**
63+
* Whether files and directories whose name starts with a dot can be served.
64+
*
65+
* @default false
66+
*/
67+
dotfiles: false,
68+
69+
/**
70+
* Whether precompressed `.br`, `.zst`, and `.gz` sidecar files can be served
71+
* when the client accepts their encoding.
72+
*
73+
* @default false
74+
*/
75+
precompressed: false,
76+
77+
/**
78+
* Whether symbolic links whose target lies outside `rootDir` can be served.
79+
* Enabling this exposes every file those links reach.
80+
*
81+
* @default false
82+
*/
83+
allowSymlinks: false,
84+
85+
/**
86+
* Extra content types keyed by lowercase file extension without the dot,
87+
* merged over the built-in detection. Unrecognised extensions are served
88+
* as `application/octet-stream`.
89+
*/
90+
mimeTypes: {},
91+
}),
92+
],
93+
})
94+
```
95+
96+
Responses carry [ETag](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/ETag) and [Last-Modified](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Last-Modified) headers, so unchanged files revalidate as `304 Not Modified`, and [range requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Range_requests) are answered with `206 Partial Content` for media seeking and resumable downloads.
97+
98+
:::info
99+
The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc/handler), [OpenAPIHandler](/docs/openapi/handler), or a custom one.
100+
:::
101+
102+
:::warning
103+
`rootDir` should contain only files you intend to make public. Requests cannot escape it through `..` segments or symbolic links, but every file inside it is reachable.
104+
:::
105+
106+
## Compression
107+
108+
Add the [Response Compression Plugin](/docs/plugins/response-compression) to compress files on the fly. It skips anything already encoded, so `precompressed` sidecars are served as they are, and it leaves `206 Partial Content` responses alone so range requests keep working.
109+
110+
```ts
111+
import { ResponseCompressionHandlerPlugin } from '@orpc/server/plugins'
112+
113+
const handler = new RPCHandler(router, {
114+
plugins: [
115+
new StaticFileHandlerPlugin({ rootDir: './public', precompressed: true }),
116+
new ResponseCompressionHandlerPlugin(),
117+
],
118+
})
119+
```
120+
121+
:::tip
122+
Precompressing assets at build time costs nothing per request and compresses better than the on the fly pass, so reach for `precompressed` first and let the compression plugin cover whatever has no sidecar.
123+
:::
124+
125+
## Learn More
126+
127+
For implementation details, see the [source code](https://github.com/middleapi/orpc/blob/main/packages/node/src/static-file-handler-plugin.ts).
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import type { StandardLazyRequest } from '@standardserver/core'
2+
import { Buffer } from 'node:buffer'
3+
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
4+
import { tmpdir } from 'node:os'
5+
import path from 'node:path'
6+
import { StaticFileHandlerPlugin } from '@orpc/node'
7+
import { RPCHandlerCodec, StandardHandler } from '@orpc/server/standard'
8+
import { bench } from 'vitest'
9+
import { drainBody } from './__shared__/payloads'
10+
11+
const rootDir = mkdtempSync(path.join(tmpdir(), 'orpc-static-file-bench-'))
12+
13+
writeFileSync(path.join(rootDir, 'file.txt'), Buffer.alloc(10 * 1024, 'a'))
14+
mkdirSync(path.join(rootDir, 'deeply', 'nested', 'dir'), { recursive: true })
15+
writeFileSync(path.join(rootDir, 'deeply', 'nested', 'dir', 'file.txt'), Buffer.alloc(10 * 1024, 'a'))
16+
17+
const handler = new StandardHandler(new RPCHandlerCodec({}, {}), {
18+
plugins: [new StaticFileHandlerPlugin({ rootDir })],
19+
})
20+
21+
function createRequest(url: `/${string}`, headers: Record<string, string> = {}): StandardLazyRequest {
22+
return {
23+
url,
24+
method: 'GET',
25+
headers,
26+
resolveBody: () => Promise.resolve(undefined),
27+
}
28+
}
29+
30+
const { response } = await handler.handle(createRequest('/file.txt'), { context: {} })
31+
await drainBody(response!.body)
32+
const etag = response!.headers.etag as string
33+
34+
describe('static file handler plugin', () => {
35+
bench('serve file', async () => {
36+
const { response } = await handler.handle(createRequest('/file.txt'), { context: {} })
37+
await drainBody(response!.body)
38+
})
39+
40+
bench('serve deeply nested encoded path', async () => {
41+
const { response } = await handler.handle(createRequest('/deeply/nested/dir/file%2etxt'), { context: {} })
42+
await drainBody(response!.body)
43+
})
44+
45+
bench('range request', async () => {
46+
const { response } = await handler.handle(createRequest('/file.txt', { range: 'bytes=0-1023' }), { context: {} })
47+
await drainBody(response!.body)
48+
})
49+
50+
bench('not modified (304)', async () => {
51+
await handler.handle(createRequest('/file.txt', { 'if-none-match': etag }), { context: {} })
52+
})
53+
54+
bench('not found fall through', async () => {
55+
await handler.handle(createRequest('/missing/file.txt'), { context: {} })
56+
})
57+
})

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"@orpc/experimental-effect": "workspace:*",
2929
"@orpc/json-schema": "workspace:*",
3030
"@orpc/next": "workspace:*",
31+
"@orpc/node": "workspace:*",
3132
"@orpc/openapi": "workspace:*",
3233
"@orpc/opentelemetry": "workspace:*",
3334
"@orpc/pino": "workspace:*",

packages/ai-sdk/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ You can read the documentation [here](https://orpc.dev).
5656
- [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/).
5757
- [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/).
5858
- [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/).
59+
- [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins, helpers, like serving static files.
5960
- [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/).
6061
- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/).
6162
- [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC.

packages/arktype/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ You can read the documentation [here](https://orpc.dev).
5656
- [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/).
5757
- [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/).
5858
- [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/).
59+
- [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins, helpers, like serving static files.
5960
- [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/).
6061
- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/).
6162
- [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC.

packages/bun/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ You can read the documentation [here](https://orpc.dev).
5353
- [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/).
5454
- [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/).
5555
- [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/).
56+
- [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins, helpers, like serving static files.
5657
- [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/).
5758
- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/).
5859
- [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC.

packages/client/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ You can read the documentation [here](https://orpc.dev).
5656
- [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/).
5757
- [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/).
5858
- [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/).
59+
- [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins, helpers, like serving static files.
5960
- [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/).
6061
- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/).
6162
- [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC.

packages/cloudflare/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ You can read the documentation [here](https://orpc.dev).
5656
- [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/).
5757
- [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/).
5858
- [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/).
59+
- [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins, helpers, like serving static files.
5960
- [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/).
6061
- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/).
6162
- [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC.

packages/contract/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ You can read the documentation [here](https://orpc.dev).
5656
- [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/).
5757
- [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/).
5858
- [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/).
59+
- [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins, helpers, like serving static files.
5960
- [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/).
6061
- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/).
6162
- [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC.

0 commit comments

Comments
 (0)