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..2a6cbd08b --- /dev/null +++ b/apps/content/docs/plugins/static-file.mdx @@ -0,0 +1,127 @@ +--- +title: "Static File Plugin" +description: "Serve static files alongside your procedures, with ETag caching, range requests, single page application fallback, and directory traversal protection." +sidebar: + label: "Static File" +--- + +## Installation + +```package-install +npm install @orpc/node@beta +``` + +## Setup + +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' +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 `.br`, `.zst`, and `.gz` sidecar files can be served + * when the client accepts their encoding. + * + * @default false + */ + precompressed: false, + + /** + * Whether symbolic links whose target lies outside `rootDir` can be served. + * Enabling this exposes every file those links reach. + * + * @default false + */ + allowSymlinks: false, + + /** + * 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: {}, + }), + ], +}) +``` + +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 `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. + +```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. +::: + +## 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..35ec5b9d1 --- /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 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..ac2a69b00 --- /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" + ], + "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", + "mime": "^4.1.0" + }, + "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..475240d03 --- /dev/null +++ b/packages/node/src/static-file-handler-plugin.test.ts @@ -0,0 +1,971 @@ +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, 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' + +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')) + + // 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']! + }) + + 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) + } + + /** + * 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') + + 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(/^"[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('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. + // 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'], + ['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'], + ['data.json', 'application/json'], + ['bundle.js.map', 'application/json'], + ] as const) { + // 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) + 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') + + 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(/^"/) + }) + + 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 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"') + + 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('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') + + 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) + }) + + 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', () => { + 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('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/') + + 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', + '/....//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) { + const res = await agent.get(url) + expect(res.status, url).toBe(404) + expect(res.text, url).toBe('not matched') + } + }) + + 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() + + 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('resolves 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') + + // 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') + expect(traversed.status).toBe(404) + expect(traversed.body).toBe('not matched') + }) + + it('refuses dot segments that climb above the mounted path', async ({ onTestFinished }) => { + const client = await createRawClient({ path: '/assets' }) + onTestFinished(() => client.close()) + + 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 }) => { + 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 () => { + 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/') + }) + + 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', () => { + 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', 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('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') + .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) + }) + + 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. + */ + 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..8be830d5e --- /dev/null +++ b/packages/node/src/static-file-handler-plugin.ts @@ -0,0 +1,573 @@ +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 { realpath, stat } from 'node:fs/promises' +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' + +/** + * `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 TEXT_CONTENT_TYPE_REGEX = /^text\// + +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 + + /** + * 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 + + /** + * 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 +} + +/** + * 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. + * + * @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 { + 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 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 + 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, 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 + } + + /** + * 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 [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 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 TEXT_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)) + } + + /** + * 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) + + if (filePath === undefined) { + return undefined + } + + // 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 (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 + + if (segment === '' || segment === '.') { + continue + } + + /** + * 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 + } + + if (segment.includes('\0') || segment.includes('/') || segment.includes('\\')) { + return undefined + } + + if (!this.dotfiles && segment.startsWith('.')) { + return undefined + } + + segments.push(segment) + } + + return segments + } + + 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 }, + }, + } + } + } + } + + 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.resolveContentType(filePath) + const negotiatesEncoding = this.precompressed && isCompressibleContentType(contentType) + + let contentEncoding: string | undefined + + if (negotiatesEncoding) { + 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 + // 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': stats.mtime.toUTCString(), + 'accept-ranges': 'bytes', + } + + if (negotiatesEncoding) { + // Sent even for the identity variant, so caches key on the encoding + headers.vary = 'accept-encoding' + } + + 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 } + } + + 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, etag, 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}` + } + } + + // 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'] = `${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 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. + * `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, etag: string, lastModifiedTime: number): boolean { + const ifRange = flattenStandardHeader(requestHeaders['if-range']) + + if (ifRange === undefined) { + return true + } + + // The etag form requires a strong comparison, so a weak tag can never match + if (ifRange.startsWith('"') || ifRange.startsWith('W/')) { + return ifRange === etag + } + + return Date.parse(ifRange) === lastModifiedTime +} + +function parseByteRange(range: string, size: number): [start: number, end: number] | 'unsatisfiable' | undefined { + // 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] === '')) { + 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..497b3022b --- /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(/^"/) + + /** + * 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/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/node/tsconfig.json b/packages/node/tsconfig.json new file mode 100644 index 000000000..8dc7c8b1b --- /dev/null +++ b/packages/node/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.lib.json", + "compilerOptions": { + "types": ["node"] + }, + "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.test.ts b/packages/server/src/plugins/response-compression.test.ts index 494061833..2a883e6b1 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: [ @@ -238,6 +238,112 @@ 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, {}], + ['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 1338a1a0b..51c2a5e28 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, 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; @@ -66,15 +66,23 @@ 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 } - 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 @@ -100,6 +108,7 @@ export class ResponseCompressionHandlerPlugin implements Stan 'standard-server': 'octet-stream' satisfies StandardBodyHint, 'content-length': [], 'content-encoding': encoding, + 'vary': varyByAcceptEncoding(headers.vary), }, }, } @@ -127,6 +136,7 @@ export class ResponseCompressionHandlerPlugin implements Stan 'content-length': [], 'content-disposition': contentDisposition, 'content-encoding': encoding, + 'vary': varyByAcceptEncoding(headers.vary), }, }, } @@ -177,6 +187,7 @@ export class ResponseCompressionHandlerPlugin implements Stan 'content-type': res.headers.get('content-type')!, 'content-length': [], 'content-encoding': encoding, + 'vary': varyByAcceptEncoding(headers.vary), }, }, } @@ -197,6 +208,7 @@ export class ResponseCompressionHandlerPlugin implements Stan 'content-type': 'application/x-www-form-urlencoded', 'content-length': [], 'content-encoding': encoding, + 'vary': varyByAcceptEncoding(headers.vary), }, }, } @@ -217,6 +229,7 @@ export class ResponseCompressionHandlerPlugin implements Stan 'content-type': 'application/json', 'content-length': [], 'content-encoding': encoding, + 'vary': varyByAcceptEncoding(headers.vary), }, }, } @@ -237,19 +250,22 @@ export class ResponseCompressionHandlerPlugin implements Stan } /** - * Parse Accept-Encoding into coding tokens (q-values ignored; order is client preference). + * 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-accept-encoding + * @see https://www.rfc-editor.org/rfc/rfc9110.html#name-vary */ -function parseAcceptEncodings(header: string | undefined): string[] { - if (header === undefined) { - return [] +function varyByAcceptEncoding(vary: string | string[] | undefined): string { + const current = flattenStandardHeader(vary) + + if (current === undefined) { + return 'accept-encoding' } - return header - .split(',') - .map(part => part.trim().split(';')[0]!.trim().toLowerCase()) - .filter(Boolean) + 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` } /** 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.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 e4c3139ab..ee55126a3 100644 --- a/packages/shared/src/http.ts +++ b/packages/shared/src/http.ts @@ -52,6 +52,34 @@ export function matchesHttpPath(url: `/${string}`, path: `/${string}`): boolean || charAfterPrefix === '#' } +const ACCEPT_ENCODING_QUALITY_REGEX = /^\s*q=([\d.]+)\s*$/i + +/** + * 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 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 qualities +} + /** * 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..aabaf7b65 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,25 @@ 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 + mime: + specifier: ^4.1.0 + version: 4.1.0 + devDependencies: + supertest: + specifier: ^7.2.2 + version: 7.2.2(supports-color@10.2.2) + packages/openapi: dependencies: '@hey-api/spec-types': @@ -8941,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'} @@ -20712,6 +20739,8 @@ snapshots: mime@2.6.0: {} + mime@4.1.0: {} + mimic-fn@2.1.0: {} miniflare@5.20260730.0-alpha: