From 7fc1da82a375970c769c30d6c40139d48c7466cd Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Sat, 8 Aug 2026 16:46:00 +0700 Subject: [PATCH 01/14] feat(node): add @orpc/node package with StaticFileHandlerPlugin --- README.md | 1 + apps/content/docs/plugins/static-file.mdx | 102 ++++ benches/static-file-handler.bench.ts | 57 ++ package.json | 1 + packages/ai-sdk/README.md | 1 + packages/arktype/README.md | 1 + packages/bun/README.md | 1 + packages/client/README.md | 1 + packages/cloudflare/README.md | 1 + packages/contract/README.md | 1 + packages/effect/README.md | 1 + packages/evlog/README.md | 1 + packages/hibernation/README.md | 1 + packages/json-schema/README.md | 1 + packages/nest/README.md | 1 + packages/next/README.md | 1 + packages/node/README.md | 203 +++++++ packages/node/package.json | 48 ++ packages/node/src/index.ts | 1 + .../src/static-file-handler-plugin.test.ts | 560 ++++++++++++++++++ .../node/src/static-file-handler-plugin.ts | 452 ++++++++++++++ .../node/tests/serves-static-files.test.ts | 66 +++ packages/node/tsconfig.json | 19 + packages/openapi/README.md | 1 + packages/opentelemetry/README.md | 1 + packages/pinia-colada/README.md | 1 + packages/pino/README.md | 1 + packages/publisher/README.md | 1 + packages/ratelimit/README.md | 1 + packages/server/README.md | 1 + .../src/plugins/response-compression.ts | 18 +- packages/shared/README.md | 1 + packages/shared/src/http.ts | 16 + packages/swr/README.md | 1 + packages/tanstack-query/README.md | 1 + packages/trpc/README.md | 1 + packages/valibot/README.md | 1 + packages/zod/README.md | 1 + pnpm-lock.yaml | 19 + 39 files changed, 1571 insertions(+), 17 deletions(-) create mode 100644 apps/content/docs/plugins/static-file.mdx create mode 100644 benches/static-file-handler.bench.ts create mode 100644 packages/node/README.md create mode 100644 packages/node/package.json create mode 100644 packages/node/src/index.ts create mode 100644 packages/node/src/static-file-handler-plugin.test.ts create mode 100644 packages/node/src/static-file-handler-plugin.ts create mode 100644 packages/node/tests/serves-static-files.test.ts create mode 100644 packages/node/tsconfig.json diff --git a/README.md b/README.md index 14c89a55d..8ff62d319 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). +- [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins, helpers, like serving static files. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). - [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. diff --git a/apps/content/docs/plugins/static-file.mdx b/apps/content/docs/plugins/static-file.mdx new file mode 100644 index 000000000..b955558c6 --- /dev/null +++ b/apps/content/docs/plugins/static-file.mdx @@ -0,0 +1,102 @@ +--- +title: "Static File Plugin" +description: "Use StaticFileHandlerPlugin to serve static files alongside your procedures with standard HTTP semantics: ETag and Last-Modified conditional requests, range requests, index files, and directory traversal protection." +sidebar: + label: "Static File" +--- + +## Installation + +```package-install +npm install @orpc/node@beta +``` + +## How It Works + +After routing, when no procedure matches a GET or HEAD request, the plugin maps the request path to a file inside `rootDir` and serves it. Matched procedures always take precedence. Requests that resolve to a directory are redirected to their trailing slash form and answered with the directory's `index.html`. + +Every file response carries a weak [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) header, so clients sending `If-None-Match` or `If-Modified-Since` receive `304 Not Modified` when the file is unchanged. Single [range requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Range_requests) are answered with `206 Partial Content`, which enables media seeking and resumable downloads. + +Dot segments like `..` are resolved in URL space and clamped at the served directory, following the [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986#section-5.2.4) normalization browsers and proxies apply, so a request can never read outside `rootDir`. Dotfiles are treated as not found unless explicitly enabled. + +## Setup + +The plugin reads files through the Node.js filesystem API but only interacts with the handler through standard oRPC interfaces, so it works with any handler on a Node.js compatible runtime, whether it uses the [Node HTTP Adapter](/docs/adapters/node-http) or the [Fetch API Adapter](/docs/adapters/fetch-api). + +```ts +import { StaticFileHandlerPlugin } from '@orpc/node' +import { RPCHandler } from '@orpc/server/node' + +const handler = new RPCHandler(router, { + plugins: [ + new StaticFileHandlerPlugin({ + /** + * The directory files are served from. Resolved against the working + * directory when relative. + */ + rootDir: './public', + + /** + * The URL path files are served under, appended to the handler prefix + * when one is set. + * + * @default '/' + */ + path: '/', + + /** + * The file served when the request path resolves to a directory. + * Set to `false` to disable directory index files. + * + * @default 'index.html' + */ + indexFile: 'index.html', + + /** + * A file served with status 200 when no file matches the request path, + * relative to `rootDir`. Useful for single-page application routing. + * + * @default undefined + */ + fallbackFile: 'index.html', + + /** + * The `Cache-Control` response header value. Set to `false` to omit the header. + * + * @default 'public, max-age=0' + */ + cacheControl: 'public, max-age=0', + + /** + * Whether files and directories whose name starts with a dot can be served. + * + * @default false + */ + dotfiles: false, + + /** + * Whether precompressed sidecar files (`.br`, `.zst`, `.gz`) can be served + * when the client accepts their encoding and the content type is compressible. + * + * @default false + */ + precompressed: false, + + /** + * Extra content types keyed by lowercase file extension without the dot, + * merged over the built-in mapping. Unknown extensions are served as + * `application/octet-stream`. + */ + mimeTypes: {}, + }), + ], +}) +``` + +:::info +When the handler is served under a [prefix](/docs/rpc/handler), files are only reachable inside that prefix, because a handler never intercepts requests outside its prefix. +::: + +## Learn More + +For implementation details, see the [source code](https://github.com/middleapi/orpc/blob/main/packages/node/src/static-file-handler-plugin.ts). diff --git a/benches/static-file-handler.bench.ts b/benches/static-file-handler.bench.ts new file mode 100644 index 000000000..493619463 --- /dev/null +++ b/benches/static-file-handler.bench.ts @@ -0,0 +1,57 @@ +import type { StandardLazyRequest } from '@standardserver/core' +import { Buffer } from 'node:buffer' +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { StaticFileHandlerPlugin } from '@orpc/node' +import { RPCHandlerCodec, StandardHandler } from '@orpc/server/standard' +import { bench } from 'vitest' +import { drainBody } from './__shared__/payloads' + +const rootDir = mkdtempSync(path.join(tmpdir(), 'orpc-static-file-bench-')) + +writeFileSync(path.join(rootDir, 'file.txt'), Buffer.alloc(10 * 1024, 'a')) +mkdirSync(path.join(rootDir, 'deeply', 'nested', 'dir'), { recursive: true }) +writeFileSync(path.join(rootDir, 'deeply', 'nested', 'dir', 'file.txt'), Buffer.alloc(10 * 1024, 'a')) + +const handler = new StandardHandler(new RPCHandlerCodec({}, {}), { + plugins: [new StaticFileHandlerPlugin({ rootDir })], +}) + +function createRequest(url: `/${string}`, headers: Record = {}): StandardLazyRequest { + return { + url, + method: 'GET', + headers, + resolveBody: () => Promise.resolve(undefined), + } +} + +const { response } = await handler.handle(createRequest('/file.txt'), { context: {} }) +await drainBody(response!.body) +const etag = response!.headers.etag as string + +describe('static file handler plugin', () => { + bench('serve 10kb file', async () => { + const { response } = await handler.handle(createRequest('/file.txt'), { context: {} }) + await drainBody(response!.body) + }) + + bench('serve deeply nested encoded path', async () => { + const { response } = await handler.handle(createRequest('/deeply/nested/dir/file%2etxt'), { context: {} }) + await drainBody(response!.body) + }) + + bench('range request', async () => { + const { response } = await handler.handle(createRequest('/file.txt', { range: 'bytes=0-1023' }), { context: {} }) + await drainBody(response!.body) + }) + + bench('not modified (304)', async () => { + await handler.handle(createRequest('/file.txt', { 'if-none-match': etag }), { context: {} }) + }) + + bench('not found fall through', async () => { + await handler.handle(createRequest('/missing/file.txt'), { context: {} }) + }) +}) diff --git a/package.json b/package.json index cb74e9c07..238b4b6ab 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@orpc/experimental-effect": "workspace:*", "@orpc/json-schema": "workspace:*", "@orpc/next": "workspace:*", + "@orpc/node": "workspace:*", "@orpc/openapi": "workspace:*", "@orpc/opentelemetry": "workspace:*", "@orpc/pino": "workspace:*", diff --git a/packages/ai-sdk/README.md b/packages/ai-sdk/README.md index cf4a67da0..6ccd87399 100644 --- a/packages/ai-sdk/README.md +++ b/packages/ai-sdk/README.md @@ -56,6 +56,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). +- [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins, helpers, like serving static files. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). - [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. diff --git a/packages/arktype/README.md b/packages/arktype/README.md index 2dfff21cb..db270e020 100644 --- a/packages/arktype/README.md +++ b/packages/arktype/README.md @@ -56,6 +56,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). +- [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins, helpers, like serving static files. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). - [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. diff --git a/packages/bun/README.md b/packages/bun/README.md index 2ecee06b6..5e36fd12f 100644 --- a/packages/bun/README.md +++ b/packages/bun/README.md @@ -53,6 +53,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). +- [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins, helpers, like serving static files. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). - [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. diff --git a/packages/client/README.md b/packages/client/README.md index 14c89a55d..8ff62d319 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -56,6 +56,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). +- [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins, helpers, like serving static files. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). - [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. diff --git a/packages/cloudflare/README.md b/packages/cloudflare/README.md index ee7fecaa1..b9dab1928 100644 --- a/packages/cloudflare/README.md +++ b/packages/cloudflare/README.md @@ -56,6 +56,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). +- [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins, helpers, like serving static files. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). - [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. diff --git a/packages/contract/README.md b/packages/contract/README.md index 4bc9eb84b..47d2d49cd 100644 --- a/packages/contract/README.md +++ b/packages/contract/README.md @@ -56,6 +56,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). +- [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins, helpers, like serving static files. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). - [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. diff --git a/packages/effect/README.md b/packages/effect/README.md index db5906a5a..05c253b3e 100644 --- a/packages/effect/README.md +++ b/packages/effect/README.md @@ -56,6 +56,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). +- [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins, helpers, like serving static files. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). - [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. diff --git a/packages/evlog/README.md b/packages/evlog/README.md index a70fb24c9..634868942 100644 --- a/packages/evlog/README.md +++ b/packages/evlog/README.md @@ -56,6 +56,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). +- [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins, helpers, like serving static files. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). - [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. diff --git a/packages/hibernation/README.md b/packages/hibernation/README.md index bdb0bbffe..f35ff43e2 100644 --- a/packages/hibernation/README.md +++ b/packages/hibernation/README.md @@ -56,6 +56,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). +- [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins, helpers, like serving static files. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). - [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. diff --git a/packages/json-schema/README.md b/packages/json-schema/README.md index cdc1f253d..5a9987ef1 100644 --- a/packages/json-schema/README.md +++ b/packages/json-schema/README.md @@ -56,6 +56,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). +- [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins, helpers, like serving static files. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). - [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. diff --git a/packages/nest/README.md b/packages/nest/README.md index dec33a5be..795c80b0f 100644 --- a/packages/nest/README.md +++ b/packages/nest/README.md @@ -56,6 +56,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). +- [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins, helpers, like serving static files. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). - [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. diff --git a/packages/next/README.md b/packages/next/README.md index 92fede7e6..d9ea8e62c 100644 --- a/packages/next/README.md +++ b/packages/next/README.md @@ -56,6 +56,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). +- [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins, helpers, like serving static files. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). - [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. diff --git a/packages/node/README.md b/packages/node/README.md new file mode 100644 index 000000000..333dca4de --- /dev/null +++ b/packages/node/README.md @@ -0,0 +1,203 @@ +

oRPC - Typesafe APIs Made Simple 🪄

+ +
+ + codecov + + + weekly downloads + + + CodSpeed + + + MIT License + + + Discord + + + Ask DeepWiki + +
+ +## Documentation + +You can read the documentation [here](https://orpc.dev). + +## Packages + +**Core** + +- [@orpc/contract](https://www.npmjs.com/package/@orpc/contract): Define API contract as the single source of truth. +- [@orpc/server](https://www.npmjs.com/package/@orpc/server): Build APIs or implement contracts. +- [@orpc/client](https://www.npmjs.com/package/@orpc/client): Consume APIs with end-to-end type safety. +- [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi): Add OpenAPI compatibility to APIs. + +**Schema validation** + +- [@orpc/zod](https://www.npmjs.com/package/@orpc/zod): Integrate with [Zod](https://zod.dev/). +- [@orpc/valibot](https://www.npmjs.com/package/@orpc/valibot): Integrate with [Valibot](https://valibot.dev/). +- [@orpc/arktype](https://www.npmjs.com/package/@orpc/arktype): Integrate with [ArkType](https://arktype.io/). + +**Built-in features** + +- [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. +- [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation): Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). +- [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema): Smart coercion for OpenAPI requests. + +**Framework & ecosystem integrations** + +- [@orpc/next](https://www.npmjs.com/package/@orpc/next): Integrate with [Next.js Server Functions](https://nextjs.org/docs/app/getting-started/mutating-data). +- [@orpc/ai-sdk](https://www.npmjs.com/package/@orpc/ai-sdk): Turn contracts and procedures into [AI SDK](https://ai-sdk.dev/) tools. +- [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): Integrate with [TanStack Query](https://tanstack.com/query/latest). +- [@orpc/pinia-colada](https://www.npmjs.com/package/@orpc/pinia-colada): Integrate with [Pinia Colada](https://pinia-colada.esm.dev/). +- [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). +- [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). +- [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). +- [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins, helpers, like serving static files. +- [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. + +**Observability** + +- [@orpc/opentelemetry](https://www.npmjs.com/package/@orpc/opentelemetry): Integrate with [OpenTelemetry](https://opentelemetry.io/) for distributed tracing. +- [@orpc/pino](https://www.npmjs.com/package/@orpc/pino): Integrate with [Pino](https://getpino.io/) for logging. +- [@orpc/evlog](https://www.npmjs.com/package/@orpc/evlog): Integrate with [Evlog](https://evlog.dev/) for logging. + +## Sponsors + +Like what we build over at [middleapi](https://github.com/middleapi)? You can help keep it going here: [GitHub Sponsors](https://github.com/sponsors/dinwwwh). Every bit helps! 🚀 + +### 🏆 Platinum Sponsor + + + + + +
ScreenshotOne.com
ScreenshotOne.com
+ +### 🥈 Silver Sponsor + + + + + +
村上さん
村上さん
+ +### Generous Sponsors + + + + + +
LN Markets
LN Markets
+ +### Sponsors + + + + + + + + + + + + + + + + + + + + + + +
Reece McDonald
Reece McDonald
nk
nk
supastarter
supastarter
Dexter Miguel
Dexter Miguel
herrfugbaum
herrfugbaum
Ryota Murakami
Ryota Murakami
David Cramer
David Cramer
Valerii Petryniak
Valerii Petryniak
Valerii Strilets
Valerii Strilets
Kyle Mistele
Kyle Mistele
christ12938
christ12938
Ryan Soderberg
Ryan Soderberg
shota
shota
Ellis Driscoll
Ellis Driscoll
+ +### Backers + + + + + + + + + + + + + + + + + + + + + + + + + +
David Walsh
David Walsh
Robbe Vaes
Robbe Vaes
Aidan Sunbury
Aidan Sunbury
soonoo
soonoo
Kevin Porten
Kevin Porten
Denis
Denis
Christopher Kapic
Christopher Kapic
Tom Ballinger
Tom Ballinger
Sam
Sam
Titoine
Titoine
Igor Makowski
Igor Makowski
hanayashiki
hanayashiki
Lev Dubinets
Lev Dubinets
Kelly Peilin Chan
Kelly Peilin Chan
Guy Ariely
Guy Ariely
Alex
Alex
Andrey Gubanov
Andrey Gubanov
+ +### Past Sponsors + +

+ Maxie + Stijn Timmer + あわわわとーにゅ + Zuplo + motopods + Francisco Hermida + Théo LUDWIG + Abhay Ramesh + shr.ink oü + 0x4e32 + Ryuz + happyboy + yicchi + Saksham + Roman Hrynevych + rokitg + Omar Khatib + Yu-Sabo + Bapusaheb Patil + grim + Nelson Lai + Lê Cao Nguyên + Robert Soriano + Andrew Peters + Ryan Vogel + SKostyukovich + Peter Adam + Fabworks + Novak Antonijevic + Laduni Estu Syalwa + Chen, Zhi-Yuan + Illarion Koperski + Anees Iqbal + Sefa Eyeoglu + natt + Adam Tkaczyk + plancraft + Nicholas +

+ +## References + +oRPC is inspired by existing solutions that prioritize type safety and developer experience. Special acknowledgments to: + +- [tRPC](https://trpc.io): For pioneering the concept of end-to-end type-safe RPC and influencing the development of type-safe APIs. +- [ts-rest](https://ts-rest.com): For its emphasis on contract-first development and OpenAPI integration, which have greatly inspired oRPC's feature set. + +## License + +Distributed under the MIT License. See [LICENSE](https://github.com/middleapi/orpc/blob/main/LICENSE) for more information. diff --git a/packages/node/package.json b/packages/node/package.json new file mode 100644 index 000000000..59c9ce462 --- /dev/null +++ b/packages/node/package.json @@ -0,0 +1,48 @@ +{ + "name": "@orpc/node", + "type": "module", + "version": "2.0.0-beta.25", + "license": "MIT", + "funding": "https://github.com/sponsors/dinwwwh", + "homepage": "https://orpc.dev", + "repository": { + "type": "git", + "url": "git+https://github.com/middleapi/orpc.git", + "directory": "packages/node" + }, + "keywords": [ + "orpc", + "node", + "static" + ], + "sideEffects": false, + "publishConfig": { + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs", + "default": "./dist/index.mjs" + } + } + }, + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "unbuild", + "type:check": "tsc -b" + }, + "dependencies": { + "@orpc/server": "workspace:*", + "@orpc/shared": "workspace:*", + "@standardserver/core": "^0.7.1" + }, + "devDependencies": { + "supertest": "^7.2.2" + } +} diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts new file mode 100644 index 000000000..4ac3db29f --- /dev/null +++ b/packages/node/src/index.ts @@ -0,0 +1 @@ +export * from './static-file-handler-plugin' diff --git a/packages/node/src/static-file-handler-plugin.test.ts b/packages/node/src/static-file-handler-plugin.test.ts new file mode 100644 index 000000000..ada6df22e --- /dev/null +++ b/packages/node/src/static-file-handler-plugin.test.ts @@ -0,0 +1,560 @@ +import type { IncomingMessage, ServerResponse } from 'node:http' +import type { StaticFileHandlerPluginOptions } from './static-file-handler-plugin' +import { Buffer } from 'node:buffer' +import { mkdirSync, mkdtempSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { brotliCompressSync, gzipSync } from 'node:zlib' +import { os } from '@orpc/server' +import { RPCHandler as FetchRPCHandler } from '@orpc/server/fetch' +import { RPCHandler } from '@orpc/server/node' +import request from 'supertest' +import { StaticFileHandlerPlugin } from './static-file-handler-plugin' + +describe('staticFileHandlerPlugin', () => { + let baseDir: string + let rootDir: string + let helloEtag: string + let helloLastModified: string + + beforeAll(async () => { + baseDir = mkdtempSync(path.join(tmpdir(), 'orpc-static-file-')) + rootDir = path.join(baseDir, 'public') + mkdirSync(rootDir) + + // A file outside the root, so a successful traversal would serve real content + writeFileSync(path.join(baseDir, 'secret.txt'), 'outside root') + + writeFileSync(path.join(rootDir, 'index.html'), '

home

') + writeFileSync(path.join(rootDir, 'hello.txt'), 'hello world') + writeFileSync(path.join(rootDir, 'data.bin'), Buffer.from([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])) + writeFileSync(path.join(rootDir, 'no-extension'), 'binary-ish') + writeFileSync(path.join(rootDir, '.secret'), 'dotfile') + writeFileSync(path.join(rootDir, 'empty.txt'), '') + + mkdirSync(path.join(rootDir, 'nested')) + writeFileSync(path.join(rootDir, 'nested', 'index.html'), '

nested

') + writeFileSync(path.join(rootDir, 'nested', 'style.css'), 'body{}') + + mkdirSync(path.join(rootDir, 'no-index')) + writeFileSync(path.join(rootDir, 'no-index', 'file.txt'), 'inside') + + writeFileSync(path.join(rootDir, 'compressed.txt'), 'identity content') + writeFileSync(path.join(rootDir, 'compressed.txt.gz'), gzipSync('gzip content')) + writeFileSync(path.join(rootDir, 'compressed.txt.br'), brotliCompressSync('br content')) + writeFileSync(path.join(rootDir, 'compressed.bin'), 'binary identity') + writeFileSync(path.join(rootDir, 'compressed.bin.gz'), gzipSync('binary gzip')) + + const res = await createStaticAgent().get('/hello.txt') + helloEtag = res.headers.etag! + helloLastModified = res.headers['last-modified']! + }) + + afterAll(() => { + rmSync(baseDir, { recursive: true, force: true }) + }) + + function createAgent(handler: RPCHandler>, options: { prefix?: `/${string}` } = {}) { + return request(async (req: IncomingMessage, response: ServerResponse) => { + const result = await handler.handle(req as any, response as any, { context: {}, ...options }) + + if (!result.matched) { + response.statusCode = 404 + response.end('not matched') + } + }) + } + + function createStaticAgent(pluginOptions: Partial = {}, handleOptions: { prefix?: `/${string}` } = {}) { + const handler = new RPCHandler({}, { + plugins: [new StaticFileHandlerPlugin({ rootDir, ...pluginOptions })], + }) + + return createAgent(handler, handleOptions) + } + + it('serves a file with standard headers', async () => { + const res = await createStaticAgent().get('/hello.txt') + + expect(res.status).toBe(200) + expect(res.text).toBe('hello world') + expect(res.headers['content-type']).toBe('text/plain; charset=utf-8') + expect(res.headers['content-length']).toBe('11') + expect(res.headers['cache-control']).toBe('public, max-age=0') + expect(res.headers['accept-ranges']).toBe('bytes') + expect(res.headers.etag).toMatch(/^W\/"[0-9a-f]+-[0-9a-f]+"$/) + expect(res.headers['last-modified']).toBe(statSync(path.join(rootDir, 'hello.txt')).mtime.toUTCString()) + // Guards against a Blob body, which would make the adapter attach a content-disposition + expect(res.headers['content-disposition']).toBeUndefined() + }) + + it('serves an empty file', async () => { + const res = await createStaticAgent().get('/empty.txt') + + expect(res.status).toBe(200) + expect(res.text).toBe('') + expect(res.headers['content-length']).toBe('0') + }) + + it('serves unknown extensions as application/octet-stream', async () => { + const res = await createStaticAgent().get('/no-extension') + + expect(res.status).toBe(200) + expect(res.headers['content-type']).toBe('application/octet-stream') + }) + + it('does not resolve content types through the prototype chain', async () => { + writeFileSync(path.join(rootDir, 'file.constructor'), 'x') + writeFileSync(path.join(rootDir, 'file.__proto__'), 'x') + + const agent = createStaticAgent() + + for (const url of ['/file.constructor', '/file.__proto__']) { + const res = await agent.get(url) + expect(res.status, url).toBe(200) + expect(res.headers['content-type'], url).toBe('application/octet-stream') + } + }) + + it('supports custom cache control and mime types', async () => { + const agent = createStaticAgent({ + cacheControl: 'public, max-age=31536000, immutable', + mimeTypes: { txt: 'text/x-custom' }, + }) + + const res = await agent.get('/hello.txt') + + expect(res.headers['cache-control']).toBe('public, max-age=31536000, immutable') + expect(res.headers['content-type']).toBe('text/x-custom') + }) + + it('omits cache-control when disabled', async () => { + const res = await createStaticAgent({ cacheControl: false }).get('/hello.txt') + + expect(res.headers['cache-control']).toBeUndefined() + }) + + it('falls through when no file matches', async () => { + const res = await createStaticAgent().get('/missing.txt') + + expect(res.status).toBe(404) + expect(res.text).toBe('not matched') + }) + + it('ignores non-GET/HEAD requests', async () => { + const res = await createStaticAgent().post('/hello.txt') + + expect(res.status).toBe(404) + expect(res.text).toBe('not matched') + }) + + it('prefers matched procedures over files', async () => { + const handler = new RPCHandler({ + ping: os.handler(() => 'pong'), + }, { + allowMethods: ['GET'], + plugins: [new StaticFileHandlerPlugin({ rootDir })], + }) + + writeFileSync(path.join(rootDir, 'ping'), 'file content') + + const res = await createAgent(handler).get(`/ping?data=${encodeURIComponent(JSON.stringify({ json: null }))}`) + + expect(res.status).toBe(200) + expect(res.text).toContain('pong') + }) + + describe('head requests', () => { + it('sends headers without a body', async () => { + const res = await createStaticAgent().head('/hello.txt') + + expect(res.status).toBe(200) + expect(res.text).toBeUndefined() + expect(res.headers['content-type']).toBe('text/plain; charset=utf-8') + expect(res.headers['content-length']).toBe('11') + expect(res.headers.etag).toMatch(/^W\//) + }) + + it('ignores range headers', async () => { + const res = await createStaticAgent().head('/data.bin').set('range', 'bytes=0-3') + + expect(res.status).toBe(200) + expect(res.headers['content-length']).toBe('10') + }) + }) + + describe('conditional requests', () => { + it('responds 304 when if-none-match matches', async () => { + const res = await createStaticAgent().get('/hello.txt').set('if-none-match', helloEtag) + + expect(res.status).toBe(304) + expect(res.text).toBe('') + expect(res.headers.etag).toBe(helloEtag) + expect(res.headers['content-type']).toBeUndefined() + expect(res.headers['content-length']).toBeUndefined() + }) + + it('responds 304 when if-none-match contains the etag in a list or is a wildcard', async () => { + const agent = createStaticAgent() + + const listRes = await agent.get('/hello.txt').set('if-none-match', `"other", ${helloEtag}`) + expect(listRes.status).toBe(304) + + const wildcardRes = await agent.get('/hello.txt').set('if-none-match', '*') + expect(wildcardRes.status).toBe(304) + }) + + it('responds 200 when if-none-match does not match', async () => { + const res = await createStaticAgent().get('/hello.txt').set('if-none-match', '"different"') + + expect(res.status).toBe(200) + expect(res.text).toBe('hello world') + }) + + it('responds 304 when not modified since if-modified-since', async () => { + const res = await createStaticAgent().get('/hello.txt').set('if-modified-since', helloLastModified) + + expect(res.status).toBe(304) + }) + + it('responds 200 when modified after if-modified-since', async () => { + const past = new Date(Date.now() - 100_000_000).toUTCString() + const res = await createStaticAgent().get('/hello.txt').set('if-modified-since', past) + + expect(res.status).toBe(200) + }) + + it('if-none-match takes precedence over if-modified-since', async () => { + const res = await createStaticAgent().get('/hello.txt').set('if-none-match', '"different"').set('if-modified-since', helloLastModified) + + expect(res.status).toBe(200) + }) + + it('revalidates to 304 even when the client sends cache-control no-cache, like fetch does', async () => { + const res = await createStaticAgent().get('/hello.txt').set('if-none-match', helloEtag).set('cache-control', 'no-cache') + + expect(res.status).toBe(304) + }) + }) + + describe('range requests', () => { + it('serves a partial response', async () => { + const res = await createStaticAgent().get('/data.bin').set('range', 'bytes=2-5') + + expect(res.status).toBe(206) + expect(res.headers['content-range']).toBe('bytes 2-5/10') + expect(res.headers['content-length']).toBe('4') + expect(res.body).toEqual(Buffer.from([2, 3, 4, 5])) + }) + + it('serves an open-ended range', async () => { + const res = await createStaticAgent().get('/data.bin').set('range', 'bytes=7-') + + expect(res.status).toBe(206) + expect(res.headers['content-range']).toBe('bytes 7-9/10') + expect(res.body).toEqual(Buffer.from([7, 8, 9])) + }) + + it('serves a suffix range', async () => { + const res = await createStaticAgent().get('/data.bin').set('range', 'bytes=-3') + + expect(res.status).toBe(206) + expect(res.headers['content-range']).toBe('bytes 7-9/10') + expect(res.body).toEqual(Buffer.from([7, 8, 9])) + }) + + it('clamps ranges past the end of the file', async () => { + const res = await createStaticAgent().get('/data.bin').set('range', 'bytes=8-100') + + expect(res.status).toBe(206) + expect(res.headers['content-range']).toBe('bytes 8-9/10') + }) + + it('responds 416 when the range is unsatisfiable', async () => { + const res = await createStaticAgent().get('/data.bin').set('range', 'bytes=100-') + + expect(res.status).toBe(416) + expect(res.headers['content-range']).toBe('bytes */10') + }) + + it('ignores malformed and multi-range headers', async () => { + const agent = createStaticAgent() + + const malformedRes = await agent.get('/data.bin').set('range', 'bytes=abc') + expect(malformedRes.status).toBe(200) + + const multiRes = await agent.get('/data.bin').set('range', 'bytes=0-1,3-4') + expect(multiRes.status).toBe(200) + + const invertedRes = await agent.get('/data.bin').set('range', 'bytes=5-2') + expect(invertedRes.status).toBe(200) + }) + + it('ignores the range when if-range does not match', async () => { + const res = await createStaticAgent().get('/data.bin').set('range', 'bytes=0-3').set('if-range', new Date(Date.now() - 100_000_000).toUTCString()) + + expect(res.status).toBe(200) + expect(res.headers['content-length']).toBe('10') + }) + + it('applies the range when if-range matches the last modified date', async () => { + const res = await createStaticAgent().get('/data.bin').set('range', 'bytes=0-3').set('if-range', statSync(path.join(rootDir, 'data.bin')).mtime.toUTCString()) + + expect(res.status).toBe(206) + }) + }) + + describe('directories and index files', () => { + it('serves the index file for the root path', async () => { + const res = await createStaticAgent().get('/') + + expect(res.status).toBe(200) + expect(res.text).toBe('

home

') + expect(res.headers['content-type']).toBe('text/html; charset=utf-8') + }) + + it('serves the index file of a nested directory', async () => { + const res = await createStaticAgent().get('/nested/') + + expect(res.status).toBe(200) + expect(res.text).toBe('

nested

') + }) + + it('redirects directories without a trailing slash, preserving the query', async () => { + const res = await createStaticAgent().get('/nested?foo=bar') + + expect(res.status).toBe(301) + expect(res.headers.location).toBe('/nested/?foo=bar') + }) + + it('falls through when the directory has no index file', async () => { + const res = await createStaticAgent().get('/no-index/') + + expect(res.status).toBe(404) + }) + + it('supports disabling index files', async () => { + const res = await createStaticAgent({ indexFile: false }).get('/nested/') + + expect(res.status).toBe(404) + }) + + it('supports a custom index file', async () => { + const res = await createStaticAgent({ indexFile: 'file.txt' }).get('/no-index/') + + expect(res.status).toBe(200) + expect(res.text).toBe('inside') + }) + }) + + describe('security', () => { + it('blocks directory traversal', async () => { + const agent = createStaticAgent() + + const urls = [ + '/../secret.txt', + '/%2e%2e/secret.txt', + '/%2E%2E/secret.txt', + '/..%2fsecret.txt', + '/%2e%2e%2fsecret.txt', + '/%252e%252e/secret.txt', + '/..%c0%af/secret.txt', + '/nested/%2e%2e/%2e%2e/secret.txt', + '/foo%5c..%5cbar.txt', + '/foo%2fbar.txt', + ] + + for (const url of urls) { + const res = await agent.get(url) + expect(res.status, url).toBe(404) + expect(res.text, url).toBe('not matched') + } + }) + + it('resolves dot segments within the root instead of following them', async () => { + const agent = createStaticAgent() + + const inside = await agent.get('/nested/%2e%2e/hello.txt') + expect(inside.status).toBe(200) + expect(inside.text).toBe('hello world') + + const clamped = await agent.get('/%2e%2e/%2e%2e/%2e%2e/hello.txt') + expect(clamped.status).toBe(200) + expect(clamped.text).toBe('hello world') + }) + + it('never serves index or fallback files that escape the root', async () => { + const indexRes = await createStaticAgent({ indexFile: '../secret.txt' }).get('/') + expect(indexRes.status).toBe(404) + + const fallbackRes = await createStaticAgent({ fallbackFile: '../secret.txt' }).get('/missing.txt') + expect(fallbackRes.status).toBe(404) + }) + + it('rejects paths containing null bytes', async () => { + const res = await createStaticAgent().get('/hello.txt%00.png') + + expect(res.status).toBe(404) + }) + + it('hides dotfiles by default and serves them when enabled', async () => { + const hiddenRes = await createStaticAgent().get('/.secret') + expect(hiddenRes.status).toBe(404) + + const allowedRes = await createStaticAgent({ dotfiles: true }).get('/.secret') + expect(allowedRes.status).toBe(200) + expect(allowedRes.body).toEqual(Buffer.from('dotfile')) + }) + }) + + describe('mounting', () => { + it('serves files under a custom path', async () => { + const agent = createStaticAgent({ path: '/assets' }) + + const res = await agent.get('/assets/hello.txt') + expect(res.status).toBe(200) + expect(res.text).toBe('hello world') + + const outsideRes = await agent.get('/hello.txt') + expect(outsideRes.status).toBe(404) + + const partialPrefixRes = await agent.get('/assetshello.txt') + expect(partialPrefixRes.status).toBe(404) + }) + + it('serves files under the handler prefix', async () => { + const agent = createStaticAgent({}, { prefix: '/api' }) + + const res = await agent.get('/api/hello.txt') + expect(res.status).toBe(200) + expect(res.text).toBe('hello world') + }) + + it('combines the handler prefix and the path option', async () => { + const agent = createStaticAgent({ path: '/assets' }, { prefix: '/api' }) + + const res = await agent.get('/api/assets/hello.txt') + expect(res.status).toBe(200) + + const outsideRes = await agent.get('/api/hello.txt') + expect(outsideRes.status).toBe(404) + }) + + it('redirects a mounted directory path without a trailing slash', async () => { + const res = await createStaticAgent({ path: '/assets' }).get('/assets') + + expect(res.status).toBe(301) + expect(res.headers.location).toBe('/assets/') + }) + }) + + describe('precompressed', () => { + it('serves the precompressed variant when the client accepts its encoding', async () => { + const res = await createStaticAgent({ precompressed: true }) + .get('/compressed.txt') + .set('accept-encoding', 'gzip') + + expect(res.status).toBe(200) + expect(res.headers['content-encoding']).toBe('gzip') + expect(res.headers.vary).toBe('accept-encoding') + expect(res.headers['content-type']).toBe('text/plain; charset=utf-8') + expect(res.text).toBe('gzip content') + }) + + it('prefers brotli over gzip and ignores q-value parameters', async () => { + const res = await createStaticAgent({ precompressed: true }) + .get('/compressed.txt') + .set('accept-encoding', 'gzip;q=0.8, br;q=0.9') + + expect(res.status).toBe(200) + expect(res.headers['content-encoding']).toBe('br') + expect(res.text).toBe('br content') + }) + + it('serves the identity variant with vary when no encoding is accepted', async () => { + const res = await createStaticAgent({ precompressed: true }) + .get('/compressed.txt') + .set('accept-encoding', 'identity') + + expect(res.status).toBe(200) + expect(res.headers['content-encoding']).toBeUndefined() + expect(res.headers.vary).toBe('accept-encoding') + expect(res.text).toBe('identity content') + }) + + it('is disabled by default', async () => { + const res = await createStaticAgent() + .get('/compressed.txt') + .set('accept-encoding', 'gzip') + + expect(res.headers['content-encoding']).toBeUndefined() + expect(res.headers.vary).toBeUndefined() + expect(res.text).toBe('identity content') + }) + + it('does not apply to non-compressible content types', async () => { + const res = await createStaticAgent({ precompressed: true }) + .get('/compressed.bin') + .set('accept-encoding', 'gzip') + + expect(res.headers['content-encoding']).toBeUndefined() + expect(res.headers.vary).toBeUndefined() + expect(res.body).toEqual(Buffer.from('binary identity')) + }) + }) + + describe('fallback file', () => { + it('serves the fallback file when nothing matches', async () => { + const res = await createStaticAgent({ fallbackFile: 'index.html' }).get('/some/spa/route') + + expect(res.status).toBe(200) + expect(res.text).toBe('

home

') + expect(res.headers['content-type']).toBe('text/html; charset=utf-8') + }) + + it('still serves existing files over the fallback', async () => { + const res = await createStaticAgent({ fallbackFile: 'index.html' }).get('/hello.txt') + + expect(res.status).toBe(200) + expect(res.text).toBe('hello world') + }) + }) + + it('handles files modified in the past consistently', async () => { + const filePath = path.join(rootDir, 'old.txt') + writeFileSync(filePath, 'old content') + const past = new Date('2020-01-02T03:04:05Z') + utimesSync(filePath, past, past) + + const agent = createStaticAgent() + const res = await agent.get('/old.txt') + + expect(res.status).toBe(200) + expect(res.headers['last-modified']).toBe(past.toUTCString()) + + const freshRes = await agent.get('/old.txt').set('if-modified-since', past.toUTCString()) + expect(freshRes.status).toBe(304) + }) + + /** + * The plugin is adapter agnostic, these smoke tests only prove the fetch + * adapter wiring; the http semantics are covered by the suites above. + */ + describe('fetch adapter', () => { + it('serves a file and falls through when no file matches', async () => { + const handler = new FetchRPCHandler({}, { + plugins: [new StaticFileHandlerPlugin({ rootDir })], + }) + + const served = await handler.handle(new Request('https://example.com/hello.txt')) + expect(served.matched).toBe(true) + expect(served.response!.status).toBe(200) + expect(await served.response!.text()).toBe('hello world') + expect(served.response!.headers.get('content-type')).toBe('text/plain; charset=utf-8') + expect(served.response!.headers.get('etag')).toBe(helloEtag) + + const unmatched = await handler.handle(new Request('https://example.com/missing.txt')) + expect(unmatched.matched).toBe(false) + }) + }) +}) diff --git a/packages/node/src/static-file-handler-plugin.ts b/packages/node/src/static-file-handler-plugin.ts new file mode 100644 index 000000000..8a03ecc07 --- /dev/null +++ b/packages/node/src/static-file-handler-plugin.ts @@ -0,0 +1,452 @@ +import type { Context } from '@orpc/server' +import type { StandardHandlerOptions, StandardHandlerPlugin, StandardHandlerRoutingInterceptor } from '@orpc/server/standard' +import type { StandardHeaders, StandardLazyRequest, StandardResponse } from '@standardserver/core' +import type { Stats } from 'node:fs' +import { createReadStream } from 'node:fs' +import { stat } from 'node:fs/promises' +import path from 'node:path' +import { Readable } from 'node:stream' +import { getOpenTelemetryConfig, isCompressibleContentType, matchesHttpPathPrefix, mergeHttpPath, parseAcceptEncodings, toArray, tryDecodeURIComponent } from '@orpc/shared' +import { flattenStandardHeader, parseStandardUrl } from '@standardserver/core' + +const DEFAULT_MIME_TYPES: Record = { + html: 'text/html; charset=utf-8', + htm: 'text/html; charset=utf-8', + css: 'text/css; charset=utf-8', + js: 'text/javascript; charset=utf-8', + mjs: 'text/javascript; charset=utf-8', + cjs: 'text/javascript; charset=utf-8', + json: 'application/json; charset=utf-8', + map: 'application/json; charset=utf-8', + webmanifest: 'application/manifest+json', + txt: 'text/plain; charset=utf-8', + md: 'text/markdown; charset=utf-8', + csv: 'text/csv; charset=utf-8', + xml: 'application/xml', + pdf: 'application/pdf', + wasm: 'application/wasm', + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + svg: 'image/svg+xml', + webp: 'image/webp', + avif: 'image/avif', + ico: 'image/x-icon', + bmp: 'image/bmp', + woff: 'font/woff', + woff2: 'font/woff2', + ttf: 'font/ttf', + otf: 'font/otf', + eot: 'application/vnd.ms-fontobject', + mp3: 'audio/mpeg', + wav: 'audio/wav', + ogg: 'audio/ogg', + mp4: 'video/mp4', + webm: 'video/webm', + zip: 'application/zip', + gz: 'application/gzip', +} + +const PRECOMPRESSED_ENCODINGS: [encoding: string, extension: string][] = [ + ['br', '.br'], + ['zstd', '.zst'], + ['gzip', '.gz'], +] + +export interface StaticFileHandlerPluginOptions { + /** + * The directory files are served from. Resolved against the working + * directory when relative. + */ + rootDir: string + + /** + * The URL path files are served under, appended to the handler prefix + * when one is set. + * + * @default '/' + */ + path?: `/${string}` + + /** + * The file served when the request path resolves to a directory. + * Set to `false` to disable directory index files. + * + * @default 'index.html' + */ + indexFile?: string | false + + /** + * A file served with status 200 when no file matches the request path, + * relative to `rootDir`. Useful for single-page application routing. + * + * @default undefined + */ + fallbackFile?: string + + /** + * The `Cache-Control` response header value. Set to `false` to omit the header. + * + * @default 'public, max-age=0' + */ + cacheControl?: string | false + + /** + * Whether files and directories whose name starts with a dot can be served. + * + * @default false + */ + dotfiles?: boolean + + /** + * Whether precompressed sidecar files (`.br`, `.zst`, `.gz`) can be served + * when the client accepts their encoding and the content type is compressible. + * + * @default false + */ + precompressed?: boolean + + /** + * Extra content types keyed by lowercase file extension without the dot, + * merged over the built-in mapping. Unknown extensions are served as + * `application/octet-stream`. + */ + mimeTypes?: Record +} + +/** + * Serves static files from a directory with standard HTTP semantics: + * `ETag`/`Last-Modified` conditional requests, range requests, index files, + * and directory traversal protection. Files are served as a fallback, + * so matched procedures always win. + * + * @see {@link https://orpc.dev/docs/plugins/static-file | Static File Plugin} + */ +export class StaticFileHandlerPlugin implements StandardHandlerPlugin { + name = '~static-file' + + /** + * Ensure the OpenTelemetry span is available when the interceptor renames it. + */ + after = ['~opentelemetry'] + + private readonly rootDir: string + /** `rootDir` with a trailing separator, precomputed for the containment check. */ + private readonly rootDirPrefix: string + private readonly path: Exclude + private readonly indexFile: Exclude + private readonly fallbackFile: StaticFileHandlerPluginOptions['fallbackFile'] + private readonly cacheControl: Exclude + private readonly dotfiles: Exclude + private readonly precompressed: Exclude + private readonly mimeTypes: Exclude + + constructor(options: StaticFileHandlerPluginOptions) { + this.rootDir = path.resolve(options.rootDir) + this.rootDirPrefix = this.rootDir.endsWith(path.sep) ? this.rootDir : this.rootDir + path.sep + const basePath = options.path ?? '/' + this.path = basePath.length > 1 && basePath.endsWith('/') ? basePath.slice(0, -1) as `/${string}` : basePath + this.indexFile = options.indexFile ?? 'index.html' + this.fallbackFile = options.fallbackFile + this.cacheControl = options.cacheControl ?? 'public, max-age=0' + this.dotfiles = options.dotfiles ?? false + this.precompressed = options.precompressed ?? false + // A null prototype prevents extensions like "constructor" from resolving through the prototype chain + this.mimeTypes = Object.assign(Object.create(null) as Record, DEFAULT_MIME_TYPES, options.mimeTypes) + } + + init(options: StandardHandlerOptions): StandardHandlerOptions { + const routingInterceptor: StandardHandlerRoutingInterceptor = async ({ next, request, prefix }) => { + const result = await next() + + if (result.matched || (request.method !== 'GET' && request.method !== 'HEAD')) { + return result + } + + const base = this.resolveBasePath(prefix) + const response = await this.serve(request, base) + + if (response === undefined) { + return result + } + + /** + * The request url is excluded because span names should have low cardinality, + * so only the configured base path is used. + */ + getOpenTelemetryConfig()?.trace.getActiveSpan()?.updateName(`${request.method} ${base === '/' ? '' : base}/* (static file)`) + + return { matched: true, response } + } + + return { + ...options, + // Run after user-provided routing interceptors so they can capture file responses + routingInterceptors: [...toArray(options.routingInterceptors), routingInterceptor], + } + } + + /** + * Joins segments under `rootDir` and guarantees the result cannot escape it, + * as a second layer of defense after URL segment validation. + */ + private resolveWithinRoot(segments: string[]): string | undefined { + const resolved = path.join(this.rootDir, ...segments) + + if (resolved !== this.rootDir && !resolved.startsWith(this.rootDirPrefix)) { + return undefined + } + + return resolved + } + + private resolveBasePath(prefix: `/${string}` | undefined): `/${string}` { + if (prefix === undefined) { + return this.path + } + + const base = mergeHttpPath(prefix, this.path) + return base.length > 1 && base.endsWith('/') ? base.slice(0, -1) as `/${string}` : base + } + + /** + * Resolves segments under `rootDir` and stats the result, + * `resolveWithinRoot` guarantees nothing outside the root is ever touched. + */ + private async lookup(segments: string[]): Promise<[filePath: string, stats: Stats] | undefined> { + const filePath = this.resolveWithinRoot(segments) + + if (filePath === undefined) { + return undefined + } + + const stats = await stat(filePath).catch(() => undefined) + return stats === undefined ? undefined : [filePath, stats] + } + + private async serve(request: StandardLazyRequest, base: `/${string}`): Promise { + const [pathname, search] = parseStandardUrl(request.url) + + if (base !== '/' && !matchesHttpPathPrefix(pathname, base)) { + return undefined + } + + const segments: string[] = [] + for (const rawSegment of pathname.slice(base.length).split('/')) { + const segment = rawSegment.includes('%') ? tryDecodeURIComponent(rawSegment) : rawSegment + + if (segment === '' || segment === '.') { + continue + } + + // Dot segments are resolved in url space and clamped at the root, so they can never escape it + if (segment === '..') { + segments.pop() + continue + } + + if (segment.includes('\0') || segment.includes('/') || segment.includes('\\')) { + return undefined + } + + if (!this.dotfiles && segment.startsWith('.')) { + return undefined + } + + segments.push(segment) + } + + let found = await this.lookup(segments) + + if (found?.[1].isDirectory()) { + if (this.indexFile === false) { + found = undefined + } + else if (!pathname.endsWith('/')) { + // Redirect so relative links inside the index file resolve correctly + return { status: 301, headers: { location: `${pathname}/${search ?? ''}` } } + } + else { + found = await this.lookup([...segments, this.indexFile]) + } + } + + if (found === undefined || !found[1].isFile()) { + if (this.fallbackFile === undefined) { + return undefined + } + + found = await this.lookup([this.fallbackFile]) + + if (found === undefined || !found[1].isFile()) { + return undefined + } + } + + let [filePath, stats] = found + + const contentType = this.mimeTypes[path.extname(filePath).slice(1).toLowerCase()] ?? 'application/octet-stream' + const negotiatesEncoding = this.precompressed && isCompressibleContentType(contentType) + + let contentEncoding: string | undefined + + if (negotiatesEncoding) { + const acceptedEncodings = new Set(parseAcceptEncodings(flattenStandardHeader(request.headers['accept-encoding']))) + const candidates = PRECOMPRESSED_ENCODINGS.filter(([encoding]) => acceptedEncodings.has(encoding)) + const candidateStats = await Promise.all(candidates.map(([, extension]) => stat(filePath + extension).catch(() => undefined))) + + for (let i = 0; i < candidates.length; i++) { + if (candidateStats[i]?.isFile()) { + filePath += candidates[i]![1] + stats = candidateStats[i]! + contentEncoding = candidates[i]![0] + break + } + } + } + + const size = stats.size + const etag = `W/"${size.toString(16)}-${stats.mtime.getTime().toString(16)}"` + const lastModified = stats.mtime.toUTCString() + // Truncated to seconds, matching the precision of http dates + const lastModifiedTime = Math.floor(stats.mtime.getTime() / 1000) * 1000 + + const headers: StandardHeaders = { + 'etag': etag, + 'last-modified': lastModified, + 'accept-ranges': 'bytes', + } + + if (negotiatesEncoding) { + // Sent even for the identity variant, so caches key on the encoding + headers.vary = 'accept-encoding' + } + + if (contentEncoding !== undefined) { + headers['content-encoding'] = contentEncoding + } + + if (this.cacheControl !== false) { + headers['cache-control'] = this.cacheControl + } + + if (isRequestFresh(request.headers, etag, lastModifiedTime)) { + return { status: 304, headers } + } + + let status = 200 + let start = 0 + let end = size - 1 + + const range = request.method === 'GET' ? flattenStandardHeader(request.headers.range) : undefined + + if (range !== undefined && isRangeApplicable(request.headers, lastModifiedTime)) { + const parsed = parseByteRange(range, size) + + if (parsed === 'unsatisfiable') { + return { status: 416, headers: { ...headers, 'content-range': `bytes */${size}` } } + } + + if (parsed !== undefined) { + status = 206 + ;[start, end] = parsed + headers['content-range'] = `bytes ${start}-${end}/${size}` + } + } + + headers['content-type'] = contentType + headers['content-length'] = String(end - start + 1) + + /** + * A HEAD response uses an empty stream instead of no body, because + * the adapters strip the content headers when the body is `undefined`. + */ + const body: ReadableStream> = request.method === 'HEAD' + ? new ReadableStream({ start: controller => controller.close() }) + : Readable.toWeb(status === 206 ? createReadStream(filePath, { start, end }) : createReadStream(filePath)) as ReadableStream> + + return { status, headers, body } + } +} + +function stripWeakEtagPrefix(etag: string): string { + return etag.startsWith('W/') ? etag.slice(2) : etag +} + +/** + * A request `Cache-Control: no-cache` directive is deliberately not considered: + * it asks for validation with the origin and a `304` is a successful validation. + * `fetch` even sends it alongside its conditional headers. + */ +function isRequestFresh(requestHeaders: StandardHeaders, etag: string, lastModifiedTime: number): boolean { + const ifNoneMatch = flattenStandardHeader(requestHeaders['if-none-match']) + if (ifNoneMatch !== undefined) { + if (ifNoneMatch.trim() === '*') { + return true + } + + const bareEtag = stripWeakEtagPrefix(etag) + return ifNoneMatch.split(',').some(tag => stripWeakEtagPrefix(tag.trim()) === bareEtag) + } + + const ifModifiedSince = flattenStandardHeader(requestHeaders['if-modified-since']) + if (ifModifiedSince !== undefined) { + const sinceTime = Date.parse(ifModifiedSince) + return !Number.isNaN(sinceTime) && lastModifiedTime <= sinceTime + } + + return false +} + +function isRangeApplicable(requestHeaders: StandardHeaders, lastModifiedTime: number): boolean { + const ifRange = flattenStandardHeader(requestHeaders['if-range']) + + if (ifRange === undefined) { + return true + } + + /** + * The etag form requires a strong comparison and generated etags + * are always weak, so it can never match. + */ + if (ifRange.startsWith('"') || ifRange.startsWith('W/')) { + return false + } + + return Date.parse(ifRange) === lastModifiedTime +} + +function parseByteRange(range: string, size: number): [start: number, end: number] | 'unsatisfiable' | undefined { + const match = range.match(/^bytes=(\d*)-(\d*)$/) + + // Malformed and multi-range headers are ignored, serving the full file + if (match === null || (match[1] === '' && match[2] === '')) { + return undefined + } + + if (match[1] === '') { + const suffixLength = Number(match[2]) + + if (suffixLength === 0 || size === 0) { + return 'unsatisfiable' + } + + return [Math.max(0, size - suffixLength), size - 1] + } + + const start = Number(match[1]) + + if (start >= size) { + return 'unsatisfiable' + } + + const end = match[2] === '' ? size - 1 : Math.min(Number(match[2]), size - 1) + + // An end before the start makes the range invalid, so it is ignored + if (end < start) { + return undefined + } + + return [start, end] +} diff --git a/packages/node/tests/serves-static-files.test.ts b/packages/node/tests/serves-static-files.test.ts new file mode 100644 index 000000000..3afffde16 --- /dev/null +++ b/packages/node/tests/serves-static-files.test.ts @@ -0,0 +1,66 @@ +import type { AddressInfo } from 'node:net' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { createServer } from 'node:http' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { os } from '@orpc/server' +import { RPCHandler } from '@orpc/server/node' +import { StaticFileHandlerPlugin } from '../src' + +it('serves static files', async ({ onTestFinished }) => { + const rootDir = mkdtempSync(path.join(tmpdir(), 'orpc-node-e2e-')) + onTestFinished(() => { + rmSync(rootDir, { recursive: true, force: true }) + }) + + writeFileSync(path.join(rootDir, 'hello.txt'), 'hello world') + + const handler = new RPCHandler({ + ping: os.handler(() => 'pong'), + }, { + allowMethods: ['GET'], + plugins: [ + new StaticFileHandlerPlugin({ rootDir }), + ], + }) + + const server = createServer(async (req, res) => { + const result = await handler.handle(req, res, { context: {} }) + + if (!result.matched) { + res.statusCode = 404 + res.end('not matched') + } + }) + onTestFinished(() => { + server.close() + }) + + await new Promise(resolve => server.listen(0, resolve)) + const url = `http://localhost:${(server.address() as AddressInfo).port}` + + const fileRes = await fetch(`${url}/hello.txt`) + expect(fileRes.status).toBe(200) + expect(await fileRes.text()).toBe('hello world') + expect(fileRes.headers.get('content-type')).toBe('text/plain; charset=utf-8') + expect(fileRes.headers.get('etag')).toMatch(/^W\//) + + /** + * Regression only reproducible with a real fetch client: it sends + * `cache-control: no-cache` alongside its conditional headers, + * which must not prevent the 304 revalidation. + */ + const cachedRes = await fetch(`${url}/hello.txt`, { + headers: { 'if-none-match': fileRes.headers.get('etag')! }, + }) + expect(cachedRes.status).toBe(304) + expect(await cachedRes.text()).toBe('') + + const procedureRes = await fetch(`${url}/ping?data=${encodeURIComponent(JSON.stringify({ json: null }))}`) + expect(procedureRes.status).toBe(200) + expect(await procedureRes.text()).toContain('pong') + + const missingRes = await fetch(`${url}/missing.txt`) + expect(missingRes.status).toBe(404) + expect(await missingRes.text()).toBe('not matched') +}) diff --git a/packages/node/tsconfig.json b/packages/node/tsconfig.json new file mode 100644 index 000000000..ffc60e720 --- /dev/null +++ b/packages/node/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.lib.json", + "compilerOptions": { + "types": ["node"] + }, + "references": [ + { "path": "../server" }, + { "path": "../shared" } + ], + "include": ["package.json", "src"], + "exclude": [ + "**/*.test.*", + "**/*.test-d.ts", + "**/*.bench.*", + "**/__tests__/**", + "**/__mocks__/**", + "**/__snapshots__/**" + ] +} diff --git a/packages/openapi/README.md b/packages/openapi/README.md index 031709ca1..2e280c4a4 100644 --- a/packages/openapi/README.md +++ b/packages/openapi/README.md @@ -56,6 +56,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). +- [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins, helpers, like serving static files. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). - [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. diff --git a/packages/opentelemetry/README.md b/packages/opentelemetry/README.md index b20f33cab..0709a4dc3 100644 --- a/packages/opentelemetry/README.md +++ b/packages/opentelemetry/README.md @@ -56,6 +56,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). +- [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins, helpers, like serving static files. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). - [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. diff --git a/packages/pinia-colada/README.md b/packages/pinia-colada/README.md index 9112c8add..808e4b43e 100644 --- a/packages/pinia-colada/README.md +++ b/packages/pinia-colada/README.md @@ -56,6 +56,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). +- [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins, helpers, like serving static files. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). - [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. diff --git a/packages/pino/README.md b/packages/pino/README.md index a4b04347e..796933162 100644 --- a/packages/pino/README.md +++ b/packages/pino/README.md @@ -56,6 +56,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). +- [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins, helpers, like serving static files. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). - [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. diff --git a/packages/publisher/README.md b/packages/publisher/README.md index ac425bdeb..b6ede838b 100644 --- a/packages/publisher/README.md +++ b/packages/publisher/README.md @@ -56,6 +56,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). +- [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins, helpers, like serving static files. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). - [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. diff --git a/packages/ratelimit/README.md b/packages/ratelimit/README.md index 4f9806553..4ac00451c 100644 --- a/packages/ratelimit/README.md +++ b/packages/ratelimit/README.md @@ -56,6 +56,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). +- [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins, helpers, like serving static files. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). - [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. diff --git a/packages/server/README.md b/packages/server/README.md index a282e8ef0..d04b65178 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -56,6 +56,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). +- [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins, helpers, like serving static files. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). - [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. diff --git a/packages/server/src/plugins/response-compression.ts b/packages/server/src/plugins/response-compression.ts index 1338a1a0b..a1aea8539 100644 --- a/packages/server/src/plugins/response-compression.ts +++ b/packages/server/src/plugins/response-compression.ts @@ -1,7 +1,7 @@ import type { StandardBodyHint } from '@standardserver/core' import type { StandardHandlerOptions, StandardHandlerPlugin, StandardHandlerRoutingInterceptor } from '../adapters/standard' import type { Context } from '../context' -import { isAsyncIteratorObject, isCompressibleContentType, stringifyJSON, toArray } from '@orpc/shared' +import { isAsyncIteratorObject, isCompressibleContentType, parseAcceptEncodings, stringifyJSON, toArray } from '@orpc/shared' import { flattenStandardHeader, generateContentDisposition } from '@standardserver/core' // Rough UTF-8 estimate. Mostly ASCII text stays close to 1 byte/char; @@ -236,22 +236,6 @@ export class ResponseCompressionHandlerPlugin implements Stan } } -/** - * Parse Accept-Encoding into coding tokens (q-values ignored; order is client preference). - * - * @see https://www.rfc-editor.org/rfc/rfc9110.html#name-accept-encoding - */ -function parseAcceptEncodings(header: string | undefined): string[] { - if (header === undefined) { - return [] - } - - return header - .split(',') - .map(part => part.trim().split(';')[0]!.trim().toLowerCase()) - .filter(Boolean) -} - /** * Whether Cache-Control includes the no-transform directive. * diff --git a/packages/shared/README.md b/packages/shared/README.md index a4ce0cb44..86c368c19 100644 --- a/packages/shared/README.md +++ b/packages/shared/README.md @@ -56,6 +56,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). +- [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins, helpers, like serving static files. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). - [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. diff --git a/packages/shared/src/http.ts b/packages/shared/src/http.ts index e4c3139ab..e1547992e 100644 --- a/packages/shared/src/http.ts +++ b/packages/shared/src/http.ts @@ -52,6 +52,22 @@ export function matchesHttpPath(url: `/${string}`, path: `/${string}`): boolean || charAfterPrefix === '#' } +/** + * Parse Accept-Encoding into coding tokens (q-values ignored; order is client preference). + * + * @see https://www.rfc-editor.org/rfc/rfc9110.html#name-accept-encoding + */ +export function parseAcceptEncodings(header: string | undefined): string[] { + if (header === undefined) { + return [] + } + + return header + .split(',') + .map(part => part.trim().split(';')[0]!.trim().toLowerCase()) + .filter(Boolean) +} + /** * inspired from Hono Compression Plugin */ diff --git a/packages/swr/README.md b/packages/swr/README.md index 5446724f4..b2636daf2 100644 --- a/packages/swr/README.md +++ b/packages/swr/README.md @@ -56,6 +56,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). +- [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins, helpers, like serving static files. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). - [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. diff --git a/packages/tanstack-query/README.md b/packages/tanstack-query/README.md index b92ca35de..d95b8b76e 100644 --- a/packages/tanstack-query/README.md +++ b/packages/tanstack-query/README.md @@ -56,6 +56,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). +- [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins, helpers, like serving static files. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). - [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. diff --git a/packages/trpc/README.md b/packages/trpc/README.md index bd5b48274..6c3f122c1 100644 --- a/packages/trpc/README.md +++ b/packages/trpc/README.md @@ -56,6 +56,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). +- [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins, helpers, like serving static files. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). - [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. diff --git a/packages/valibot/README.md b/packages/valibot/README.md index f8de87be8..3c63102fd 100644 --- a/packages/valibot/README.md +++ b/packages/valibot/README.md @@ -56,6 +56,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). +- [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins, helpers, like serving static files. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). - [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. diff --git a/packages/zod/README.md b/packages/zod/README.md index f2472bc3e..fee2e03ab 100644 --- a/packages/zod/README.md +++ b/packages/zod/README.md @@ -56,6 +56,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). +- [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins, helpers, like serving static files. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). - [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19f9762ad..2c424387a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@orpc/next': specifier: workspace:* version: link:packages/next + '@orpc/node': + specifier: workspace:* + version: link:packages/node '@orpc/openapi': specifier: workspace:* version: link:packages/openapi @@ -551,6 +554,22 @@ importers: specifier: ^19.2.8 version: 19.2.8 + packages/node: + dependencies: + '@orpc/server': + specifier: workspace:* + version: link:../server + '@orpc/shared': + specifier: workspace:* + version: link:../shared + '@standardserver/core': + specifier: ^0.7.1 + version: 0.7.1 + devDependencies: + supertest: + specifier: ^7.2.2 + version: 7.2.2(supports-color@10.2.2) + packages/openapi: dependencies: '@hey-api/spec-types': From f555fd239d7befec4797cc639ccab4b2897ac6af Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Mon, 10 Aug 2026 10:18:10 +0700 Subject: [PATCH 02/14] fix(node): close open redirect and symlink escape in StaticFileHandlerPlugin The directory redirect echoed the raw request target, so //attacker.example/.. resolved cross-origin, and symlinks (including precompressed sidecars, which skipped the containment check) could read any file outside rootDir. Also evaluates If-Match/If-Unmodified-Since, makes the ETag strong so If-Range can match, honors Accept-Encoding q-values, and keeps representation metadata off 304 and 416 responses. --- apps/content/docs/plugins/static-file.mdx | 16 +- benches/static-file-handler.bench.ts | 10 + .../src/static-file-handler-plugin.test.ts | 398 +++++++++++++++++- .../node/src/static-file-handler-plugin.ts | 236 ++++++++--- .../node/tests/serves-static-files.test.ts | 2 +- .../src/plugins/response-compression.test.ts | 2 +- .../src/plugins/response-compression.ts | 6 +- packages/shared/src/http.test.ts | 30 ++ packages/shared/src/http.ts | 28 +- 9 files changed, 647 insertions(+), 81 deletions(-) diff --git a/apps/content/docs/plugins/static-file.mdx b/apps/content/docs/plugins/static-file.mdx index b955558c6..8b350d00a 100644 --- a/apps/content/docs/plugins/static-file.mdx +++ b/apps/content/docs/plugins/static-file.mdx @@ -15,9 +15,13 @@ npm install @orpc/node@beta After routing, when no procedure matches a GET or HEAD request, the plugin maps the request path to a file inside `rootDir` and serves it. Matched procedures always take precedence. Requests that resolve to a directory are redirected to their trailing slash form and answered with the directory's `index.html`. -Every file response carries a weak [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) header, so clients sending `If-None-Match` or `If-Modified-Since` receive `304 Not Modified` when the file is unchanged. Single [range requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Range_requests) are answered with `206 Partial Content`, which enables media seeking and resumable downloads. +Every file response carries an [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) header, so clients sending `If-None-Match` or `If-Modified-Since` receive `304 Not Modified` when the file is unchanged, and `If-Match` or `If-Unmodified-Since` receive `412 Precondition Failed`. Single [range requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Range_requests) are answered with `206 Partial Content`, which enables media seeking and resumable downloads. -Dot segments like `..` are resolved in URL space and clamped at the served directory, following the [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986#section-5.2.4) normalization browsers and proxies apply, so a request can never read outside `rootDir`. Dotfiles are treated as not found unless explicitly enabled. +Dot segments like `..` are resolved in URL space and clamped at the served directory, following the [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986#section-5.2.4) normalization browsers and proxies apply, and the resolved path is checked against `rootDir` again before any file is opened. Symbolic links whose target leaves `rootDir` are refused unless `allowSymlinks` is set. Dotfiles are treated as not found unless explicitly enabled, which is a request-path policy, so it does not apply to a configured `indexFile` or `fallbackFile`. + +:::warning +`rootDir` should contain only files you intend to make public. Anything reachable inside it is served, including a file a request happens to name. +::: ## Setup @@ -82,6 +86,14 @@ const handler = new RPCHandler(router, { */ precompressed: false, + /** + * Whether symbolic links whose target lies outside `rootDir` can be served. + * Enabling this makes every file the links reach publicly readable. + * + * @default false + */ + allowSymlinks: false, + /** * Extra content types keyed by lowercase file extension without the dot, * merged over the built-in mapping. Unknown extensions are served as diff --git a/benches/static-file-handler.bench.ts b/benches/static-file-handler.bench.ts index 493619463..850689d18 100644 --- a/benches/static-file-handler.bench.ts +++ b/benches/static-file-handler.bench.ts @@ -18,6 +18,11 @@ const handler = new StandardHandler(new RPCHandlerCodec({}, {}), { plugins: [new StaticFileHandlerPlugin({ rootDir })], }) +/** Skips the symlink containment check, which costs one `realpath` per lookup. */ +const trustedHandler = new StandardHandler(new RPCHandlerCodec({}, {}), { + plugins: [new StaticFileHandlerPlugin({ rootDir, allowSymlinks: true })], +}) + function createRequest(url: `/${string}`, headers: Record = {}): StandardLazyRequest { return { url, @@ -54,4 +59,9 @@ describe('static file handler plugin', () => { bench('not found fall through', async () => { await handler.handle(createRequest('/missing/file.txt'), { context: {} }) }) + + bench('serve 10kb file (allowSymlinks)', async () => { + const { response } = await trustedHandler.handle(createRequest('/file.txt'), { context: {} }) + await drainBody(response!.body) + }) }) diff --git a/packages/node/src/static-file-handler-plugin.test.ts b/packages/node/src/static-file-handler-plugin.test.ts index ada6df22e..544362574 100644 --- a/packages/node/src/static-file-handler-plugin.test.ts +++ b/packages/node/src/static-file-handler-plugin.test.ts @@ -1,13 +1,17 @@ import type { IncomingMessage, ServerResponse } from 'node:http' +import type { AddressInfo } from 'node:net' import type { StaticFileHandlerPluginOptions } from './static-file-handler-plugin' import { Buffer } from 'node:buffer' -import { mkdirSync, mkdtempSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs' +import { createServer, request as httpRequest } from 'node:http' import { tmpdir } from 'node:os' import path from 'node:path' +import process from 'node:process' import { brotliCompressSync, gzipSync } from 'node:zlib' import { os } from '@orpc/server' import { RPCHandler as FetchRPCHandler } from '@orpc/server/fetch' import { RPCHandler } from '@orpc/server/node' +import * as sharedModule from '@orpc/shared' import request from 'supertest' import { StaticFileHandlerPlugin } from './static-file-handler-plugin' @@ -45,6 +49,10 @@ describe('staticFileHandlerPlugin', () => { writeFileSync(path.join(rootDir, 'compressed.bin'), 'binary identity') writeFileSync(path.join(rootDir, 'compressed.bin.gz'), gzipSync('binary gzip')) + // A directory where a sidecar would be, so the candidate resolves but is not a file + writeFileSync(path.join(rootDir, 'dir-sidecar.txt'), 'identity content') + mkdirSync(path.join(rootDir, 'dir-sidecar.txt.br')) + const res = await createStaticAgent().get('/hello.txt') helloEtag = res.headers.etag! helloLastModified = res.headers['last-modified']! @@ -73,6 +81,45 @@ describe('staticFileHandlerPlugin', () => { return createAgent(handler, handleOptions) } + /** + * supertest and fetch both normalize the request target with the WHATWG url parser, + * which resolves dot segments and collapses leading slashes before the server sees them. + * These attacks only reach the plugin through a client that sends the target verbatim. + */ + async function createRawClient(pluginOptions: Partial = {}) { + const handler = new RPCHandler({}, { + plugins: [new StaticFileHandlerPlugin({ rootDir, ...pluginOptions })], + }) + + const server = createServer(async (req, res) => { + const result = await handler.handle(req, res, { context: {} }) + + if (!result.matched) { + res.statusCode = 404 + res.end('not matched') + } + }) + + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() as AddressInfo + + return { + close: () => { + server.close() + }, + get: (target: string) => new Promise<{ status: number | undefined, location: string | undefined, body: string }>((resolve, reject) => { + const req = httpRequest({ host: '127.0.0.1', port, path: target }, (res) => { + let body = '' + res.setEncoding('utf8') + res.on('data', chunk => body += chunk) + res.on('end', () => resolve({ status: res.statusCode, location: res.headers.location, body })) + }) + req.on('error', reject) + req.end() + }), + } + } + it('serves a file with standard headers', async () => { const res = await createStaticAgent().get('/hello.txt') @@ -82,7 +129,7 @@ describe('staticFileHandlerPlugin', () => { expect(res.headers['content-length']).toBe('11') expect(res.headers['cache-control']).toBe('public, max-age=0') expect(res.headers['accept-ranges']).toBe('bytes') - expect(res.headers.etag).toMatch(/^W\/"[0-9a-f]+-[0-9a-f]+"$/) + expect(res.headers.etag).toMatch(/^"[0-9a-f]+-[0-9a-f]+"$/) expect(res.headers['last-modified']).toBe(statSync(path.join(rootDir, 'hello.txt')).mtime.toUTCString()) // Guards against a Blob body, which would make the adapter attach a content-disposition expect(res.headers['content-disposition']).toBeUndefined() @@ -172,7 +219,7 @@ describe('staticFileHandlerPlugin', () => { expect(res.text).toBeUndefined() expect(res.headers['content-type']).toBe('text/plain; charset=utf-8') expect(res.headers['content-length']).toBe('11') - expect(res.headers.etag).toMatch(/^W\//) + expect(res.headers.etag).toMatch(/^"/) }) it('ignores range headers', async () => { @@ -204,6 +251,13 @@ describe('staticFileHandlerPlugin', () => { expect(wildcardRes.status).toBe(304) }) + it('responds 304 when if-none-match carries the weak form of the etag', async () => { + // If-None-Match uses the weak comparison function, and caches may add the W/ prefix + const res = await createStaticAgent().get('/hello.txt').set('if-none-match', `W/${helloEtag}`) + + expect(res.status).toBe(304) + }) + it('responds 200 when if-none-match does not match', async () => { const res = await createStaticAgent().get('/hello.txt').set('if-none-match', '"different"') @@ -230,6 +284,25 @@ describe('staticFileHandlerPlugin', () => { expect(res.status).toBe(200) }) + it('responds 412 when if-match or if-unmodified-since fails', async () => { + const agent = createStaticAgent() + + expect((await agent.get('/hello.txt').set('if-match', '"nope"')).status).toBe(412) + expect((await agent.get('/hello.txt').set('if-match', helloEtag)).status).toBe(200) + expect((await agent.get('/hello.txt').set('if-match', '*')).status).toBe(200) + expect((await agent.get('/hello.txt').set('if-match', `"other", ${helloEtag}`)).status).toBe(200) + + const past = new Date(Date.now() - 100_000_000).toUTCString() + expect((await agent.get('/hello.txt').set('if-unmodified-since', past)).status).toBe(412) + + const future = new Date(Date.now() + 100_000).toUTCString() + expect((await agent.get('/hello.txt').set('if-unmodified-since', future)).status).toBe(200) + + // An unparsable date and an empty list are malformed, so both are ignored + expect((await agent.get('/hello.txt').set('if-unmodified-since', 'not a date')).status).toBe(200) + expect((await agent.get('/hello.txt').set('if-match', ' ')).status).toBe(200) + }) + it('revalidates to 304 even when the client sends cache-control no-cache, like fetch does', async () => { const res = await createStaticAgent().get('/hello.txt').set('if-none-match', helloEtag).set('cache-control', 'no-cache') @@ -302,6 +375,46 @@ describe('staticFileHandlerPlugin', () => { expect(res.status).toBe(206) }) + + it('applies the range when if-range matches the etag and ignores it otherwise', async () => { + const agent = createStaticAgent() + const etag = (await agent.get('/data.bin')).headers.etag! + + const matching = await agent.get('/data.bin').set('range', 'bytes=0-3').set('if-range', etag) + expect(matching.status).toBe(206) + expect(matching.headers['content-range']).toBe('bytes 0-3/10') + + const mismatching = await agent.get('/data.bin').set('range', 'bytes=0-3').set('if-range', '"other"') + expect(mismatching.status).toBe(200) + expect(mismatching.headers['content-length']).toBe('10') + + // A weak tag can never satisfy the strong comparison if-range requires + const weak = await agent.get('/data.bin').set('range', 'bytes=0-3').set('if-range', `W/${etag}`) + expect(weak.status).toBe(200) + }) + + it('responds 416 for a zero length suffix range and for any suffix range on an empty file', async () => { + const agent = createStaticAgent() + + const zeroSuffixRes = await agent.get('/data.bin').set('range', 'bytes=-0') + expect(zeroSuffixRes.status).toBe(416) + expect(zeroSuffixRes.headers['content-range']).toBe('bytes */10') + + const emptyFileRes = await agent.get('/empty.txt').set('range', 'bytes=-3') + expect(emptyFileRes.status).toBe(416) + expect(emptyFileRes.headers['content-range']).toBe('bytes */0') + }) + + it('accepts whitespace and uppercase in the range unit', async () => { + const agent = createStaticAgent() + + const spacedRes = await agent.get('/data.bin').set('range', 'bytes= 0-3') + expect(spacedRes.status).toBe(206) + expect(spacedRes.headers['content-range']).toBe('bytes 0-3/10') + + const upperRes = await agent.get('/data.bin').set('range', 'BYTES=0-3') + expect(upperRes.status).toBe(206) + }) }) describe('directories and index files', () => { @@ -327,6 +440,33 @@ describe('staticFileHandlerPlugin', () => { expect(res.headers.location).toBe('/nested/?foo=bar') }) + it('applies the cache policy to the directory redirect', async () => { + const res = await createStaticAgent().get('/nested') + expect(res.status).toBe(301) + expect(res.headers['cache-control']).toBe('public, max-age=0') + + const disabledRes = await createStaticAgent({ cacheControl: false }).get('/nested') + expect(disabledRes.status).toBe(301) + expect(disabledRes.headers['cache-control']).toBeUndefined() + }) + + it('does not serve a file for a url with a trailing slash', async () => { + const res = await createStaticAgent().get('/hello.txt/') + + expect(res.status).toBe(404) + + // With a fallback configured the url is treated like any other unmatched route + const fallbackRes = await createStaticAgent({ fallbackFile: 'index.html' }).get('/hello.txt/') + expect(fallbackRes.status).toBe(200) + expect(fallbackRes.text).toBe('

home

') + }) + + it('falls through instead of redirecting when index files are disabled', async () => { + const res = await createStaticAgent({ indexFile: false }).get('/nested') + + expect(res.status).toBe(404) + }) + it('falls through when the directory has no index file', async () => { const res = await createStaticAgent().get('/no-index/') @@ -362,6 +502,15 @@ describe('staticFileHandlerPlugin', () => { '/nested/%2e%2e/%2e%2e/secret.txt', '/foo%5c..%5cbar.txt', '/foo%2fbar.txt', + '/....//secret.txt', + '/..;/secret.txt', + '/..%00/secret.txt', + '/%c0%ae%c0%ae/secret.txt', + '/..%5csecret.txt', + '/%2e%2e%5csecret.txt', + '/..%252fsecret.txt', + '/.%2e/secret.txt', + '/./../secret.txt', ] for (const url of urls) { @@ -371,6 +520,14 @@ describe('staticFileHandlerPlugin', () => { } }) + it('blocks directory traversal when dotfiles are enabled', async () => { + const agent = createStaticAgent({ dotfiles: true }) + + for (const url of ['/../secret.txt', '/..%c0%af/secret.txt', '/..%c0%afsecret.txt', '/..%2fsecret.txt']) { + expect((await agent.get(url)).status, url).toBe(404) + } + }) + it('resolves dot segments within the root instead of following them', async () => { const agent = createStaticAgent() @@ -383,12 +540,126 @@ describe('staticFileHandlerPlugin', () => { expect(clamped.text).toBe('hello world') }) - it('never serves index or fallback files that escape the root', async () => { - const indexRes = await createStaticAgent({ indexFile: '../secret.txt' }).get('/') - expect(indexRes.status).toBe(404) + it('clamps dot segments sent verbatim by a client that does not normalize them', async ({ onTestFinished }) => { + const client = await createRawClient() + onTestFinished(() => client.close()) + + const inside = await client.get('/nested/../hello.txt') + expect(inside.status).toBe(200) + expect(inside.body).toBe('hello world') - const fallbackRes = await createStaticAgent({ fallbackFile: '../secret.txt' }).get('/missing.txt') - expect(fallbackRes.status).toBe(404) + const clamped = await client.get('/../../../hello.txt') + expect(clamped.status).toBe(200) + expect(clamped.body).toBe('hello world') + + // secret.txt really exists one directory above the root, so this asserts a blocked escape + const traversed = await client.get('/nested/../../secret.txt') + expect(traversed.status).toBe(404) + expect(traversed.body).toBe('not matched') + }) + + it('clamps dot segments at the mounted path', async ({ onTestFinished }) => { + const client = await createRawClient({ path: '/assets' }) + onTestFinished(() => client.close()) + + const clamped = await client.get('/assets/../../hello.txt') + expect(clamped.status).toBe(200) + expect(clamped.body).toBe('hello world') + }) + + it('never redirects to a protocol relative location', async ({ onTestFinished }) => { + const client = await createRawClient() + onTestFinished(() => client.close()) + + // Without rebuilding the location these resolve to http://attacker.example + const rootRes = await client.get('//attacker.example/..') + expect(rootRes.status).toBe(301) + expect(rootRes.location).toBe('/') + + const nestedRes = await client.get('//attacker.example/../nested') + expect(nestedRes.status).toBe(301) + expect(nestedRes.location).toBe('/nested/') + }) + + it('never serves index or fallback files that escape the root', async ({ onTestFinished }) => { + const siblingDir = `${rootDir}-secret` + mkdirSync(siblingDir, { recursive: true }) + writeFileSync(path.join(siblingDir, 'x.txt'), 'sibling secret') + onTestFinished(() => rmSync(siblingDir, { recursive: true, force: true })) + + // The second form would escape if the root prefix lost its trailing separator + for (const file of ['../secret.txt', `../${path.basename(siblingDir)}/x.txt`]) { + expect((await createStaticAgent({ indexFile: file }).get('/')).status, file).toBe(404) + expect((await createStaticAgent({ fallbackFile: file }).get('/missing.txt')).status, file).toBe(404) + } + + // A path that leaves and re-enters the root is still served + const inside = await createStaticAgent({ fallbackFile: 'nested/../hello.txt' }).get('/missing.txt') + expect(inside.status).toBe(200) + }) + + it('serves configured index and fallback files even when they are dotfiles', async () => { + expect((await createStaticAgent({ indexFile: '.secret' }).get('/')).status).toBe(200) + expect((await createStaticAgent({ fallbackFile: '.secret' }).get('/missing')).status).toBe(200) + + // Request paths are still blocked + expect((await createStaticAgent({ fallbackFile: 'index.html' }).get('/.secret')).status).toBe(404) + }) + + it('never follows symlinks that leave the root', async ({ onTestFinished }) => { + const outsideDir = path.join(baseDir, 'outside-dir') + mkdirSync(outsideDir, { recursive: true }) + writeFileSync(path.join(outsideDir, 'file.txt'), 'outside dir') + symlinkSync(path.join(baseDir, 'secret.txt'), path.join(rootDir, 'link.txt')) + symlinkSync(baseDir, path.join(rootDir, 'up')) + symlinkSync(outsideDir, path.join(rootDir, 'linkdir')) + symlinkSync(path.join(rootDir, '.secret'), path.join(rootDir, 'notdot.txt')) + onTestFinished(() => { + for (const name of ['link.txt', 'up', 'linkdir', 'notdot.txt']) { + rmSync(path.join(rootDir, name), { force: true }) + } + rmSync(outsideDir, { recursive: true, force: true }) + }) + + const agent = createStaticAgent() + + for (const url of ['/link.txt', '/up/secret.txt', '/linkdir/file.txt']) { + expect((await agent.get(url)).status, url).toBe(404) + } + + const allowedRes = await createStaticAgent({ allowSymlinks: true }).get('/link.txt') + expect(allowedRes.status).toBe(200) + expect(allowedRes.text).toBe('outside root') + + // Dotfile hiding is a url policy, so a link inside the root to a dotfile still resolves + expect((await agent.get('/notdot.txt')).text).toBe('dotfile') + }) + + it('never serves a precompressed sidecar that leaves the root', async ({ onTestFinished }) => { + writeFileSync(path.join(baseDir, 'evil.gz'), gzipSync('outside gzip')) + writeFileSync(path.join(rootDir, 'sidecar.txt'), 'identity content') + symlinkSync(path.join(baseDir, 'evil.gz'), path.join(rootDir, 'sidecar.txt.gz')) + onTestFinished(() => rmSync(path.join(rootDir, 'sidecar.txt.gz'), { force: true })) + + const res = await createStaticAgent({ precompressed: true }).get('/sidecar.txt').set('accept-encoding', 'gzip') + + expect(res.status).toBe(200) + expect(res.headers['content-encoding']).toBeUndefined() + expect(res.text).toBe('identity content') + }) + + it('serves files when rootDir itself is a symlink', async ({ onTestFinished }) => { + const linkedRoot = path.join(baseDir, 'rootlink') + symlinkSync(rootDir, linkedRoot) + onTestFinished(() => rmSync(linkedRoot, { force: true })) + + expect((await createStaticAgent({ rootDir: linkedRoot }).get('/hello.txt')).status).toBe(200) + }) + + it('falls through when rootDir does not exist', async () => { + const res = await createStaticAgent({ rootDir: path.join(baseDir, 'nope') }).get('/hello.txt') + + expect(res.status).toBe(404) }) it('rejects paths containing null bytes', async () => { @@ -446,6 +717,25 @@ describe('staticFileHandlerPlugin', () => { expect(res.status).toBe(301) expect(res.headers.location).toBe('/assets/') }) + + it('normalizes a trailing slash in the path option', async () => { + const agent = createStaticAgent({ path: '/assets/' }) + + const res = await agent.get('/assets/hello.txt') + expect(res.status).toBe(200) + expect(res.text).toBe('hello world') + + const redirectRes = await agent.get('/assets') + expect(redirectRes.status).toBe(301) + expect(redirectRes.headers.location).toBe('/assets/') + }) + + it.skipIf(process.platform === 'win32')('supports a rootDir that is the filesystem root', async () => { + const res = await createStaticAgent({ rootDir: path.parse(rootDir).root }).get(path.join(rootDir, 'hello.txt')) + + expect(res.status).toBe(200) + expect(res.text).toBe('hello world') + }) }) describe('precompressed', () => { @@ -461,7 +751,7 @@ describe('staticFileHandlerPlugin', () => { expect(res.text).toBe('gzip content') }) - it('prefers brotli over gzip and ignores q-value parameters', async () => { + it('prefers brotli over gzip', async () => { const res = await createStaticAgent({ precompressed: true }) .get('/compressed.txt') .set('accept-encoding', 'gzip;q=0.8, br;q=0.9') @@ -492,6 +782,75 @@ describe('staticFileHandlerPlugin', () => { expect(res.text).toBe('identity content') }) + it('honors accept-encoding q-values', async () => { + const agent = createStaticAgent({ precompressed: true }) + + const rejectedRes = await agent.get('/compressed.txt').set('accept-encoding', 'gzip;q=0') + expect(rejectedRes.headers['content-encoding']).toBeUndefined() + expect(rejectedRes.text).toBe('identity content') + + const wildcardRes = await agent.get('/compressed.txt').set('accept-encoding', '*') + expect(wildcardRes.headers['content-encoding']).toBe('br') + + // An empty list element is skipped rather than matching an empty coding + const emptyElementRes = await agent.get('/compressed.txt').set('accept-encoding', 'gzip,,') + expect(emptyElementRes.headers['content-encoding']).toBe('gzip') + }) + + it('lets an explicit q-value take precedence over the wildcard', async () => { + const agent = createStaticAgent({ precompressed: true }) + + const rejectedRes = await agent.get('/compressed.txt').set('accept-encoding', 'br;q=0, *') + expect(rejectedRes.headers['content-encoding']).toBe('gzip') + + const allRejectedRes = await agent.get('/compressed.txt').set('accept-encoding', '*, br;q=0, gzip;q=0, zstd;q=0') + expect(allRejectedRes.headers['content-encoding']).toBeUndefined() + expect(allRejectedRes.text).toBe('identity content') + }) + + it('skips accepted encodings that have no sidecar file', async () => { + const res = await createStaticAgent({ precompressed: true }) + .get('/compressed.txt') + .set('accept-encoding', 'zstd, gzip') + + expect(res.status).toBe(200) + expect(res.headers['content-encoding']).toBe('gzip') + expect(res.headers.vary).toBe('accept-encoding') + expect(res.text).toBe('gzip content') + }) + + it('skips a sidecar path that is not a file', async () => { + const res = await createStaticAgent({ precompressed: true }) + .get('/dir-sidecar.txt') + .set('accept-encoding', 'br') + + expect(res.status).toBe(200) + expect(res.headers['content-encoding']).toBeUndefined() + expect(res.text).toBe('identity content') + }) + + it('serves the identity variant when the file has no sidecars at all', async () => { + const res = await createStaticAgent({ precompressed: true }) + .get('/hello.txt') + .set('accept-encoding', 'br, gzip') + + expect(res.status).toBe(200) + expect(res.headers['content-encoding']).toBeUndefined() + expect(res.headers.vary).toBe('accept-encoding') + expect(res.text).toBe('hello world') + }) + + it('omits content-encoding from a 304', async () => { + const agent = createStaticAgent({ precompressed: true }) + const etag = (await agent.get('/compressed.txt').set('accept-encoding', 'gzip')).headers.etag! + + const res = await agent.get('/compressed.txt').set('accept-encoding', 'gzip').set('if-none-match', etag) + + expect(res.status).toBe(304) + expect(res.headers['content-encoding']).toBeUndefined() + expect(res.headers.vary).toBe('accept-encoding') + }) + it('does not apply to non-compressible content types', async () => { const res = await createStaticAgent({ precompressed: true }) .get('/compressed.bin') @@ -536,6 +895,27 @@ describe('staticFileHandlerPlugin', () => { expect(freshRes.status).toBe(304) }) + describe('opentelemetry', () => { + it('renames the active span to the mounted base path', async ({ onTestFinished }) => { + const span = { updateName: vi.fn(), setAttribute: vi.fn() } + const spy = vi.spyOn(sharedModule, 'getOpenTelemetryConfig').mockReturnValue({ + trace: { getActiveSpan: () => span }, + } as any) + onTestFinished(() => spy.mockRestore()) + + await createStaticAgent().get('/hello.txt') + expect(span.updateName).toHaveBeenLastCalledWith('GET /* (static file)') + + await createStaticAgent({ path: '/assets' }).get('/assets/hello.txt') + expect(span.updateName).toHaveBeenLastCalledWith('GET /assets/* (static file)') + + // The span is left to the handler when no file is served + span.updateName.mockClear() + await createStaticAgent().get('/missing.txt') + expect(span.updateName).not.toHaveBeenCalledWith(expect.stringContaining('static file')) + }) + }) + /** * The plugin is adapter agnostic, these smoke tests only prove the fetch * adapter wiring; the http semantics are covered by the suites above. diff --git a/packages/node/src/static-file-handler-plugin.ts b/packages/node/src/static-file-handler-plugin.ts index 8a03ecc07..e54bdc6ea 100644 --- a/packages/node/src/static-file-handler-plugin.ts +++ b/packages/node/src/static-file-handler-plugin.ts @@ -3,10 +3,10 @@ import type { StandardHandlerOptions, StandardHandlerPlugin, StandardHandlerRout import type { StandardHeaders, StandardLazyRequest, StandardResponse } from '@standardserver/core' import type { Stats } from 'node:fs' import { createReadStream } from 'node:fs' -import { stat } from 'node:fs/promises' +import { realpath, stat } from 'node:fs/promises' import path from 'node:path' import { Readable } from 'node:stream' -import { getOpenTelemetryConfig, isCompressibleContentType, matchesHttpPathPrefix, mergeHttpPath, parseAcceptEncodings, toArray, tryDecodeURIComponent } from '@orpc/shared' +import { getOpenTelemetryConfig, isCompressibleContentType, matchesHttpPathPrefix, mergeHttpPath, parseAcceptEncodingQualities, toArray, tryDecodeURIComponent } from '@orpc/shared' import { flattenStandardHeader, parseStandardUrl } from '@standardserver/core' const DEFAULT_MIME_TYPES: Record = { @@ -107,6 +107,14 @@ export interface StaticFileHandlerPluginOptions { */ precompressed?: boolean + /** + * Whether symbolic links whose target lies outside `rootDir` can be served. + * Enabling this makes every file the links reach publicly readable. + * + * @default false + */ + allowSymlinks?: boolean + /** * Extra content types keyed by lowercase file extension without the dot, * merged over the built-in mapping. Unknown extensions are served as @@ -121,6 +129,12 @@ export interface StaticFileHandlerPluginOptions { * and directory traversal protection. Files are served as a fallback, * so matched procedures always win. * + * @remarks + * Deliberate deviations, all matching `send`, nginx, and Hono: range handling applies to `GET` + * only, so `HEAD` always reports the full length even though `Accept-Ranges` advertises support; + * a suffix range against an empty file answers `416`; and `dotfiles` is a request-path policy, + * so it never applies to a configured `indexFile` or `fallbackFile`. + * * @see {@link https://orpc.dev/docs/plugins/static-file | Static File Plugin} */ export class StaticFileHandlerPlugin implements StandardHandlerPlugin { @@ -132,26 +146,36 @@ export class StaticFileHandlerPlugin implements StandardHandl after = ['~opentelemetry'] private readonly rootDir: string - /** `rootDir` with a trailing separator, precomputed for the containment check. */ + /** `rootDir` with a trailing separator, precomputed for the lexical containment check. */ private readonly rootDirPrefix: string + /** + * `rootDir` with its own symbolic links resolved, paired with its trailing separator form. + * Resolved once, so a symlinked `rootDir` keeps working and platform links like the macOS + * `/var` to `/private/var` do not reject every file. + */ + private readonly rootDirRealPaths: Promise<[rootDirReal: string, rootDirRealPrefix: string]> private readonly path: Exclude private readonly indexFile: Exclude private readonly fallbackFile: StaticFileHandlerPluginOptions['fallbackFile'] private readonly cacheControl: Exclude private readonly dotfiles: Exclude private readonly precompressed: Exclude + private readonly allowSymlinks: Exclude private readonly mimeTypes: Exclude constructor(options: StaticFileHandlerPluginOptions) { this.rootDir = path.resolve(options.rootDir) this.rootDirPrefix = this.rootDir.endsWith(path.sep) ? this.rootDir : this.rootDir + path.sep - const basePath = options.path ?? '/' - this.path = basePath.length > 1 && basePath.endsWith('/') ? basePath.slice(0, -1) as `/${string}` : basePath + this.rootDirRealPaths = realpath(this.rootDir) + .catch(() => this.rootDir) + .then(rootDirReal => [rootDirReal, rootDirReal.endsWith(path.sep) ? rootDirReal : rootDirReal + path.sep]) + this.path = stripTrailingSlash(options.path ?? '/') this.indexFile = options.indexFile ?? 'index.html' this.fallbackFile = options.fallbackFile this.cacheControl = options.cacheControl ?? 'public, max-age=0' this.dotfiles = options.dotfiles ?? false this.precompressed = options.precompressed ?? false + this.allowSymlinks = options.allowSymlinks ?? false // A null prototype prevents extensions like "constructor" from resolving through the prototype chain this.mimeTypes = Object.assign(Object.create(null) as Record, DEFAULT_MIME_TYPES, options.mimeTypes) } @@ -201,18 +225,29 @@ export class StaticFileHandlerPlugin implements StandardHandl return resolved } - private resolveBasePath(prefix: `/${string}` | undefined): `/${string}` { - if (prefix === undefined) { - return this.path + /** + * Resolves symbolic links and confirms the target is still inside the root, + * which the lexical `resolveWithinRoot` check cannot see. + */ + private async resolveContainedRealPath(filePath: string): Promise { + if (this.allowSymlinks) { + return filePath } - const base = mergeHttpPath(prefix, this.path) - return base.length > 1 && base.endsWith('/') ? base.slice(0, -1) as `/${string}` : base + const [rootDirReal, rootDirRealPrefix] = await this.rootDirRealPaths + // An empty string is contained by no root, so an unresolvable path is simply not contained + const realPath = await realpath(filePath).catch(() => '') + + return realPath === rootDirReal || realPath.startsWith(rootDirRealPrefix) ? realPath : undefined + } + + private resolveBasePath(prefix: `/${string}` | undefined): `/${string}` { + return prefix === undefined ? this.path : stripTrailingSlash(mergeHttpPath(prefix, this.path)) } /** - * Resolves segments under `rootDir` and stats the result, - * `resolveWithinRoot` guarantees nothing outside the root is ever touched. + * Resolves segments under `rootDir` and stats the result, rejecting anything that + * escapes the root either lexically or by following a symbolic link. */ private async lookup(segments: string[]): Promise<[filePath: string, stats: Stats] | undefined> { const filePath = this.resolveWithinRoot(segments) @@ -221,18 +256,26 @@ export class StaticFileHandlerPlugin implements StandardHandl return undefined } - const stats = await stat(filePath).catch(() => undefined) - return stats === undefined ? undefined : [filePath, stats] - } - - private async serve(request: StandardLazyRequest, base: `/${string}`): Promise { - const [pathname, search] = parseStandardUrl(request.url) + // Both resolutions are always needed, so they run together and cost the latency of one + const [stats, containedRealPath] = await Promise.all([ + stat(filePath).catch(() => undefined), + this.resolveContainedRealPath(filePath), + ]) - if (base !== '/' && !matchesHttpPathPrefix(pathname, base)) { + if (stats === undefined || containedRealPath === undefined) { return undefined } + return [filePath, stats] + } + + /** + * Normalizes the url path into filesystem segments, + * returning `undefined` when the request can never map to a servable file. + */ + private resolveSegments(pathname: string, base: `/${string}`): string[] | undefined { const segments: string[] = [] + for (const rawSegment of pathname.slice(base.length).split('/')) { const segment = rawSegment.includes('%') ? tryDecodeURIComponent(rawSegment) : rawSegment @@ -257,18 +300,51 @@ export class StaticFileHandlerPlugin implements StandardHandl segments.push(segment) } - let found = await this.lookup(segments) + return segments + } - if (found?.[1].isDirectory()) { - if (this.indexFile === false) { - found = undefined - } - else if (!pathname.endsWith('/')) { - // Redirect so relative links inside the index file resolve correctly - return { status: 301, headers: { location: `${pathname}/${search ?? ''}` } } - } - else { - found = await this.lookup([...segments, this.indexFile]) + private async serve(request: StandardLazyRequest, base: `/${string}`): Promise { + const [pathname, search] = parseStandardUrl(request.url) + + if (base !== '/' && !matchesHttpPathPrefix(pathname, base)) { + return undefined + } + + const segments = this.resolveSegments(pathname, base) + + if (segments === undefined) { + return undefined + } + + let found: [filePath: string, stats: Stats] | undefined + + if (pathname.endsWith('/')) { + // The url already denotes a directory, so only its index file can be served + found = this.indexFile === false ? undefined : await this.lookup([...segments, this.indexFile]) + } + else { + found = await this.lookup(segments) + + if (found?.[1].isDirectory()) { + if (this.indexFile === false) { + found = undefined + } + else { + /** + * Rebuilt from the normalized segments rather than echoing the request target, + * so the location can never be protocol relative or carry dot segments. + * Redirected so relative links inside the index file resolve correctly. + */ + const location = `${base === '/' ? '' : base}${segments.map(segment => `/${encodeURIComponent(segment)}`).join('')}/` + + return { + status: 301, + headers: { + location: `${location}${search ?? ''}`, + ...this.cacheControl === false ? {} : { 'cache-control': this.cacheControl }, + }, + } + } } } @@ -292,29 +368,42 @@ export class StaticFileHandlerPlugin implements StandardHandl let contentEncoding: string | undefined if (negotiatesEncoding) { - const acceptedEncodings = new Set(parseAcceptEncodings(flattenStandardHeader(request.headers['accept-encoding']))) - const candidates = PRECOMPRESSED_ENCODINGS.filter(([encoding]) => acceptedEncodings.has(encoding)) - const candidateStats = await Promise.all(candidates.map(([, extension]) => stat(filePath + extension).catch(() => undefined))) - - for (let i = 0; i < candidates.length; i++) { - if (candidateStats[i]?.isFile()) { - filePath += candidates[i]![1] - stats = candidateStats[i]! - contentEncoding = candidates[i]![0] - break - } + const qualities = parseAcceptEncodingQualities(flattenStandardHeader(request.headers['accept-encoding'])) + + const variants = await Promise.all(PRECOMPRESSED_ENCODINGS + // An explicit q-value takes precedence over the wildcard, so `br;q=0, *` never serves brotli + .filter(([encoding]) => (qualities.get(encoding) ?? qualities.get('*') ?? 0) > 0) + .map(async ([encoding, extension]) => { + const candidatePath = filePath + extension + // Sidecars are reached by string concatenation, so they need the same containment check + const [candidateStats, containedRealPath] = await Promise.all([ + stat(candidatePath).catch(() => undefined), + this.resolveContainedRealPath(candidatePath), + ]) + + return candidateStats?.isFile() && containedRealPath !== undefined + ? { encoding, extension, stats: candidateStats } + : undefined + })) + + const variant = variants.find(variant => variant !== undefined) + + if (variant !== undefined) { + filePath += variant.extension + stats = variant.stats + contentEncoding = variant.encoding } } const size = stats.size - const etag = `W/"${size.toString(16)}-${stats.mtime.getTime().toString(16)}"` - const lastModified = stats.mtime.toUTCString() + // Size and mtime at millisecond resolution, a strictly finer validator than the one nginx treats as strong + const etag = `"${size.toString(16)}-${stats.mtime.getTime().toString(16)}"` // Truncated to seconds, matching the precision of http dates const lastModifiedTime = Math.floor(stats.mtime.getTime() / 1000) * 1000 const headers: StandardHeaders = { 'etag': etag, - 'last-modified': lastModified, + 'last-modified': stats.mtime.toUTCString(), 'accept-ranges': 'bytes', } @@ -323,14 +412,14 @@ export class StaticFileHandlerPlugin implements StandardHandl headers.vary = 'accept-encoding' } - if (contentEncoding !== undefined) { - headers['content-encoding'] = contentEncoding - } - if (this.cacheControl !== false) { headers['cache-control'] = this.cacheControl } + if (isPreconditionFailed(request.headers, etag, lastModifiedTime)) { + return { status: 412, headers } + } + if (isRequestFresh(request.headers, etag, lastModifiedTime)) { return { status: 304, headers } } @@ -341,7 +430,7 @@ export class StaticFileHandlerPlugin implements StandardHandl const range = request.method === 'GET' ? flattenStandardHeader(request.headers.range) : undefined - if (range !== undefined && isRangeApplicable(request.headers, lastModifiedTime)) { + if (range !== undefined && isRangeApplicable(request.headers, etag, lastModifiedTime)) { const parsed = parseByteRange(range, size) if (parsed === 'unsatisfiable') { @@ -355,8 +444,13 @@ export class StaticFileHandlerPlugin implements StandardHandl } } + // Representation metadata is kept off the 304 and 416, neither of which carries the representation + if (contentEncoding !== undefined) { + headers['content-encoding'] = contentEncoding + } + headers['content-type'] = contentType - headers['content-length'] = String(end - start + 1) + headers['content-length'] = `${end - start + 1}` /** * A HEAD response uses an empty stream instead of no body, because @@ -370,10 +464,40 @@ export class StaticFileHandlerPlugin implements StandardHandl } } +function stripTrailingSlash(path: `/${string}`): `/${string}` { + return path.length > 1 && path.endsWith('/') ? path.slice(0, -1) as `/${string}` : path +} + function stripWeakEtagPrefix(etag: string): string { return etag.startsWith('W/') ? etag.slice(2) : etag } +/** + * Evaluated before freshness, where `If-Match` takes precedence over `If-Unmodified-Since`. + * + * @see https://www.rfc-editor.org/rfc/rfc9110.html#name-precedence-of-preconditions + */ +function isPreconditionFailed(requestHeaders: StandardHeaders, etag: string, lastModifiedTime: number): boolean { + const ifMatch = flattenStandardHeader(requestHeaders['if-match'])?.trim() + // An empty list is malformed rather than unsatisfiable, so it is ignored + if (ifMatch !== undefined && ifMatch !== '') { + if (ifMatch === '*') { + return false + } + + // If-Match uses the strong comparison function, so a weak tag never matches + return !ifMatch.split(',').some(tag => tag.trim() === etag) + } + + const ifUnmodifiedSince = flattenStandardHeader(requestHeaders['if-unmodified-since']) + if (ifUnmodifiedSince !== undefined) { + const sinceTime = Date.parse(ifUnmodifiedSince) + return !Number.isNaN(sinceTime) && lastModifiedTime > sinceTime + } + + return false +} + /** * A request `Cache-Control: no-cache` directive is deliberately not considered: * it asks for validation with the origin and a `304` is a successful validation. @@ -399,26 +523,24 @@ function isRequestFresh(requestHeaders: StandardHeaders, etag: string, lastModif return false } -function isRangeApplicable(requestHeaders: StandardHeaders, lastModifiedTime: number): boolean { +function isRangeApplicable(requestHeaders: StandardHeaders, etag: string, lastModifiedTime: number): boolean { const ifRange = flattenStandardHeader(requestHeaders['if-range']) if (ifRange === undefined) { return true } - /** - * The etag form requires a strong comparison and generated etags - * are always weak, so it can never match. - */ + // The etag form requires a strong comparison, so a weak tag can never match if (ifRange.startsWith('"') || ifRange.startsWith('W/')) { - return false + return ifRange === etag } return Date.parse(ifRange) === lastModifiedTime } function parseByteRange(range: string, size: number): [start: number, end: number] | 'unsatisfiable' | undefined { - const match = range.match(/^bytes=(\d*)-(\d*)$/) + // The range unit is a case insensitive token and may be followed by optional whitespace + const match = range.match(/^bytes=\s*(\d*)-(\d*)$/i) // Malformed and multi-range headers are ignored, serving the full file if (match === null || (match[1] === '' && match[2] === '')) { diff --git a/packages/node/tests/serves-static-files.test.ts b/packages/node/tests/serves-static-files.test.ts index 3afffde16..497b3022b 100644 --- a/packages/node/tests/serves-static-files.test.ts +++ b/packages/node/tests/serves-static-files.test.ts @@ -43,7 +43,7 @@ it('serves static files', async ({ onTestFinished }) => { expect(fileRes.status).toBe(200) expect(await fileRes.text()).toBe('hello world') expect(fileRes.headers.get('content-type')).toBe('text/plain; charset=utf-8') - expect(fileRes.headers.get('etag')).toMatch(/^W\//) + expect(fileRes.headers.get('etag')).toMatch(/^"/) /** * Regression only reproducible with a real fetch client: it sends diff --git a/packages/server/src/plugins/response-compression.test.ts b/packages/server/src/plugins/response-compression.test.ts index 494061833..acd44e60f 100644 --- a/packages/server/src/plugins/response-compression.test.ts +++ b/packages/server/src/plugins/response-compression.test.ts @@ -93,7 +93,7 @@ describe('responseCompressionHandlerPlugin', () => { expect(response!.headers.get('content-encoding')).toBe('deflate') }) - it('ignores q-values when parsing accept-encoding', async () => { + it('ignores q-value parameters when matching a coding', async () => { const largeText = 'x'.repeat(2000) const handler = new RPCHandler(os.handler(() => largeText), { plugins: [ diff --git a/packages/server/src/plugins/response-compression.ts b/packages/server/src/plugins/response-compression.ts index a1aea8539..d864eacad 100644 --- a/packages/server/src/plugins/response-compression.ts +++ b/packages/server/src/plugins/response-compression.ts @@ -1,7 +1,7 @@ import type { StandardBodyHint } from '@standardserver/core' import type { StandardHandlerOptions, StandardHandlerPlugin, StandardHandlerRoutingInterceptor } from '../adapters/standard' import type { Context } from '../context' -import { isAsyncIteratorObject, isCompressibleContentType, parseAcceptEncodings, stringifyJSON, toArray } from '@orpc/shared' +import { isAsyncIteratorObject, isCompressibleContentType, parseAcceptEncodingQualities, stringifyJSON, toArray } from '@orpc/shared' import { flattenStandardHeader, generateContentDisposition } from '@standardserver/core' // Rough UTF-8 estimate. Mostly ASCII text stays close to 1 byte/char; @@ -71,10 +71,10 @@ export class ResponseCompressionHandlerPlugin implements Stan return result } - const acceptEncodings = parseAcceptEncodings( + const acceptEncodings = parseAcceptEncodingQualities( flattenStandardHeader(interceptorOptions.request.headers['accept-encoding']), ) - const encoding = this.encodings.find(enc => acceptEncodings.includes(enc)) + const encoding = this.encodings.find(enc => (acceptEncodings.get(enc) ?? 0) > 0) if (encoding === undefined) { return result diff --git a/packages/shared/src/http.test.ts b/packages/shared/src/http.test.ts index 5e21707eb..3338712f5 100644 --- a/packages/shared/src/http.test.ts +++ b/packages/shared/src/http.test.ts @@ -4,6 +4,7 @@ import { matchesHttpPathPrefix, mergeHttpPath, normalizeHttpPath, + parseAcceptEncodingQualities, pathToHttpPath, } from './http' @@ -182,3 +183,32 @@ describe('isCompressibleContentType', () => { expect(isCompressibleContentType(`text/plain; ${'a'.repeat(1024)}`)).toBe(false) }) }) + +describe('parseAcceptEncodingQualities', () => { + it('defaults an unqualified coding to 1', () => { + expect(parseAcceptEncodingQualities('gzip, br')).toEqual(new Map([['gzip', 1], ['br', 1]])) + }) + + it('parses q-values, where 0 means the coding is unacceptable', () => { + expect(parseAcceptEncodingQualities('gzip;q=0, br;q=0.8, zstd;q=1.0')).toEqual( + new Map([['gzip', 0], ['br', 0.8], ['zstd', 1]]), + ) + }) + + it('normalizes case and whitespace, and keeps the wildcard under its own key', () => { + expect(parseAcceptEncodingQualities(' GZIP ; Q=0 , * ')).toEqual(new Map([['gzip', 0], ['*', 1]])) + }) + + it('ignores parameters that are not q-values', () => { + expect(parseAcceptEncodingQualities('gzip;a=1;q=0.5, br;a=1')).toEqual(new Map([['gzip', 0.5], ['br', 1]])) + }) + + it('skips empty list elements', () => { + expect(parseAcceptEncodingQualities('gzip,, , br')).toEqual(new Map([['gzip', 1], ['br', 1]])) + }) + + it('returns an empty map for a missing or empty header', () => { + expect(parseAcceptEncodingQualities(undefined)).toEqual(new Map()) + expect(parseAcceptEncodingQualities('')).toEqual(new Map()) + }) +}) diff --git a/packages/shared/src/http.ts b/packages/shared/src/http.ts index e1547992e..ee55126a3 100644 --- a/packages/shared/src/http.ts +++ b/packages/shared/src/http.ts @@ -52,20 +52,32 @@ export function matchesHttpPath(url: `/${string}`, path: `/${string}`): boolean || charAfterPrefix === '#' } +const ACCEPT_ENCODING_QUALITY_REGEX = /^\s*q=([\d.]+)\s*$/i + /** - * Parse Accept-Encoding into coding tokens (q-values ignored; order is client preference). + * Parse Accept-Encoding into each coding's q-value, where `0` means the coding is explicitly + * unacceptable. The `*` wildcard is kept under its own key, so a caller can honour it while + * still letting a specific coding take precedence over it. * * @see https://www.rfc-editor.org/rfc/rfc9110.html#name-accept-encoding */ -export function parseAcceptEncodings(header: string | undefined): string[] { - if (header === undefined) { - return [] +export function parseAcceptEncodingQualities(header: string | undefined): Map { + const qualities = new Map() + + for (const part of header?.split(',') ?? []) { + const [rawCoding, ...params] = part.split(';') + const coding = rawCoding!.trim().toLowerCase() + + if (coding === '') { + continue + } + + const quality = params.map(param => ACCEPT_ENCODING_QUALITY_REGEX.exec(param)?.[1]).find(value => value !== undefined) + + qualities.set(coding, quality === undefined ? 1 : Number(quality)) } - return header - .split(',') - .map(part => part.trim().split(';')[0]!.trim().toLowerCase()) - .filter(Boolean) + return qualities } /** From a238ac8a36461087b4c014bbed8a13b58ec2b609 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Mon, 10 Aug 2026 11:31:01 +0700 Subject: [PATCH 03/14] fix(server): skip compression for partial responses A 206 body is a byte range of the identity representation, so compressing it left Content-Range describing offsets the client never receives, breaking range-resume clients. Also refuses static file dot segments that climb above the served path instead of clamping them, so a proxy in front of the handler cannot disagree with it about which path was requested. --- apps/content/docs/plugins/static-file.mdx | 2 +- .../src/static-file-handler-plugin.test.ts | 23 +++++--- .../node/src/static-file-handler-plugin.ts | 10 +++- .../src/plugins/response-compression.test.ts | 53 +++++++++++++++++++ .../src/plugins/response-compression.ts | 8 +++ 5 files changed, 86 insertions(+), 10 deletions(-) diff --git a/apps/content/docs/plugins/static-file.mdx b/apps/content/docs/plugins/static-file.mdx index 8b350d00a..7d9a31f00 100644 --- a/apps/content/docs/plugins/static-file.mdx +++ b/apps/content/docs/plugins/static-file.mdx @@ -17,7 +17,7 @@ After routing, when no procedure matches a GET or HEAD request, the plugin maps Every file response carries an [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) header, so clients sending `If-None-Match` or `If-Modified-Since` receive `304 Not Modified` when the file is unchanged, and `If-Match` or `If-Unmodified-Since` receive `412 Precondition Failed`. Single [range requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Range_requests) are answered with `206 Partial Content`, which enables media seeking and resumable downloads. -Dot segments like `..` are resolved in URL space and clamped at the served directory, following the [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986#section-5.2.4) normalization browsers and proxies apply, and the resolved path is checked against `rootDir` again before any file is opened. Symbolic links whose target leaves `rootDir` are refused unless `allowSymlinks` is set. Dotfiles are treated as not found unless explicitly enabled, which is a request-path policy, so it does not apply to a configured `indexFile` or `fallbackFile`. +Dot segments like `..` are resolved in URL space, following the [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986#section-5.2.4) normalization browsers and proxies apply. One that would climb above the served path is refused rather than clamped, so a proxy in front of the handler can never disagree with it about which path was requested, and the resolved path is checked against `rootDir` again before any file is opened. Symbolic links whose target leaves `rootDir` are refused unless `allowSymlinks` is set. Dotfiles are treated as not found unless explicitly enabled, which is a request-path policy, so it does not apply to a configured `indexFile` or `fallbackFile`. :::warning `rootDir` should contain only files you intend to make public. Anything reachable inside it is served, including a file a request happens to name. diff --git a/packages/node/src/static-file-handler-plugin.test.ts b/packages/node/src/static-file-handler-plugin.test.ts index 544362574..e1254c586 100644 --- a/packages/node/src/static-file-handler-plugin.test.ts +++ b/packages/node/src/static-file-handler-plugin.test.ts @@ -540,7 +540,7 @@ describe('staticFileHandlerPlugin', () => { expect(clamped.text).toBe('hello world') }) - it('clamps dot segments sent verbatim by a client that does not normalize them', async ({ onTestFinished }) => { + it('resolves dot segments sent verbatim by a client that does not normalize them', async ({ onTestFinished }) => { const client = await createRawClient() onTestFinished(() => client.close()) @@ -548,9 +548,11 @@ describe('staticFileHandlerPlugin', () => { expect(inside.status).toBe(200) expect(inside.body).toBe('hello world') - const clamped = await client.get('/../../../hello.txt') - expect(clamped.status).toBe(200) - expect(clamped.body).toBe('hello world') + // Climbing above the served path is refused rather than clamped, so a proxy in front of + // this plugin can never disagree with it about which path was requested + const above = await client.get('/../../../hello.txt') + expect(above.status).toBe(404) + expect(above.body).toBe('not matched') // secret.txt really exists one directory above the root, so this asserts a blocked escape const traversed = await client.get('/nested/../../secret.txt') @@ -558,13 +560,18 @@ describe('staticFileHandlerPlugin', () => { expect(traversed.body).toBe('not matched') }) - it('clamps dot segments at the mounted path', async ({ onTestFinished }) => { + it('refuses dot segments that climb above the mounted path', async ({ onTestFinished }) => { const client = await createRawClient({ path: '/assets' }) onTestFinished(() => client.close()) - const clamped = await client.get('/assets/../../hello.txt') - expect(clamped.status).toBe(200) - expect(clamped.body).toBe('hello world') + const above = await client.get('/assets/../../hello.txt') + expect(above.status).toBe(404) + expect(above.body).toBe('not matched') + + // Dot segments that stay within the mount are still resolved + const inside = await client.get('/assets/nested/../hello.txt') + expect(inside.status).toBe(200) + expect(inside.body).toBe('hello world') }) it('never redirects to a protocol relative location', async ({ onTestFinished }) => { diff --git a/packages/node/src/static-file-handler-plugin.ts b/packages/node/src/static-file-handler-plugin.ts index e54bdc6ea..27015b3f4 100644 --- a/packages/node/src/static-file-handler-plugin.ts +++ b/packages/node/src/static-file-handler-plugin.ts @@ -283,8 +283,16 @@ export class StaticFileHandlerPlugin implements StandardHandl continue } - // Dot segments are resolved in url space and clamped at the root, so they can never escape it + /** + * Dot segments are resolved in url space. One that would climb above the served path is + * refused rather than clamped, so the path this plugin resolves always matches the one a + * proxy in front of it sees after its own normalization. + */ if (segment === '..') { + if (segments.length === 0) { + return undefined + } + segments.pop() continue } diff --git a/packages/server/src/plugins/response-compression.test.ts b/packages/server/src/plugins/response-compression.test.ts index acd44e60f..a1f223058 100644 --- a/packages/server/src/plugins/response-compression.test.ts +++ b/packages/server/src/plugins/response-compression.test.ts @@ -238,6 +238,59 @@ describe('responseCompressionHandlerPlugin', () => { }) }) + describe('partial responses', () => { + it.each([ + ['a 206 status', 206, {}], + ['a content-range header', 200, { 'content-range': 'bytes 0-1999/4000' }], + ])('does not compress a response with %s', async (_label, status, extraHeaders) => { + const largeText = 'x'.repeat(2000) + const handler = new RPCHandler(os.handler(() => largeText), { + plugins: [ + { + name: 'set-partial-response', + init(options) { + return { + ...options, + routingInterceptors: [ + async ({ next, ...interceptorOptions }) => { + const result = await next(interceptorOptions) + if (!result.matched) { + return result + } + return { + ...result, + response: { + ...result.response, + status, + headers: { ...result.response.headers, ...extraHeaders }, + }, + } + }, + ...options.routingInterceptors ?? [], + ], + } + }, + }, + new ResponseCompressionHandlerPlugin({ threshold: 100 }), + ], + }) + + const { matched, response } = await handler.handle(new Request('http://localhost', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'accept-encoding': 'gzip', + }, + body: JSON.stringify({ json: null }), + })) + + expect(matched).toBe(true) + // Compressing would leave content-range describing offsets the client never receives + expect(response!.headers.has('content-encoding')).toBe(false) + await expect(response!.json()).resolves.toEqual({ json: largeText }) + }) + }) + describe('json body', () => { it.each( ['gzip', 'deflate', 'deflate-raw'] as const, diff --git a/packages/server/src/plugins/response-compression.ts b/packages/server/src/plugins/response-compression.ts index d864eacad..31c09e886 100644 --- a/packages/server/src/plugins/response-compression.ts +++ b/packages/server/src/plugins/response-compression.ts @@ -66,6 +66,14 @@ export class ResponseCompressionHandlerPlugin implements Stan return result } + /** + * A partial response body is a byte range of the identity representation, so compressing it + * would leave `Content-Range` describing offsets the client never receives. + */ + if (response.status === 206 || response.headers['content-range'] !== undefined) { + return result + } + // Cache-Control: no-transform forbids intermediaries (and this plugin) from transforming the body if (isNoTransformCacheControl(flattenStandardHeader(response.headers['cache-control']))) { return result From 42cf198d1c541b48477cf6d4abc165e7e2b0ddfb Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Mon, 10 Aug 2026 13:41:32 +0700 Subject: [PATCH 04/14] refactor(node): detect static file content types with mime Replaces a hand-maintained 38-entry table with the mime package, so extensions like .vtt, .m3u8, .ics, .tar and the office formats resolve instead of falling back to application/octet-stream. mime returns bare types, so text types still get an explicit utf-8 charset rather than leaving the browser to sniff an encoding. --- apps/content/docs/plugins/static-file.mdx | 9 ++- packages/node/package.json | 3 +- .../src/static-file-handler-plugin.test.ts | 20 ++++++ .../node/src/static-file-handler-plugin.ts | 70 ++++++++----------- pnpm-lock.yaml | 10 +++ 5 files changed, 66 insertions(+), 46 deletions(-) diff --git a/apps/content/docs/plugins/static-file.mdx b/apps/content/docs/plugins/static-file.mdx index 7d9a31f00..7d4c312ce 100644 --- a/apps/content/docs/plugins/static-file.mdx +++ b/apps/content/docs/plugins/static-file.mdx @@ -23,6 +23,8 @@ Dot segments like `..` are resolved in URL space, following the [RFC 3986](https `rootDir` should contain only files you intend to make public. Anything reachable inside it is served, including a file a request happens to name. ::: +Content types are detected from the file extension with [mime](https://www.npmjs.com/package/mime), and text types are served as UTF-8 so the browser never has to guess an encoding. Use `mimeTypes` to override or add to the detection. + ## Setup The plugin reads files through the Node.js filesystem API but only interacts with the handler through standard oRPC interfaces, so it works with any handler on a Node.js compatible runtime, whether it uses the [Node HTTP Adapter](/docs/adapters/node-http) or the [Fetch API Adapter](/docs/adapters/fetch-api). @@ -95,9 +97,10 @@ const handler = new RPCHandler(router, { allowSymlinks: false, /** - * Extra content types keyed by lowercase file extension without the dot, - * merged over the built-in mapping. Unknown extensions are served as - * `application/octet-stream`. + * Content types keyed by lowercase file extension without the dot, taking precedence + * over the type detected from the extension. Values are sent verbatim, so a text type + * needs its own charset. Extensions that neither this nor the detection recognises are + * served as `application/octet-stream`. */ mimeTypes: {}, }), diff --git a/packages/node/package.json b/packages/node/package.json index 59c9ce462..acb65a001 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -40,7 +40,8 @@ "dependencies": { "@orpc/server": "workspace:*", "@orpc/shared": "workspace:*", - "@standardserver/core": "^0.7.1" + "@standardserver/core": "^0.7.1", + "mime": "^4.1.0" }, "devDependencies": { "supertest": "^7.2.2" diff --git a/packages/node/src/static-file-handler-plugin.test.ts b/packages/node/src/static-file-handler-plugin.test.ts index e1254c586..9d5f664f9 100644 --- a/packages/node/src/static-file-handler-plugin.test.ts +++ b/packages/node/src/static-file-handler-plugin.test.ts @@ -150,6 +150,26 @@ describe('staticFileHandlerPlugin', () => { expect(res.headers['content-type']).toBe('application/octet-stream') }) + it('detects content types beyond the common web set', async () => { + const agent = createStaticAgent() + + // Types a hand-maintained table tends to miss, and the charset rule applied to each + for (const [name, contentType] of [ + ['captions.vtt', 'text/vtt; charset=utf-8'], + ['playlist.m3u8', 'application/vnd.apple.mpegurl'], + ['calendar.ics', 'text/calendar; charset=utf-8'], + ['favicon.ico', 'image/vnd.microsoft.icon'], + ['archive.tar', 'application/x-tar'], + ['sheet.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'], + ] as const) { + writeFileSync(path.join(rootDir, name), 'x') + + const res = await agent.get(`/${name}`) + expect(res.status, name).toBe(200) + expect(res.headers['content-type'], name).toBe(contentType) + } + }) + it('does not resolve content types through the prototype chain', async () => { writeFileSync(path.join(rootDir, 'file.constructor'), 'x') writeFileSync(path.join(rootDir, 'file.__proto__'), 'x') diff --git a/packages/node/src/static-file-handler-plugin.ts b/packages/node/src/static-file-handler-plugin.ts index 27015b3f4..9fd884526 100644 --- a/packages/node/src/static-file-handler-plugin.ts +++ b/packages/node/src/static-file-handler-plugin.ts @@ -8,45 +8,13 @@ import path from 'node:path' import { Readable } from 'node:stream' import { getOpenTelemetryConfig, isCompressibleContentType, matchesHttpPathPrefix, mergeHttpPath, parseAcceptEncodingQualities, toArray, tryDecodeURIComponent } from '@orpc/shared' import { flattenStandardHeader, parseStandardUrl } from '@standardserver/core' +import mime from 'mime' -const DEFAULT_MIME_TYPES: Record = { - html: 'text/html; charset=utf-8', - htm: 'text/html; charset=utf-8', - css: 'text/css; charset=utf-8', - js: 'text/javascript; charset=utf-8', - mjs: 'text/javascript; charset=utf-8', - cjs: 'text/javascript; charset=utf-8', - json: 'application/json; charset=utf-8', - map: 'application/json; charset=utf-8', - webmanifest: 'application/manifest+json', - txt: 'text/plain; charset=utf-8', - md: 'text/markdown; charset=utf-8', - csv: 'text/csv; charset=utf-8', - xml: 'application/xml', - pdf: 'application/pdf', - wasm: 'application/wasm', - png: 'image/png', - jpg: 'image/jpeg', - jpeg: 'image/jpeg', - gif: 'image/gif', - svg: 'image/svg+xml', - webp: 'image/webp', - avif: 'image/avif', - ico: 'image/x-icon', - bmp: 'image/bmp', - woff: 'font/woff', - woff2: 'font/woff2', - ttf: 'font/ttf', - otf: 'font/otf', - eot: 'application/vnd.ms-fontobject', - mp3: 'audio/mpeg', - wav: 'audio/wav', - ogg: 'audio/ogg', - mp4: 'video/mp4', - webm: 'video/webm', - zip: 'application/zip', - gz: 'application/gzip', -} +/** + * Content types served as utf-8. `mime` returns bare types, but a text response without an + * explicit encoding leaves the browser to sniff one. Mirrors the rule `send` applies. + */ +const UTF8_CONTENT_TYPE_REGEX = /^text\/|^application\/(?:javascript|json)$/ const PRECOMPRESSED_ENCODINGS: [encoding: string, extension: string][] = [ ['br', '.br'], @@ -116,8 +84,9 @@ export interface StaticFileHandlerPluginOptions { allowSymlinks?: boolean /** - * Extra content types keyed by lowercase file extension without the dot, - * merged over the built-in mapping. Unknown extensions are served as + * Content types keyed by lowercase file extension without the dot, taking precedence over + * the type detected from the extension. Values are sent verbatim, so a text type needs its + * own charset. Extensions that neither this nor the detection recognises are served as * `application/octet-stream`. */ mimeTypes?: Record @@ -177,7 +146,7 @@ export class StaticFileHandlerPlugin implements StandardHandl this.precompressed = options.precompressed ?? false this.allowSymlinks = options.allowSymlinks ?? false // A null prototype prevents extensions like "constructor" from resolving through the prototype chain - this.mimeTypes = Object.assign(Object.create(null) as Record, DEFAULT_MIME_TYPES, options.mimeTypes) + this.mimeTypes = Object.assign(Object.create(null) as Record, options.mimeTypes) } init(options: StandardHandlerOptions): StandardHandlerOptions { @@ -241,6 +210,23 @@ export class StaticFileHandlerPlugin implements StandardHandl return realPath === rootDirReal || realPath.startsWith(rootDirRealPrefix) ? realPath : undefined } + private resolveContentType(filePath: string): string { + const extension = path.extname(filePath).slice(1).toLowerCase() + const configured = this.mimeTypes[extension] + + if (configured !== undefined) { + return configured + } + + const contentType = mime.getType(extension) + + if (contentType === null) { + return 'application/octet-stream' + } + + return UTF8_CONTENT_TYPE_REGEX.test(contentType) ? `${contentType}; charset=utf-8` : contentType + } + private resolveBasePath(prefix: `/${string}` | undefined): `/${string}` { return prefix === undefined ? this.path : stripTrailingSlash(mergeHttpPath(prefix, this.path)) } @@ -370,7 +356,7 @@ export class StaticFileHandlerPlugin implements StandardHandl let [filePath, stats] = found - const contentType = this.mimeTypes[path.extname(filePath).slice(1).toLowerCase()] ?? 'application/octet-stream' + const contentType = this.resolveContentType(filePath) const negotiatesEncoding = this.precompressed && isCompressibleContentType(contentType) let contentEncoding: string | undefined diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c424387a..aabaf7b65 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -565,6 +565,9 @@ importers: '@standardserver/core': specifier: ^0.7.1 version: 0.7.1 + mime: + specifier: ^4.1.0 + version: 4.1.0 devDependencies: supertest: specifier: ^7.2.2 @@ -8960,6 +8963,11 @@ packages: engines: {node: '>=4.0.0'} hasBin: true + mime@4.1.0: + resolution: {integrity: sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==} + engines: {node: '>=16'} + hasBin: true + mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -20731,6 +20739,8 @@ snapshots: mime@2.6.0: {} + mime@4.1.0: {} + mimic-fn@2.1.0: {} miniflare@5.20260730.0-alpha: From ea8a5b60bcd17d221d9eb722eda23953df9c7555 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Mon, 10 Aug 2026 13:48:55 +0700 Subject: [PATCH 05/14] fix(node): stop sending a charset with application/json RFC 8259 defines no charset parameter for application/json, so adding one has no effect on a compliant recipient. The charset is now limited to text/*, where http defines no default and the html encoding sniffing algorithm ends at a locale-dependent fallback. --- apps/content/docs/plugins/static-file.mdx | 2 +- .../node/src/static-file-handler-plugin.test.ts | 8 ++++++-- packages/node/src/static-file-handler-plugin.ts | 13 +++++++++---- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/apps/content/docs/plugins/static-file.mdx b/apps/content/docs/plugins/static-file.mdx index 7d4c312ce..d73cc703e 100644 --- a/apps/content/docs/plugins/static-file.mdx +++ b/apps/content/docs/plugins/static-file.mdx @@ -23,7 +23,7 @@ Dot segments like `..` are resolved in URL space, following the [RFC 3986](https `rootDir` should contain only files you intend to make public. Anything reachable inside it is served, including a file a request happens to name. ::: -Content types are detected from the file extension with [mime](https://www.npmjs.com/package/mime), and text types are served as UTF-8 so the browser never has to guess an encoding. Use `mimeTypes` to override or add to the detection. +Content types are detected from the file extension with [mime](https://www.npmjs.com/package/mime). HTTP defines no default charset, and the [HTML encoding sniffing algorithm](https://html.spec.whatwg.org/multipage/parsing.html#determining-the-character-encoding) ends at a locale-dependent fallback, so `text/*` responses are pinned to UTF-8. Use `mimeTypes` to override or add to the detection. ## Setup diff --git a/packages/node/src/static-file-handler-plugin.test.ts b/packages/node/src/static-file-handler-plugin.test.ts index 9d5f664f9..475240d03 100644 --- a/packages/node/src/static-file-handler-plugin.test.ts +++ b/packages/node/src/static-file-handler-plugin.test.ts @@ -153,7 +153,8 @@ describe('staticFileHandlerPlugin', () => { it('detects content types beyond the common web set', async () => { const agent = createStaticAgent() - // Types a hand-maintained table tends to miss, and the charset rule applied to each + // Types a hand-maintained table tends to miss, and the charset rule applied to each. + // json carries no charset, the parameter is undefined for it rather than merely redundant. for (const [name, contentType] of [ ['captions.vtt', 'text/vtt; charset=utf-8'], ['playlist.m3u8', 'application/vnd.apple.mpegurl'], @@ -161,8 +162,11 @@ describe('staticFileHandlerPlugin', () => { ['favicon.ico', 'image/vnd.microsoft.icon'], ['archive.tar', 'application/x-tar'], ['sheet.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'], + ['data.json', 'application/json'], + ['bundle.js.map', 'application/json'], ] as const) { - writeFileSync(path.join(rootDir, name), 'x') + // Valid json, so the test client's own json parser does not choke on the two json cases + writeFileSync(path.join(rootDir, name), '{}') const res = await agent.get(`/${name}`) expect(res.status, name).toBe(200) diff --git a/packages/node/src/static-file-handler-plugin.ts b/packages/node/src/static-file-handler-plugin.ts index 9fd884526..8be830d5e 100644 --- a/packages/node/src/static-file-handler-plugin.ts +++ b/packages/node/src/static-file-handler-plugin.ts @@ -11,10 +11,15 @@ import { flattenStandardHeader, parseStandardUrl } from '@standardserver/core' import mime from 'mime' /** - * Content types served as utf-8. `mime` returns bare types, but a text response without an - * explicit encoding leaves the browser to sniff one. Mirrors the rule `send` applies. + * `mime` returns bare types, and http defines no default charset. A text response without one + * is left to the html encoding sniffing algorithm, whose final fallback is the user's locale, + * so text is pinned to utf-8. Binary types need no charset, and `application/json` is always + * utf-8 by definition, where the parameter is undefined rather than merely redundant. + * + * @see https://www.rfc-editor.org/rfc/rfc8259#section-11 + * @see https://html.spec.whatwg.org/multipage/parsing.html#determining-the-character-encoding */ -const UTF8_CONTENT_TYPE_REGEX = /^text\/|^application\/(?:javascript|json)$/ +const TEXT_CONTENT_TYPE_REGEX = /^text\// const PRECOMPRESSED_ENCODINGS: [encoding: string, extension: string][] = [ ['br', '.br'], @@ -224,7 +229,7 @@ export class StaticFileHandlerPlugin implements StandardHandl return 'application/octet-stream' } - return UTF8_CONTENT_TYPE_REGEX.test(contentType) ? `${contentType}; charset=utf-8` : contentType + return TEXT_CONTENT_TYPE_REGEX.test(contentType) ? `${contentType}; charset=utf-8` : contentType } private resolveBasePath(prefix: `/${string}` | undefined): `/${string}` { From 25c19e2eb1267291d5354cfececb22e5f1527f1c Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Mon, 10 Aug 2026 14:01:37 +0700 Subject: [PATCH 06/14] docs: tighten the static file plugin page Replaces the How It Works section with one sentence on when files are served. The spec level detail on dot segment normalization, encoding sniffing and precondition headers described the implementation rather than anything a reader acts on. --- apps/content/docs/plugins/static-file.mdx | 41 +++++++++-------------- 1 file changed, 16 insertions(+), 25 deletions(-) diff --git a/apps/content/docs/plugins/static-file.mdx b/apps/content/docs/plugins/static-file.mdx index d73cc703e..09321232b 100644 --- a/apps/content/docs/plugins/static-file.mdx +++ b/apps/content/docs/plugins/static-file.mdx @@ -1,6 +1,6 @@ --- title: "Static File Plugin" -description: "Use StaticFileHandlerPlugin to serve static files alongside your procedures with standard HTTP semantics: ETag and Last-Modified conditional requests, range requests, index files, and directory traversal protection." +description: "Serve static files alongside your procedures, with ETag caching, range requests, single page application fallback, and directory traversal protection." sidebar: label: "Static File" --- @@ -11,23 +11,9 @@ sidebar: npm install @orpc/node@beta ``` -## How It Works - -After routing, when no procedure matches a GET or HEAD request, the plugin maps the request path to a file inside `rootDir` and serves it. Matched procedures always take precedence. Requests that resolve to a directory are redirected to their trailing slash form and answered with the directory's `index.html`. - -Every file response carries an [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) header, so clients sending `If-None-Match` or `If-Modified-Since` receive `304 Not Modified` when the file is unchanged, and `If-Match` or `If-Unmodified-Since` receive `412 Precondition Failed`. Single [range requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Range_requests) are answered with `206 Partial Content`, which enables media seeking and resumable downloads. - -Dot segments like `..` are resolved in URL space, following the [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986#section-5.2.4) normalization browsers and proxies apply. One that would climb above the served path is refused rather than clamped, so a proxy in front of the handler can never disagree with it about which path was requested, and the resolved path is checked against `rootDir` again before any file is opened. Symbolic links whose target leaves `rootDir` are refused unless `allowSymlinks` is set. Dotfiles are treated as not found unless explicitly enabled, which is a request-path policy, so it does not apply to a configured `indexFile` or `fallbackFile`. - -:::warning -`rootDir` should contain only files you intend to make public. Anything reachable inside it is served, including a file a request happens to name. -::: - -Content types are detected from the file extension with [mime](https://www.npmjs.com/package/mime). HTTP defines no default charset, and the [HTML encoding sniffing algorithm](https://html.spec.whatwg.org/multipage/parsing.html#determining-the-character-encoding) ends at a locale-dependent fallback, so `text/*` responses are pinned to UTF-8. Use `mimeTypes` to override or add to the detection. - ## Setup -The plugin reads files through the Node.js filesystem API but only interacts with the handler through standard oRPC interfaces, so it works with any handler on a Node.js compatible runtime, whether it uses the [Node HTTP Adapter](/docs/adapters/node-http) or the [Fetch API Adapter](/docs/adapters/fetch-api). +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. ```ts import { StaticFileHandlerPlugin } from '@orpc/node' @@ -60,7 +46,7 @@ const handler = new RPCHandler(router, { /** * A file served with status 200 when no file matches the request path, - * relative to `rootDir`. Useful for single-page application routing. + * relative to `rootDir`. Useful for single page application routing. * * @default undefined */ @@ -81,8 +67,8 @@ const handler = new RPCHandler(router, { dotfiles: false, /** - * Whether precompressed sidecar files (`.br`, `.zst`, `.gz`) can be served - * when the client accepts their encoding and the content type is compressible. + * Whether precompressed `.br`, `.zst`, and `.gz` sidecar files can be served + * when the client accepts their encoding. * * @default false */ @@ -90,17 +76,16 @@ const handler = new RPCHandler(router, { /** * Whether symbolic links whose target lies outside `rootDir` can be served. - * Enabling this makes every file the links reach publicly readable. + * Enabling this exposes every file those links reach. * * @default false */ allowSymlinks: false, /** - * Content types keyed by lowercase file extension without the dot, taking precedence - * over the type detected from the extension. Values are sent verbatim, so a text type - * needs its own charset. Extensions that neither this nor the detection recognises are - * served as `application/octet-stream`. + * Extra content types keyed by lowercase file extension without the dot, + * merged over the built-in detection. Unrecognised extensions are served + * as `application/octet-stream`. */ mimeTypes: {}, }), @@ -108,8 +93,14 @@ const handler = new RPCHandler(router, { }) ``` +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. + :::info -When the handler is served under a [prefix](/docs/rpc/handler), files are only reachable inside that prefix, because a handler never intercepts requests outside its prefix. +The plugin reads files with the Node.js filesystem API but talks to the handler through standard oRPC interfaces, so it works with [RPCHandler](/docs/rpc/handler), [OpenAPIHandler](/docs/openapi/handler), or a custom handler, on either the [Node HTTP Adapter](/docs/adapters/node-http) or the [Fetch API Adapter](/docs/adapters/fetch-api). When the handler runs under a prefix, files are only reachable inside that prefix. +::: + +:::warning +`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. ::: ## Learn More From 775aa90fb010918ae30458c94a746095f60ceb05 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Mon, 10 Aug 2026 14:02:52 +0700 Subject: [PATCH 07/14] docs: use the standard handler compatibility note on the static file page --- apps/content/docs/plugins/static-file.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/content/docs/plugins/static-file.mdx b/apps/content/docs/plugins/static-file.mdx index 09321232b..3a95b89a1 100644 --- a/apps/content/docs/plugins/static-file.mdx +++ b/apps/content/docs/plugins/static-file.mdx @@ -96,7 +96,7 @@ const handler = new RPCHandler(router, { 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. :::info -The plugin reads files with the Node.js filesystem API but talks to the handler through standard oRPC interfaces, so it works with [RPCHandler](/docs/rpc/handler), [OpenAPIHandler](/docs/openapi/handler), or a custom handler, on either the [Node HTTP Adapter](/docs/adapters/node-http) or the [Fetch API Adapter](/docs/adapters/fetch-api). When the handler runs under a prefix, files are only reachable inside that prefix. +The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc/handler), [OpenAPIHandler](/docs/openapi/handler), or a custom one. ::: :::warning From 93f0ea1599a34f3593613f99b299633931339c2c Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Mon, 10 Aug 2026 14:15:30 +0700 Subject: [PATCH 08/14] fix(node): keep the standard-server hint off static file responses The adapters set a standard-server body format hint for stream bodies, so every served file carried one. It describes how to decode an encoded oRPC payload, which a plain file is not, and it leaked to any http client. --- packages/node/src/static-file-handler-plugin.test.ts | 3 +++ packages/node/src/static-file-handler-plugin.ts | 5 +++++ packages/node/tests/serves-static-files.test.ts | 2 ++ 3 files changed, 10 insertions(+) diff --git a/packages/node/src/static-file-handler-plugin.test.ts b/packages/node/src/static-file-handler-plugin.test.ts index 475240d03..8b57f635d 100644 --- a/packages/node/src/static-file-handler-plugin.test.ts +++ b/packages/node/src/static-file-handler-plugin.test.ts @@ -133,6 +133,8 @@ describe('staticFileHandlerPlugin', () => { expect(res.headers['last-modified']).toBe(statSync(path.join(rootDir, 'hello.txt')).mtime.toUTCString()) // Guards against a Blob body, which would make the adapter attach a content-disposition expect(res.headers['content-disposition']).toBeUndefined() + // The adapter's body format hint belongs to encoded oRPC payloads, not to a served file + expect(res.headers['standard-server']).toBeUndefined() }) it('serves an empty file', async () => { @@ -341,6 +343,7 @@ describe('staticFileHandlerPlugin', () => { expect(res.status).toBe(206) expect(res.headers['content-range']).toBe('bytes 2-5/10') expect(res.headers['content-length']).toBe('4') + expect(res.headers['standard-server']).toBeUndefined() expect(res.body).toEqual(Buffer.from([2, 3, 4, 5])) }) diff --git a/packages/node/src/static-file-handler-plugin.ts b/packages/node/src/static-file-handler-plugin.ts index 8be830d5e..f4065b989 100644 --- a/packages/node/src/static-file-handler-plugin.ts +++ b/packages/node/src/static-file-handler-plugin.ts @@ -404,6 +404,11 @@ export class StaticFileHandlerPlugin implements StandardHandl 'etag': etag, 'last-modified': stats.mtime.toUTCString(), 'accept-ranges': 'bytes', + /** + * A file is served as-is rather than as an encoded oRPC payload, so the adapter's + * body format hint is disabled instead of leaking onto every static response. + */ + 'standard-server': [], } if (negotiatesEncoding) { diff --git a/packages/node/tests/serves-static-files.test.ts b/packages/node/tests/serves-static-files.test.ts index 497b3022b..eee8f493f 100644 --- a/packages/node/tests/serves-static-files.test.ts +++ b/packages/node/tests/serves-static-files.test.ts @@ -44,6 +44,8 @@ it('serves static files', async ({ onTestFinished }) => { expect(await fileRes.text()).toBe('hello world') expect(fileRes.headers.get('content-type')).toBe('text/plain; charset=utf-8') expect(fileRes.headers.get('etag')).toMatch(/^"/) + // Internal oRPC protocol hints must not reach a plain http client + expect(fileRes.headers.get('standard-server')).toBeNull() /** * Regression only reproducible with a real fetch client: it sends From 7c62400d70fee0251bbfd30c6d7644c43bb2908f Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Mon, 10 Aug 2026 14:20:04 +0700 Subject: [PATCH 09/14] refactor(node): drop the standard-server comment, the tests state the rule --- packages/node/src/static-file-handler-plugin.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/node/src/static-file-handler-plugin.ts b/packages/node/src/static-file-handler-plugin.ts index f4065b989..12566a9a8 100644 --- a/packages/node/src/static-file-handler-plugin.ts +++ b/packages/node/src/static-file-handler-plugin.ts @@ -404,10 +404,6 @@ export class StaticFileHandlerPlugin implements StandardHandl 'etag': etag, 'last-modified': stats.mtime.toUTCString(), 'accept-ranges': 'bytes', - /** - * A file is served as-is rather than as an encoded oRPC payload, so the adapter's - * body format hint is disabled instead of leaking onto every static response. - */ 'standard-server': [], } From edc6a2306eeb34f11faab9a90f9a62c181f0d133 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Mon, 10 Aug 2026 14:28:44 +0700 Subject: [PATCH 10/14] simplify bench --- benches/static-file-handler.bench.ts | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/benches/static-file-handler.bench.ts b/benches/static-file-handler.bench.ts index 850689d18..35ec5b9d1 100644 --- a/benches/static-file-handler.bench.ts +++ b/benches/static-file-handler.bench.ts @@ -18,11 +18,6 @@ const handler = new StandardHandler(new RPCHandlerCodec({}, {}), { plugins: [new StaticFileHandlerPlugin({ rootDir })], }) -/** Skips the symlink containment check, which costs one `realpath` per lookup. */ -const trustedHandler = new StandardHandler(new RPCHandlerCodec({}, {}), { - plugins: [new StaticFileHandlerPlugin({ rootDir, allowSymlinks: true })], -}) - function createRequest(url: `/${string}`, headers: Record = {}): StandardLazyRequest { return { url, @@ -37,7 +32,7 @@ await drainBody(response!.body) const etag = response!.headers.etag as string describe('static file handler plugin', () => { - bench('serve 10kb file', async () => { + bench('serve file', async () => { const { response } = await handler.handle(createRequest('/file.txt'), { context: {} }) await drainBody(response!.body) }) @@ -59,9 +54,4 @@ describe('static file handler plugin', () => { bench('not found fall through', async () => { await handler.handle(createRequest('/missing/file.txt'), { context: {} }) }) - - bench('serve 10kb file (allowSymlinks)', async () => { - const { response } = await trustedHandler.handle(createRequest('/file.txt'), { context: {} }) - await drainBody(response!.body) - }) }) From d7e90a7ba23647464805778eb17d307257c18bf5 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Mon, 10 Aug 2026 14:49:32 +0700 Subject: [PATCH 11/14] test(node): cover serving static files with response compression Adds a raw socket end to end test for the two plugins together, driven below fetch so the wire encoding is observable. It surfaced a missing Vary, so a compressed response now varies on accept-encoding and a shared cache can no longer hand a compressed body to a client that did not ask for one. Reverts the standard-server suppression, leaving that header to the standard server layer. --- apps/content/docs/plugins/static-file.mdx | 19 +++ .../src/static-file-handler-plugin.test.ts | 3 - .../node/src/static-file-handler-plugin.ts | 1 - .../node/tests/serves-static-files.test.ts | 2 - .../works-with-response-compression.test.ts | 119 ++++++++++++++++++ .../src/plugins/response-compression.test.ts | 53 ++++++++ .../src/plugins/response-compression.ts | 24 ++++ 7 files changed, 215 insertions(+), 6 deletions(-) create mode 100644 packages/node/tests/works-with-response-compression.test.ts diff --git a/apps/content/docs/plugins/static-file.mdx b/apps/content/docs/plugins/static-file.mdx index 3a95b89a1..ecac16c22 100644 --- a/apps/content/docs/plugins/static-file.mdx +++ b/apps/content/docs/plugins/static-file.mdx @@ -99,6 +99,25 @@ Responses carry [ETag](https://developer.mozilla.org/en-US/docs/Web/HTTP/Referen The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc/handler), [OpenAPIHandler](/docs/openapi/handler), or a custom one. ::: +## Compression + +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. + +```ts +import { ResponseCompressionHandlerPlugin } from '@orpc/server/plugins' + +const handler = new RPCHandler(router, { + plugins: [ + new StaticFileHandlerPlugin({ rootDir: './public', precompressed: true }), + new ResponseCompressionHandlerPlugin(), + ], +}) +``` + +:::tip +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. +::: + :::warning `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. ::: diff --git a/packages/node/src/static-file-handler-plugin.test.ts b/packages/node/src/static-file-handler-plugin.test.ts index 8b57f635d..475240d03 100644 --- a/packages/node/src/static-file-handler-plugin.test.ts +++ b/packages/node/src/static-file-handler-plugin.test.ts @@ -133,8 +133,6 @@ describe('staticFileHandlerPlugin', () => { expect(res.headers['last-modified']).toBe(statSync(path.join(rootDir, 'hello.txt')).mtime.toUTCString()) // Guards against a Blob body, which would make the adapter attach a content-disposition expect(res.headers['content-disposition']).toBeUndefined() - // The adapter's body format hint belongs to encoded oRPC payloads, not to a served file - expect(res.headers['standard-server']).toBeUndefined() }) it('serves an empty file', async () => { @@ -343,7 +341,6 @@ describe('staticFileHandlerPlugin', () => { expect(res.status).toBe(206) expect(res.headers['content-range']).toBe('bytes 2-5/10') expect(res.headers['content-length']).toBe('4') - expect(res.headers['standard-server']).toBeUndefined() expect(res.body).toEqual(Buffer.from([2, 3, 4, 5])) }) diff --git a/packages/node/src/static-file-handler-plugin.ts b/packages/node/src/static-file-handler-plugin.ts index 12566a9a8..8be830d5e 100644 --- a/packages/node/src/static-file-handler-plugin.ts +++ b/packages/node/src/static-file-handler-plugin.ts @@ -404,7 +404,6 @@ export class StaticFileHandlerPlugin implements StandardHandl 'etag': etag, 'last-modified': stats.mtime.toUTCString(), 'accept-ranges': 'bytes', - 'standard-server': [], } if (negotiatesEncoding) { diff --git a/packages/node/tests/serves-static-files.test.ts b/packages/node/tests/serves-static-files.test.ts index eee8f493f..497b3022b 100644 --- a/packages/node/tests/serves-static-files.test.ts +++ b/packages/node/tests/serves-static-files.test.ts @@ -44,8 +44,6 @@ it('serves static files', async ({ onTestFinished }) => { expect(await fileRes.text()).toBe('hello world') expect(fileRes.headers.get('content-type')).toBe('text/plain; charset=utf-8') expect(fileRes.headers.get('etag')).toMatch(/^"/) - // Internal oRPC protocol hints must not reach a plain http client - expect(fileRes.headers.get('standard-server')).toBeNull() /** * Regression only reproducible with a real fetch client: it sends diff --git a/packages/node/tests/works-with-response-compression.test.ts b/packages/node/tests/works-with-response-compression.test.ts new file mode 100644 index 000000000..c576a903b --- /dev/null +++ b/packages/node/tests/works-with-response-compression.test.ts @@ -0,0 +1,119 @@ +import type { AddressInfo } from 'node:net' +import { Buffer } from 'node:buffer' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { createServer, request as httpRequest } from 'node:http' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { brotliCompressSync, brotliDecompressSync, gunzipSync } from 'node:zlib' +import { RPCHandler } from '@orpc/server/node' +import { ResponseCompressionHandlerPlugin } from '@orpc/server/plugins' +import { StaticFileHandlerPlugin } from '../src' + +/** + * Driven over a raw socket rather than `fetch`, which negotiates its own encoding and + * decompresses transparently, hiding exactly what this test needs to observe. + */ +it('works with the response compression plugin', async ({ onTestFinished }) => { + const rootDir = mkdtempSync(path.join(tmpdir(), 'orpc-node-compression-e2e-')) + onTestFinished(() => { + rmSync(rootDir, { recursive: true, force: true }) + }) + + const script = `console.log(${'"padding",'.repeat(400)})` + writeFileSync(path.join(rootDir, 'app.js'), script) + writeFileSync(path.join(rootDir, 'tiny.txt'), 'small') + writeFileSync(path.join(rootDir, 'photo.png'), Buffer.alloc(4096, 7)) + // A sidecar the static plugin serves directly, which compression must leave alone + writeFileSync(path.join(rootDir, 'bundle.js'), script) + writeFileSync(path.join(rootDir, 'bundle.js.br'), brotliCompressSync(script)) + + const handler = new RPCHandler({}, { + plugins: [ + new StaticFileHandlerPlugin({ rootDir, precompressed: true }), + new ResponseCompressionHandlerPlugin({ threshold: 1024 }), + ], + }) + + const server = createServer(async (req, res) => { + const result = await handler.handle(req, res, { context: {} }) + + if (!result.matched) { + res.statusCode = 404 + res.end('not matched') + } + }) + onTestFinished(() => { + server.close() + }) + + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() as AddressInfo + + const get = (url: string, headers: Record = {}) => new Promise<{ + status: number | undefined + headers: Record + body: Buffer + }>((resolve, reject) => { + const req = httpRequest({ host: '127.0.0.1', port, path: url, headers }, (res) => { + const chunks: Buffer[] = [] + res.on('data', chunk => chunks.push(chunk)) + res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) })) + }) + req.on('error', reject) + req.end() + }) + + // A compressible file over the threshold is compressed on the way out + const compressed = await get('/app.js', { 'accept-encoding': 'gzip' }) + expect(compressed.status).toBe(200) + expect(compressed.headers['content-encoding']).toBe('gzip') + expect(compressed.headers['content-type']).toBe('text/javascript; charset=utf-8') + // A shared cache must key on the encoding, or it hands this body to a client that cannot decode it + expect(compressed.headers.vary).toBe('accept-encoding') + expect(gunzipSync(compressed.body).toString()).toBe(script) + expect(compressed.body.length).toBeLessThan(script.length) + + // The same file is served verbatim when the client accepts no encoding + const identity = await get('/app.js') + expect(identity.status).toBe(200) + expect(identity.headers['content-encoding']).toBeUndefined() + expect(identity.body.toString()).toBe(script) + + // A precompressed sidecar is already encoded, so compression must not run again + const sidecar = await get('/bundle.js', { 'accept-encoding': 'br, gzip' }) + expect(sidecar.status).toBe(200) + expect(sidecar.headers['content-encoding']).toBe('br') + // Both plugins ask to vary on the encoding, which must not accumulate duplicates + expect(sidecar.headers.vary).toBe('accept-encoding') + expect(brotliDecompressSync(sidecar.body).toString()).toBe(script) + + /** + * A partial body is a byte range of the identity representation. Compressing it would leave + * `Content-Range` describing offsets the client never receives, breaking range resumption. + */ + const partial = await get('/app.js', { 'accept-encoding': 'gzip', 'range': 'bytes=0-99' }) + expect(partial.status).toBe(206) + expect(partial.headers['content-encoding']).toBeUndefined() + expect(partial.headers['content-range']).toBe(`bytes 0-99/${script.length}`) + expect(partial.body.toString()).toBe(script.slice(0, 100)) + + // A revalidation carries no body to compress + const revalidated = await get('/app.js', { + 'accept-encoding': 'gzip', + 'if-none-match': identity.headers.etag as string, + }) + expect(revalidated.status).toBe(304) + expect(revalidated.headers['content-encoding']).toBeUndefined() + expect(revalidated.body).toHaveLength(0) + + // Below the threshold, and a type that does not benefit, are both left alone + const tiny = await get('/tiny.txt', { 'accept-encoding': 'gzip' }) + expect(tiny.status).toBe(200) + expect(tiny.headers['content-encoding']).toBeUndefined() + expect(tiny.body.toString()).toBe('small') + + const image = await get('/photo.png', { 'accept-encoding': 'gzip' }) + expect(image.status).toBe(200) + expect(image.headers['content-encoding']).toBeUndefined() + expect(image.body).toHaveLength(4096) +}) diff --git a/packages/server/src/plugins/response-compression.test.ts b/packages/server/src/plugins/response-compression.test.ts index a1f223058..2a883e6b1 100644 --- a/packages/server/src/plugins/response-compression.test.ts +++ b/packages/server/src/plugins/response-compression.test.ts @@ -238,6 +238,59 @@ describe('responseCompressionHandlerPlugin', () => { }) }) + describe('vary', () => { + it.each([ + ['adds accept-encoding when absent', undefined, 'accept-encoding'], + ['keeps an existing accept-encoding once', 'accept-encoding', 'accept-encoding'], + ['appends to other fields', 'origin', 'origin, accept-encoding'], + ['matches case insensitively', 'Accept-Encoding', 'Accept-Encoding'], + ['leaves the wildcard alone', '*', '*'], + ])('%s', async (_label, vary, expected) => { + const largeText = 'x'.repeat(2000) + const handler = new RPCHandler(os.handler(() => largeText), { + plugins: [ + { + name: 'set-vary', + init(options) { + return { + ...options, + routingInterceptors: [ + async ({ next, ...interceptorOptions }) => { + const result = await next(interceptorOptions) + if (!result.matched) { + return result + } + return { + ...result, + response: { + ...result.response, + headers: { ...result.response.headers, ...vary === undefined ? {} : { vary } }, + }, + } + }, + ...options.routingInterceptors ?? [], + ], + } + }, + }, + new ResponseCompressionHandlerPlugin({ threshold: 100 }), + ], + }) + + const { response } = await handler.handle(new Request('http://localhost', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'accept-encoding': 'gzip', + }, + body: JSON.stringify({ json: null }), + })) + + expect(response!.headers.get('content-encoding')).toBe('gzip') + expect(response!.headers.get('vary')).toBe(expected) + }) + }) + describe('partial responses', () => { it.each([ ['a 206 status', 206, {}], diff --git a/packages/server/src/plugins/response-compression.ts b/packages/server/src/plugins/response-compression.ts index 31c09e886..51c2a5e28 100644 --- a/packages/server/src/plugins/response-compression.ts +++ b/packages/server/src/plugins/response-compression.ts @@ -108,6 +108,7 @@ export class ResponseCompressionHandlerPlugin implements Stan 'standard-server': 'octet-stream' satisfies StandardBodyHint, 'content-length': [], 'content-encoding': encoding, + 'vary': varyByAcceptEncoding(headers.vary), }, }, } @@ -135,6 +136,7 @@ export class ResponseCompressionHandlerPlugin implements Stan 'content-length': [], 'content-disposition': contentDisposition, 'content-encoding': encoding, + 'vary': varyByAcceptEncoding(headers.vary), }, }, } @@ -185,6 +187,7 @@ export class ResponseCompressionHandlerPlugin implements Stan 'content-type': res.headers.get('content-type')!, 'content-length': [], 'content-encoding': encoding, + 'vary': varyByAcceptEncoding(headers.vary), }, }, } @@ -205,6 +208,7 @@ export class ResponseCompressionHandlerPlugin implements Stan 'content-type': 'application/x-www-form-urlencoded', 'content-length': [], 'content-encoding': encoding, + 'vary': varyByAcceptEncoding(headers.vary), }, }, } @@ -225,6 +229,7 @@ export class ResponseCompressionHandlerPlugin implements Stan 'content-type': 'application/json', 'content-length': [], 'content-encoding': encoding, + 'vary': varyByAcceptEncoding(headers.vary), }, }, } @@ -244,6 +249,25 @@ export class ResponseCompressionHandlerPlugin implements Stan } } +/** + * The encoding is chosen from the request, so a shared cache must key on it or it will hand + * a compressed body to a client that cannot decode it. + * + * @see https://www.rfc-editor.org/rfc/rfc9110.html#name-vary + */ +function varyByAcceptEncoding(vary: string | string[] | undefined): string { + const current = flattenStandardHeader(vary) + + if (current === undefined) { + return 'accept-encoding' + } + + const fields = current.split(',').map(field => field.trim().toLowerCase()) + + // `*` already forbids reuse across requests, so narrowing it would be a downgrade + return fields.includes('accept-encoding') || fields.includes('*') ? current : `${current}, accept-encoding` +} + /** * Whether Cache-Control includes the no-transform directive. * From 63231b1bb60166012784b58e65a24a6d13f62330 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Mon, 10 Aug 2026 15:02:15 +0700 Subject: [PATCH 12/14] fix tsconfig --- packages/node/tsconfig.json | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/node/tsconfig.json b/packages/node/tsconfig.json index ffc60e720..8dc7c8b1b 100644 --- a/packages/node/tsconfig.json +++ b/packages/node/tsconfig.json @@ -3,10 +3,6 @@ "compilerOptions": { "types": ["node"] }, - "references": [ - { "path": "../server" }, - { "path": "../shared" } - ], "include": ["package.json", "src"], "exclude": [ "**/*.test.*", From ecdb4526f1ccfddbad19fe3ae3816038fa7d83cf Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Mon, 10 Aug 2026 15:02:49 +0700 Subject: [PATCH 13/14] fix pacakge.json --- packages/node/package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/node/package.json b/packages/node/package.json index acb65a001..ac2a69b00 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -12,8 +12,7 @@ }, "keywords": [ "orpc", - "node", - "static" + "node" ], "sideEffects": false, "publishConfig": { From 53917d4bb742400ff617096d0f61b30f44019fd8 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Mon, 10 Aug 2026 15:05:04 +0700 Subject: [PATCH 14/14] docs: keep the rootDir warning in the static file setup section --- apps/content/docs/plugins/static-file.mdx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/content/docs/plugins/static-file.mdx b/apps/content/docs/plugins/static-file.mdx index ecac16c22..2a6cbd08b 100644 --- a/apps/content/docs/plugins/static-file.mdx +++ b/apps/content/docs/plugins/static-file.mdx @@ -99,6 +99,10 @@ Responses carry [ETag](https://developer.mozilla.org/en-US/docs/Web/HTTP/Referen The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc/handler), [OpenAPIHandler](/docs/openapi/handler), or a custom one. ::: +:::warning +`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. +::: + ## Compression 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. @@ -118,10 +122,6 @@ const handler = new RPCHandler(router, { 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. ::: -:::warning -`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. -::: - ## Learn More For implementation details, see the [source code](https://github.com/middleapi/orpc/blob/main/packages/node/src/static-file-handler-plugin.ts).