├── .github └── workflows │ └── main.yml ├── .gitignore ├── .yarnrc ├── README.md ├── examples ├── bun-app │ ├── package.json │ ├── public │ │ └── index.html │ ├── src │ │ ├── application.ts │ │ ├── handlers │ │ │ ├── auth.ts │ │ │ ├── home.ts │ │ │ ├── index.ts │ │ │ └── users.ts │ │ ├── server.ts │ │ └── types │ │ │ └── global.d.ts │ └── tsconfig.json ├── express-app │ ├── package.json │ ├── public │ │ └── index.html │ ├── src │ │ ├── application.ts │ │ ├── handlers │ │ │ ├── auth.ts │ │ │ ├── home.ts │ │ │ ├── index.ts │ │ │ └── users.ts │ │ ├── helpers │ │ │ └── listen.ts │ │ ├── server.ts │ │ └── types │ │ │ └── global.d.ts │ └── tsconfig.json └── node-app │ ├── package.json │ ├── public │ └── index.html │ ├── src │ ├── application.ts │ ├── handlers │ │ ├── auth.ts │ │ ├── home.ts │ │ ├── index.ts │ │ └── users.ts │ ├── helpers │ │ └── listen.ts │ ├── server.ts │ └── types │ │ └── global.d.ts │ └── tsconfig.json ├── package.json ├── packages ├── bun │ ├── .eslintignore │ ├── index.d.ts │ ├── index.js │ ├── package.json │ ├── rollup.config.js │ ├── src │ │ ├── __tests__ │ │ │ ├── custom-serveFile.test.ts │ │ │ └── links.test.ts │ │ ├── builtins │ │ │ ├── Bun.ts │ │ │ └── fs.ts │ │ ├── bun.ts │ │ ├── constants.ts │ │ ├── core │ │ ├── fs │ │ ├── index.ts │ │ ├── support │ │ │ ├── __tests__ │ │ │ │ └── serveFile.test.ts │ │ │ ├── serveFile.ts │ │ │ └── testHelpers.ts │ │ ├── test │ │ │ ├── global.d.ts │ │ │ └── setup.ts │ │ ├── types │ │ └── web-io │ │ │ ├── BodyInit.ts │ │ │ ├── Headers.ts │ │ │ ├── HeadersInit.ts │ │ │ ├── Request.ts │ │ │ ├── RequestInit.ts │ │ │ ├── Response.ts │ │ │ ├── ResponseInit.ts │ │ │ └── index.ts │ ├── tsconfig.build.json │ └── tsconfig.json ├── cfw │ ├── .eslintignore │ ├── index.d.ts │ ├── index.js │ ├── package.json │ ├── rollup.config.js │ ├── src │ │ ├── __tests__ │ │ │ ├── custom-serveFile.test.ts │ │ │ └── links.test.ts │ │ ├── cfw.ts │ │ ├── constants.ts │ │ ├── core │ │ ├── index.ts │ │ ├── support │ │ │ ├── path.ts │ │ │ └── resolveFilePath.ts │ │ ├── test │ │ │ ├── node-fetch.d.ts │ │ │ └── setup.ts │ │ ├── types │ │ └── web-io │ │ │ ├── BodyInit.ts │ │ │ ├── Headers.ts │ │ │ ├── HeadersInit.ts │ │ │ ├── Request.ts │ │ │ ├── RequestInit.ts │ │ │ ├── Response.ts │ │ │ ├── ResponseInit.ts │ │ │ └── index.ts │ ├── tsconfig.build.json │ ├── tsconfig.json │ └── vitest.config.ts ├── core │ ├── .eslintignore │ ├── package.json │ ├── src │ │ ├── constants.ts │ │ ├── core │ │ │ ├── CustomRequest.ts │ │ │ ├── CustomResponse.ts │ │ │ ├── HttpError.ts │ │ │ ├── Router.ts │ │ │ ├── StaticFile.ts │ │ │ ├── __tests__ │ │ │ │ ├── CustomRequest.test.ts │ │ │ │ ├── CustomResponse.test.ts │ │ │ │ ├── HttpError.test.ts │ │ │ │ ├── Router.test.ts │ │ │ │ ├── defineAdapter.test.ts │ │ │ │ ├── routeRequest.test.ts │ │ │ │ └── types.test.ts │ │ │ ├── defineAdapter.ts │ │ │ ├── index.ts │ │ │ └── support │ │ │ │ ├── __tests__ │ │ │ │ └── mimeTypes.test.ts │ │ │ │ ├── createMeta.ts │ │ │ │ ├── defineErrors.ts │ │ │ │ ├── getPlatform.ts │ │ │ │ ├── mimeTypes.ts │ │ │ │ ├── parseUrl.ts │ │ │ │ └── tryAsync.ts │ │ ├── fs │ │ │ ├── __tests__ │ │ │ │ └── resolveFilePath.test.ts │ │ │ ├── caching.ts │ │ │ ├── fileServing.ts │ │ │ ├── index.ts │ │ │ └── resolveFilePath.ts │ │ ├── test │ │ │ ├── node-fetch.d.ts │ │ │ └── setup.ts │ │ ├── types │ │ │ ├── global.d.ts │ │ │ ├── http.ts │ │ │ ├── index.ts │ │ │ ├── json.ts │ │ │ └── utilities.ts │ │ └── web-io │ │ │ └── index.ts │ ├── tsconfig.json │ └── vitest.config.ts ├── express │ ├── index.d.ts │ ├── index.js │ └── package.json └── node │ ├── .eslintignore │ ├── index.d.ts │ ├── index.js │ ├── package.json │ ├── rollup.config.js │ ├── src │ ├── __tests__ │ │ ├── express.test.ts │ │ ├── helpers │ │ │ ├── createMockExpress.ts │ │ │ └── createMockNode.ts │ │ ├── links.test.ts │ │ └── node.test.ts │ ├── constants.ts │ ├── core │ ├── express.ts │ ├── fs │ ├── index.ts │ ├── node.ts │ ├── packages │ │ ├── express │ │ │ └── index.ts │ │ └── node │ │ │ └── index.ts │ ├── support │ │ ├── __tests__ │ │ │ └── serveFile.test.ts │ │ ├── fromNodeRequest.ts │ │ ├── headers.ts │ │ ├── pipeStreamAsync.ts │ │ ├── readEntireStream.ts │ │ ├── serveFile.ts │ │ └── streams.ts │ ├── test │ │ └── setup.ts │ ├── types │ └── web-io │ │ ├── Body.ts │ │ ├── Headers.ts │ │ ├── Request.ts │ │ ├── Response.ts │ │ ├── __tests__ │ │ ├── Body.test.ts │ │ ├── Request.test.ts │ │ └── Response.test.ts │ │ └── index.ts │ ├── tsconfig.build.json │ ├── tsconfig.json │ └── vitest.config.ts ├── publish.sh ├── tsconfig.json ├── turbo.json ├── version.sh └── yarn.lock /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: ['main'] 6 | pull_request: 7 | types: [opened, synchronize] 8 | 9 | jobs: 10 | build: 11 | name: Build and Test (Node ${{ matrix.node }}) 12 | timeout-minutes: 15 13 | runs-on: ubuntu-latest 14 | 15 | strategy: 16 | matrix: 17 | node: [14, 16, 18] 18 | fail-fast: false 19 | 20 | steps: 21 | - name: Check out code 22 | uses: actions/checkout@v2 23 | with: 24 | fetch-depth: 2 25 | 26 | - name: Setup Node v${{ matrix.node }} 27 | uses: actions/setup-node@v2 28 | with: 29 | node-version: ${{ matrix.node }} 30 | cache: 'yarn' 31 | 32 | - name: Install Bun 33 | uses: antongolub/action-setup-bun@v1 34 | 35 | - name: Install dependencies 36 | run: yarn 37 | 38 | - name: Build 39 | run: yarn build 40 | 41 | - name: Test 42 | run: yarn test 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .turbo/ 2 | build/ 3 | lib/ 4 | node_modules/ 5 | coverage/ 6 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | version-git-tag false 2 | -------------------------------------------------------------------------------- /examples/bun-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bun-app", 3 | "version": "0.1.0", 4 | "scripts": { 5 | "dev": "nodemon --exec \"bun\" src/server.ts -e js,ts,json --watch src --watch ../../packages/bun/build --delay 500ms", 6 | "lint": "eslint --max-warnings 0 \"src/**/*.ts\"", 7 | "typecheck": "tsc --noEmit", 8 | "format": "prettier --write src", 9 | "format:check": "prettier --check src", 10 | "test": "yarn lint && yarn typecheck && yarn format:check", 11 | "clean": "rm -rf .turbo coverage" 12 | }, 13 | "dependencies": {}, 14 | "devDependencies": { 15 | "bun-types": "^0.7.1", 16 | "eslint": "^8.19.0", 17 | "eslint-config-xt": "^1.7.0", 18 | "nodemon": "^2.0.19", 19 | "prettier": "^2.7.1", 20 | "typescript": "^4.7.4" 21 | }, 22 | "eslintConfig": { 23 | "extends": "xt", 24 | "rules": { 25 | "@typescript-eslint/consistent-type-imports": "warn" 26 | } 27 | }, 28 | "prettier": { 29 | "singleQuote": true, 30 | "trailingComma": "all", 31 | "arrowParens": "always" 32 | }, 33 | "license": "ISC" 34 | } 35 | -------------------------------------------------------------------------------- /examples/bun-app/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Home 5 | 6 | 7 |

Hello World!

8 | 9 | 10 | -------------------------------------------------------------------------------- /examples/bun-app/src/application.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path'; 2 | 3 | import { createApplication } from '@nbit/bun'; 4 | 5 | const { defineRoutes, attachRoutes } = createApplication({ 6 | root: join(import.meta.dir, '..'), 7 | allowStaticFrom: ['public'], 8 | getContext: (request) => ({ 9 | auth: async () => { 10 | const authHeader = request.headers.get('Authorization') ?? ''; 11 | const token = authHeader.startsWith('Bearer') 12 | ? authHeader.split(' ')[1] 13 | : undefined; 14 | return isValidAuthToken(token); 15 | }, 16 | }), 17 | }); 18 | 19 | function isValidAuthToken(token: string | undefined) { 20 | return token === 'some-valid-token'; 21 | } 22 | 23 | export { defineRoutes, attachRoutes }; 24 | -------------------------------------------------------------------------------- /examples/bun-app/src/handlers/auth.ts: -------------------------------------------------------------------------------- 1 | import { Response } from '@nbit/bun'; 2 | 3 | import { defineRoutes } from '../application'; 4 | 5 | export default defineRoutes((app) => [ 6 | app.post('/auth', async (request) => { 7 | const body = await request.json(); 8 | return Response.json({ body }); 9 | }), 10 | ]); 11 | -------------------------------------------------------------------------------- /examples/bun-app/src/handlers/home.ts: -------------------------------------------------------------------------------- 1 | import { Response } from '@nbit/bun'; 2 | 3 | import { defineRoutes } from '../application'; 4 | 5 | export default defineRoutes((app) => [ 6 | app.get('/', (_request) => { 7 | // Will be resolved relative to the root specified in application.ts 8 | return Response.file('public/index.html'); 9 | }), 10 | ]); 11 | -------------------------------------------------------------------------------- /examples/bun-app/src/handlers/index.ts: -------------------------------------------------------------------------------- 1 | export { default as auth } from './auth'; 2 | export { default as home } from './home'; 3 | export { default as users } from './users'; 4 | -------------------------------------------------------------------------------- /examples/bun-app/src/handlers/users.ts: -------------------------------------------------------------------------------- 1 | import { Response } from '@nbit/bun'; 2 | 3 | import { defineRoutes } from '../application'; 4 | 5 | export default defineRoutes(({ get, post }) => [ 6 | get('/hello', (_request) => { 7 | return new Response('Hello'); 8 | }), 9 | 10 | get('/hello/:name', async (request) => { 11 | // Note: TypeScript knows exactly the shape of params here 12 | const { name } = request.params; 13 | return Response.json({ hi: name }); 14 | }), 15 | 16 | // From the terminal below, try: curl -H 'content-type: application/json' -d '{"hello":"world"}' http://localhost:3000/stuff 17 | post('/stuff', async (request) => { 18 | const body = await request.json(); 19 | // The following is equivalent to returning Response.json({ body }) 20 | return new Response(JSON.stringify({ body }), { 21 | headers: { 'Content-Type': 'application/json' }, 22 | }); 23 | }), 24 | ]); 25 | -------------------------------------------------------------------------------- /examples/bun-app/src/server.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { attachRoutes } from './application'; 3 | import * as handlers from './handlers'; 4 | 5 | const port = 3000; 6 | 7 | Bun.serve({ 8 | port, 9 | fetch: attachRoutes(...Object.values(handlers)), 10 | }); 11 | 12 | console.log(`Server running at http://localhost:${port}`); 13 | -------------------------------------------------------------------------------- /examples/bun-app/src/types/global.d.ts: -------------------------------------------------------------------------------- 1 | ../../../../packages/core/src/types/global.d.ts -------------------------------------------------------------------------------- /examples/bun-app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["bun-types"] 5 | }, 6 | "include": ["src"] 7 | } 8 | -------------------------------------------------------------------------------- /examples/express-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "express-app", 3 | "version": "0.1.0", 4 | "scripts": { 5 | "dev": "ts-node-dev --no-notify --respawn --transpile-only src/server.ts", 6 | "lint": "eslint --max-warnings 0 \"src/**/*.ts\"", 7 | "typecheck": "tsc --noEmit", 8 | "format": "prettier --write src", 9 | "format:check": "prettier --check src", 10 | "test": "yarn lint && yarn typecheck && yarn format:check", 11 | "clean": "rm -rf .turbo coverage" 12 | }, 13 | "dependencies": { 14 | "express": "^4.18.1" 15 | }, 16 | "devDependencies": { 17 | "@types/express": "^4.17.13", 18 | "@types/node": "^18.0.3", 19 | "eslint": "^8.19.0", 20 | "eslint-config-xt": "^1.7.0", 21 | "prettier": "^2.7.1", 22 | "ts-node-dev": "^2.0.0", 23 | "typescript": "^4.7.4" 24 | }, 25 | "eslintConfig": { 26 | "extends": "xt", 27 | "rules": { 28 | "@typescript-eslint/consistent-type-imports": "warn" 29 | } 30 | }, 31 | "prettier": { 32 | "singleQuote": true, 33 | "trailingComma": "all", 34 | "arrowParens": "always" 35 | }, 36 | "license": "ISC" 37 | } 38 | -------------------------------------------------------------------------------- /examples/express-app/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Home 5 | 6 | 7 |

Hello World!

8 | 9 | 10 | -------------------------------------------------------------------------------- /examples/express-app/src/application.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path'; 2 | 3 | import { createApplication } from '@nbit/express'; 4 | 5 | const { defineRoutes, attachRoutes } = createApplication({ 6 | root: join(__dirname, '..'), 7 | allowStaticFrom: ['public'], 8 | getContext: (request) => ({ 9 | auth: async () => { 10 | const authHeader = request.headers.get('Authorization') ?? ''; 11 | const token = authHeader.startsWith('Bearer') 12 | ? authHeader.split(' ')[1] 13 | : undefined; 14 | return isValidAuthToken(token); 15 | }, 16 | }), 17 | }); 18 | 19 | function isValidAuthToken(token: string | undefined) { 20 | return token === 'some-valid-token'; 21 | } 22 | 23 | export { defineRoutes, attachRoutes }; 24 | -------------------------------------------------------------------------------- /examples/express-app/src/handlers/auth.ts: -------------------------------------------------------------------------------- 1 | import { Response } from '@nbit/express'; 2 | 3 | import { defineRoutes } from '../application'; 4 | 5 | export default defineRoutes((app) => [ 6 | app.post('/auth', async (request) => { 7 | const body = await request.json(); 8 | return Response.json({ body }); 9 | }), 10 | ]); 11 | -------------------------------------------------------------------------------- /examples/express-app/src/handlers/home.ts: -------------------------------------------------------------------------------- 1 | import { Response } from '@nbit/express'; 2 | 3 | import { defineRoutes } from '../application'; 4 | 5 | export default defineRoutes((app) => [ 6 | app.get('/', (_request) => { 7 | // Will be resolved relative to the root specified in application.ts 8 | return Response.file('public/index.html'); 9 | }), 10 | ]); 11 | -------------------------------------------------------------------------------- /examples/express-app/src/handlers/index.ts: -------------------------------------------------------------------------------- 1 | export { default as auth } from './auth'; 2 | export { default as home } from './home'; 3 | export { default as users } from './users'; 4 | -------------------------------------------------------------------------------- /examples/express-app/src/handlers/users.ts: -------------------------------------------------------------------------------- 1 | import { Response } from '@nbit/express'; 2 | 3 | import { defineRoutes } from '../application'; 4 | 5 | export default defineRoutes(({ get, post }) => [ 6 | get('/hello', (_request) => { 7 | return new Response('Hello'); 8 | }), 9 | 10 | get('/hello/:name', async (request) => { 11 | // Note: TypeScript knows exactly the shape of params here 12 | const { name } = request.params; 13 | return Response.json({ hi: name }); 14 | }), 15 | 16 | // From the terminal below, try: curl -H 'content-type: application/json' -d '{"hello":"world"}' http://localhost:3000/stuff 17 | post('/stuff', async (request) => { 18 | const body = await request.json(); 19 | // The following is equivalent to returning Response.json({ body }) 20 | return new Response(JSON.stringify({ body }), { 21 | headers: { 'Content-Type': 'application/json' }, 22 | }); 23 | }), 24 | ]); 25 | -------------------------------------------------------------------------------- /examples/express-app/src/helpers/listen.ts: -------------------------------------------------------------------------------- 1 | import type { Server as HttpServer } from 'http'; 2 | import type { ListenOptions } from 'net'; 3 | 4 | /** 5 | * Start an httpServer listening. Returns a promise that will resolve when 6 | * server has started listening successfully or will reject with an error such 7 | * as EADDRINUSE. 8 | */ 9 | export function listen(httpServer: HttpServer, options: ListenOptions) { 10 | return new Promise((resolve, reject) => { 11 | httpServer.once('error', reject); 12 | httpServer.listen(options, () => { 13 | httpServer.removeListener('error', reject); 14 | resolve(); 15 | }); 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /examples/express-app/src/server.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import express from 'express'; 3 | 4 | import { attachRoutes } from './application'; 5 | import * as handlers from './handlers'; 6 | 7 | const app = express(); 8 | 9 | const middleware = attachRoutes(...Object.values(handlers)); 10 | 11 | app.use(middleware); 12 | 13 | const port = 3000; 14 | 15 | app.listen(port, () => { 16 | console.log(`Server running at http://localhost:${port}`); 17 | }); 18 | -------------------------------------------------------------------------------- /examples/express-app/src/types/global.d.ts: -------------------------------------------------------------------------------- 1 | ../../../../packages/core/src/types/global.d.ts -------------------------------------------------------------------------------- /examples/express-app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs" 5 | }, 6 | "include": ["src"] 7 | } 8 | -------------------------------------------------------------------------------- /examples/node-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-app", 3 | "version": "0.1.0", 4 | "scripts": { 5 | "dev": "ts-node-dev --no-notify --respawn --transpile-only src/server.ts", 6 | "lint": "eslint --max-warnings 0 \"src/**/*.ts\"", 7 | "typecheck": "tsc --noEmit", 8 | "format": "prettier --write src", 9 | "format:check": "prettier --check src", 10 | "test": "yarn lint && yarn typecheck && yarn format:check", 11 | "clean": "rm -rf .turbo coverage" 12 | }, 13 | "dependencies": {}, 14 | "devDependencies": { 15 | "@types/express": "^4.17.13", 16 | "@types/node": "^18.0.3", 17 | "eslint": "^8.19.0", 18 | "eslint-config-xt": "^1.7.0", 19 | "prettier": "^2.7.1", 20 | "ts-node-dev": "^2.0.0", 21 | "typescript": "^4.7.4" 22 | }, 23 | "eslintConfig": { 24 | "extends": "xt", 25 | "rules": { 26 | "@typescript-eslint/consistent-type-imports": "warn" 27 | } 28 | }, 29 | "prettier": { 30 | "singleQuote": true, 31 | "trailingComma": "all", 32 | "arrowParens": "always" 33 | }, 34 | "license": "ISC" 35 | } 36 | -------------------------------------------------------------------------------- /examples/node-app/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Home 5 | 6 | 7 |

Hello World!

8 | 9 | 10 | -------------------------------------------------------------------------------- /examples/node-app/src/application.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path'; 2 | 3 | import { createApplication } from '@nbit/node'; 4 | 5 | const { defineRoutes, attachRoutes } = createApplication({ 6 | root: join(__dirname, '..'), 7 | allowStaticFrom: ['public'], 8 | getContext: (request) => ({ 9 | auth: async () => { 10 | const authHeader = request.headers.get('Authorization') ?? ''; 11 | const token = authHeader.startsWith('Bearer') 12 | ? authHeader.split(' ')[1] 13 | : undefined; 14 | return isValidAuthToken(token); 15 | }, 16 | }), 17 | }); 18 | 19 | function isValidAuthToken(token: string | undefined) { 20 | return token === 'some-valid-token'; 21 | } 22 | 23 | export { defineRoutes, attachRoutes }; 24 | -------------------------------------------------------------------------------- /examples/node-app/src/handlers/auth.ts: -------------------------------------------------------------------------------- 1 | import { Response } from '@nbit/node'; 2 | 3 | import { defineRoutes } from '../application'; 4 | 5 | export default defineRoutes((app) => [ 6 | app.post('/auth', async (request) => { 7 | const body = await request.json(); 8 | return Response.json({ body }); 9 | }), 10 | ]); 11 | -------------------------------------------------------------------------------- /examples/node-app/src/handlers/home.ts: -------------------------------------------------------------------------------- 1 | import { Response } from '@nbit/node'; 2 | 3 | import { defineRoutes } from '../application'; 4 | 5 | export default defineRoutes((app) => [ 6 | app.get('/', (_request) => { 7 | // Will be resolved relative to the root specified in application.ts 8 | return Response.file('public/index.html'); 9 | }), 10 | ]); 11 | -------------------------------------------------------------------------------- /examples/node-app/src/handlers/index.ts: -------------------------------------------------------------------------------- 1 | export { default as auth } from './auth'; 2 | export { default as home } from './home'; 3 | export { default as users } from './users'; 4 | -------------------------------------------------------------------------------- /examples/node-app/src/handlers/users.ts: -------------------------------------------------------------------------------- 1 | import { Response } from '@nbit/node'; 2 | 3 | import { defineRoutes } from '../application'; 4 | 5 | export default defineRoutes(({ get, post }) => [ 6 | get('/hello', (_request) => { 7 | return new Response('Hello'); 8 | }), 9 | 10 | get('/hello/:name', async (request) => { 11 | // Note: TypeScript knows exactly the shape of params here 12 | const { name } = request.params; 13 | return Response.json({ hi: name }); 14 | }), 15 | 16 | // From the terminal below, try: curl -H 'content-type: application/json' -d '{"hello":"world"}' http://localhost:3000/stuff 17 | post('/stuff', async (request) => { 18 | const body = await request.json(); 19 | // The following is equivalent to returning Response.json({ body }) 20 | return new Response(JSON.stringify({ body }), { 21 | headers: { 'Content-Type': 'application/json' }, 22 | }); 23 | }), 24 | ]); 25 | -------------------------------------------------------------------------------- /examples/node-app/src/helpers/listen.ts: -------------------------------------------------------------------------------- 1 | import type { Server as HttpServer } from 'http'; 2 | import type { ListenOptions } from 'net'; 3 | 4 | /** 5 | * Start an httpServer listening. Returns a promise that will resolve when 6 | * server has started listening successfully or will reject with an error such 7 | * as EADDRINUSE. 8 | */ 9 | export function listen(httpServer: HttpServer, options: ListenOptions) { 10 | return new Promise((resolve, reject) => { 11 | httpServer.once('error', reject); 12 | httpServer.listen(options, () => { 13 | httpServer.removeListener('error', reject); 14 | resolve(); 15 | }); 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /examples/node-app/src/server.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import http from 'http'; 3 | 4 | import { attachRoutes } from './application'; 5 | import * as handlers from './handlers'; 6 | 7 | const port = 3000; 8 | 9 | const server = http.createServer(attachRoutes(...Object.values(handlers))); 10 | 11 | server.listen(port, () => { 12 | console.log(`Server running at http://localhost:${port}/`); 13 | }); 14 | -------------------------------------------------------------------------------- /examples/node-app/src/types/global.d.ts: -------------------------------------------------------------------------------- 1 | ../../../../packages/core/src/types/global.d.ts -------------------------------------------------------------------------------- /examples/node-app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs" 5 | }, 6 | "include": ["src"] 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.13.4", 3 | "workspaces": [ 4 | "examples/*", 5 | "packages/*" 6 | ], 7 | "scripts": { 8 | "lint": "turbo run --force lint", 9 | "typecheck": "turbo run --force typecheck", 10 | "format:check": "turbo run --force format:check", 11 | "unit": "turbo run --force unit", 12 | "test": "turbo run --force lint typecheck format:check unit", 13 | "clean": "rm -rf node_modules/.cache && yarn workspaces run clean", 14 | "build": "turbo run build", 15 | "version:all": "turbo run version --force -- --new-version $npm_package_version --no-git-tag-version", 16 | "postversion": "yarn version:all && git commit -am v$npm_package_version && git tag v$npm_package_version" 17 | }, 18 | "dependencies": {}, 19 | "devDependencies": { 20 | "turbo": "^1.3.1" 21 | }, 22 | "prettier": { 23 | "singleQuote": true, 24 | "trailingComma": "all", 25 | "arrowParens": "always" 26 | }, 27 | "license": "ISC", 28 | "private": true 29 | } 30 | -------------------------------------------------------------------------------- /packages/bun/.eslintignore: -------------------------------------------------------------------------------- 1 | build/ 2 | lib/ 3 | -------------------------------------------------------------------------------- /packages/bun/index.d.ts: -------------------------------------------------------------------------------- 1 | export * from './src'; 2 | -------------------------------------------------------------------------------- /packages/bun/index.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-useless-path-segments 2 | export * from './build/index.js'; 3 | -------------------------------------------------------------------------------- /packages/bun/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nbit/bun", 3 | "version": "0.13.4", 4 | "files": [ 5 | "index.js", 6 | "index.d.ts" 7 | ], 8 | "main": "index.js", 9 | "types": "index.d.ts", 10 | "scripts": { 11 | "lint": "eslint --max-warnings 0 src", 12 | "typecheck": "tsc --noEmit", 13 | "format": "prettier --write src", 14 | "format:check": "prettier --check src", 15 | "copy:lib": "rsync -aL --delete ./src/ ./lib", 16 | "unit": "yarn copy:lib && bun test --preload ./lib/test/setup.ts lib", 17 | "test": "yarn lint && yarn typecheck && yarn format:check && yarn unit", 18 | "clean": "rm -rf .turbo build coverage lib", 19 | "copy-files": "cp ../../README.md build/ && lean-package > build/package.json", 20 | "build": "rm -rf build && tsc -p tsconfig.build.json && rollup -c && rm -rf build/dts && yarn copy-files && prettier --write build", 21 | "version": "yarn version" 22 | }, 23 | "dependencies": {}, 24 | "devDependencies": { 25 | "@rollup/plugin-replace": "^4.0.0", 26 | "@rollup/plugin-typescript": "^8.3.3", 27 | "bun-types": "^0.7.1", 28 | "eslint": "^8.19.0", 29 | "eslint-config-xt": "^1.7.0", 30 | "expect-type": "^0.13.0", 31 | "lean-package": "^1.4.0", 32 | "prettier": "^2.7.1", 33 | "rollup": "^2.76.0", 34 | "rollup-plugin-cleanup": "^3.2.1", 35 | "rollup-plugin-dts": "^4.2.2", 36 | "typescript": "^4.7.4" 37 | }, 38 | "eslintConfig": { 39 | "extends": "xt", 40 | "rules": { 41 | "@typescript-eslint/consistent-type-imports": "warn" 42 | } 43 | }, 44 | "prettier": { 45 | "singleQuote": true, 46 | "trailingComma": "all", 47 | "arrowParens": "always" 48 | }, 49 | "license": "ISC", 50 | "repository": { 51 | "type": "git", 52 | "url": "git://github.com/sstur/nbit.git" 53 | }, 54 | "bugs": { 55 | "url": "https://github.com/sstur/nbit/issues" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /packages/bun/rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from '@rollup/plugin-typescript'; 2 | import dts from 'rollup-plugin-dts'; 3 | import cleanup from 'rollup-plugin-cleanup'; 4 | import replace from '@rollup/plugin-replace'; 5 | 6 | export default [ 7 | { 8 | input: 'src/index.ts', 9 | output: { 10 | dir: 'build', 11 | format: 'esm', 12 | strict: false, 13 | esModule: false, 14 | }, 15 | external: ['fs/promises', 'path'], 16 | plugins: [ 17 | typescript({ 18 | module: 'esnext', 19 | include: ['../**/*.ts'], 20 | }), 21 | replace({ 22 | preventAssignment: true, 23 | delimiters: ['', ''], 24 | values: { 25 | 'import Bun': '// import Bun', 26 | 'import { Request': '// import { Request', 27 | 'import { Response': '// import { Response', 28 | '(..._)': '()', 29 | }, 30 | }), 31 | cleanup({ 32 | extensions: ['js', 'ts'], 33 | }), 34 | ], 35 | }, 36 | { 37 | input: 'build/dts/index.d.ts', 38 | output: { 39 | file: 'build/index.d.ts', 40 | format: 'es', 41 | }, 42 | external: ['bun'], 43 | plugins: [dts()], 44 | }, 45 | ]; 46 | -------------------------------------------------------------------------------- /packages/bun/src/__tests__/custom-serveFile.test.ts: -------------------------------------------------------------------------------- 1 | import Response from '../core/CustomResponse'; 2 | import { createApplication } from '../bun'; 3 | 4 | describe('serveFile', () => { 5 | const { defineRoutes, createRequestHandler } = createApplication({ 6 | root: '/home/foo', 7 | allowStaticFrom: ['public'], 8 | serveFile: async (params) => { 9 | const { status, headers, ...other } = params; 10 | const headersObject = Object.fromEntries(headers.entries()); 11 | return Response.json( 12 | { status, headers: headersObject, ...other }, 13 | { 14 | status, 15 | headers: { ...headersObject, ETag: params.filePath }, 16 | }, 17 | ); 18 | }, 19 | }); 20 | 21 | it('should invoke custom serveFile', async () => { 22 | const routes = defineRoutes((app) => [ 23 | app.get('/file', async (_request) => { 24 | return Response.file('public/file.txt'); 25 | }), 26 | ]); 27 | const requestHandler = createRequestHandler(routes); 28 | const request = new Request('http://localhost/file'); 29 | const response = await requestHandler(request); 30 | expect(response.status).toBe(200); 31 | expect(response.headers.get('ETag')).toBe('public/file.txt'); 32 | const parsed = await response.json(); 33 | expect(parsed).toEqual({ 34 | filePath: 'public/file.txt', 35 | fullFilePath: '/home/foo/public/file.txt', 36 | status: 200, 37 | statusText: '', 38 | headers: {}, 39 | options: {}, 40 | }); 41 | }); 42 | 43 | it('should pass through init options', async () => { 44 | const routes = defineRoutes((app) => [ 45 | app.get('/file', async (_request) => { 46 | return Response.file('public/file.txt', { 47 | status: 404, 48 | headers: { foo: '1' }, 49 | maxAge: 10, 50 | cachingHeaders: true, 51 | }); 52 | }), 53 | ]); 54 | const requestHandler = createRequestHandler(routes); 55 | const request = new Request('http://localhost/file'); 56 | const response = await requestHandler(request); 57 | expect(response.status).toBe(404); 58 | expect(response.headers.get('foo')).toBe('1'); 59 | expect(response.headers.get('ETag')).toBe('public/file.txt'); 60 | const parsed = await response.json(); 61 | expect(parsed).toEqual({ 62 | filePath: 'public/file.txt', 63 | fullFilePath: '/home/foo/public/file.txt', 64 | status: 404, 65 | statusText: '', 66 | headers: { foo: '1' }, 67 | options: { maxAge: 10, cachingHeaders: true }, 68 | }); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /packages/bun/src/__tests__/links.test.ts: -------------------------------------------------------------------------------- 1 | import { getPlatform } from '../core/support/getPlatform'; 2 | 3 | it('should correctly import across symlinked boundaries', () => { 4 | const platform = getPlatform(); 5 | expect(platform).toBe('bun'); 6 | }); 7 | -------------------------------------------------------------------------------- /packages/bun/src/builtins/Bun.ts: -------------------------------------------------------------------------------- 1 | import { mockable } from '../support/testHelpers'; 2 | 3 | // This won't be part of the final build; it's only for tests 4 | export default mockable(Bun); 5 | -------------------------------------------------------------------------------- /packages/bun/src/builtins/fs.ts: -------------------------------------------------------------------------------- 1 | import { stat } from 'fs/promises'; 2 | 3 | // This is re-exported here so it can be easily mocked for tests. 4 | export default { 5 | stat, 6 | }; 7 | -------------------------------------------------------------------------------- /packages/bun/src/bun.ts: -------------------------------------------------------------------------------- 1 | import { defineAdapter } from './core'; 2 | import { StaticFile } from './core/StaticFile'; 3 | import { resolveFilePath } from './fs'; 4 | import { serveFile } from './support/serveFile'; 5 | 6 | export const createApplication = defineAdapter((applicationOptions) => { 7 | // This `fromStaticFile` function is identical to that of node. 8 | const fromStaticFile = async ( 9 | requestHeaders: Headers, 10 | staticFile: StaticFile, 11 | ): Promise => { 12 | const { filePath, options, responseInit: init } = staticFile; 13 | const resolved = resolveFilePath(filePath, applicationOptions); 14 | if (!resolved) { 15 | return; 16 | } 17 | const [fullFilePath] = resolved; 18 | const customServeFile = applicationOptions.serveFile; 19 | if (customServeFile) { 20 | const { status, statusText, headers } = new Response(null, init); 21 | const maybeResponse = await customServeFile({ 22 | filePath, 23 | fullFilePath, 24 | status, 25 | statusText, 26 | headers, 27 | options, 28 | }); 29 | return maybeResponse ?? undefined; 30 | } 31 | const fileResponse = await serveFile(requestHeaders, fullFilePath, options); 32 | if (!fileResponse) { 33 | return; 34 | } 35 | // Use the status from fileResponse if provided (e.g. "304 Not Modified") 36 | // otherwise fall back to user-specified value or default. 37 | const responseStatus = fileResponse.status ?? init.status ?? 200; 38 | const responseHeaders = new Headers(init.headers); 39 | // Merge in the headers without overwriting existing headers 40 | for (const [key, value] of Object.entries(fileResponse.headers ?? {})) { 41 | if (!responseHeaders.has(key)) { 42 | responseHeaders.set(key, value); 43 | } 44 | } 45 | return new Response(fileResponse.body ?? '', { 46 | ...init, 47 | status: responseStatus, 48 | headers: responseHeaders, 49 | }); 50 | }; 51 | 52 | return { 53 | onError: (request, error) => { 54 | return new Response(String(error), { status: 500 }); 55 | }, 56 | toResponse: async (request, result) => { 57 | if (result instanceof StaticFile) { 58 | return await fromStaticFile(request.headers, result); 59 | } 60 | return result; 61 | }, 62 | createNativeHandler: (handleRequest) => handleRequest, 63 | }; 64 | }); 65 | -------------------------------------------------------------------------------- /packages/bun/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const PLATFORM = 'bun'; 2 | -------------------------------------------------------------------------------- /packages/bun/src/core: -------------------------------------------------------------------------------- 1 | ../../core/src/core -------------------------------------------------------------------------------- /packages/bun/src/fs: -------------------------------------------------------------------------------- 1 | ../../core/src/fs -------------------------------------------------------------------------------- /packages/bun/src/index.ts: -------------------------------------------------------------------------------- 1 | export { HttpError } from './core'; 2 | export { Request } from './web-io'; 3 | export { default as Response } from './core/CustomResponse'; 4 | export { createApplication } from './bun'; 5 | -------------------------------------------------------------------------------- /packages/bun/src/support/__tests__/serveFile.test.ts: -------------------------------------------------------------------------------- 1 | import { serveFile } from '../serveFile'; 2 | 3 | describe('serveFile', () => { 4 | it('should serve a file that exists', async () => { 5 | const filePath = '/foo/thing.png'; 6 | const result = await serveFile(new Headers(), filePath); 7 | expect(result).toEqual({ 8 | headers: { 9 | 'Content-Length': '42', 10 | 'Content-Type': 'image/png', 11 | ETag: 'W/"2a16806b5bc00"', 12 | 'Last-Modified': 'Tue, 01 Jan 2019 00:00:00 GMT', 13 | }, 14 | body: { _stream: '/foo/thing.png' }, 15 | }); 16 | }); 17 | 18 | it('should return null if the file does not exist', async () => { 19 | const filePath = './foo.txt'; 20 | const result = await serveFile(new Headers(), filePath); 21 | expect(result).toBe(null); 22 | }); 23 | 24 | it('should return null if the entry at path is not a file', async () => { 25 | const filePath = './foo/dir'; 26 | const result = await serveFile(new Headers(), filePath); 27 | expect(result).toBe(null); 28 | }); 29 | 30 | it('should fall back to default content type', async () => { 31 | const filePath = './foo/file.asdf'; 32 | const result = await serveFile(new Headers(), filePath); 33 | expect(result).toEqual({ 34 | headers: { 35 | 'Content-Length': '15', 36 | 'Content-Type': 'application/octet-stream', 37 | ETag: 'W/"f16806b5bc00"', 38 | 'Last-Modified': 'Tue, 01 Jan 2019 00:00:00 GMT', 39 | }, 40 | body: { _stream: './foo/file.asdf' }, 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /packages/bun/src/support/serveFile.ts: -------------------------------------------------------------------------------- 1 | import { type FileBlob } from 'bun'; 2 | 3 | import fs from '../builtins/fs'; 4 | import Bun from '../builtins/Bun'; 5 | import { computeHeaders } from '../fs'; 6 | import { tryAsync } from '../core/support/tryAsync'; 7 | import type { StaticFileOptions } from '../core/StaticFile'; 8 | 9 | type FileResponse = { 10 | status?: number; 11 | headers?: Record; 12 | body?: FileBlob; 13 | }; 14 | 15 | // This implementation is identical to that of node, except it returns a 16 | // Bun.file() instead of a ReadableStream which is more performant according to 17 | // Bun docs. 18 | export async function serveFile( 19 | requestHeaders: Headers, 20 | fullFilePath: string, 21 | options: StaticFileOptions = {}, 22 | ): Promise { 23 | const fileStats = await tryAsync(() => fs.stat(fullFilePath)); 24 | if (!fileStats || !fileStats.isFile()) { 25 | return null; 26 | } 27 | const result = await computeHeaders( 28 | requestHeaders, 29 | fullFilePath, 30 | fileStats, 31 | options, 32 | ); 33 | if (result == null || result.status === 304) { 34 | return result; 35 | } 36 | return { 37 | headers: result.headers, 38 | body: Bun.file(fullFilePath), 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /packages/bun/src/support/testHelpers.ts: -------------------------------------------------------------------------------- 1 | // For situations where we can't set properties on an object, e.g. the global 2 | // `Bun`, we'll use this helper to create a Proxy 3 | export function mockable(obj: T): T { 4 | const mocks: Record = {}; 5 | const target = obj as Record; 6 | const receiver = new Proxy(target, { 7 | get(target, key) { 8 | return Object.hasOwn(mocks, key) ? mocks[key] : target[key]; 9 | }, 10 | set(target, key, value) { 11 | if (value === target[key]) { 12 | delete mocks[key]; 13 | } else { 14 | mocks[key] = value; 15 | } 16 | return true; 17 | }, 18 | }); 19 | return receiver as T; 20 | } 21 | 22 | export function mockMethod( 23 | obj: O, 24 | key: K, 25 | mock: O[K], 26 | ) { 27 | const original = obj[key]; 28 | obj[key] = mock; 29 | return () => { 30 | obj[key] = original; 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /packages/bun/src/test/global.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-var */ 2 | import type * as bun from 'bun:test'; 3 | 4 | declare global { 5 | // NOTE: Need to use `var` and not `const` here for weird TS reasons. 6 | // Source https://stackoverflow.com/a/69429093 7 | var describe: typeof bun.describe; 8 | var expect: typeof bun.expect; 9 | var it: typeof bun.it; 10 | var beforeAll: typeof bun.beforeAll; 11 | var beforeEach: typeof bun.beforeEach; 12 | var afterAll: typeof bun.afterAll; 13 | var afterEach: typeof bun.afterEach; 14 | } 15 | 16 | export {}; 17 | -------------------------------------------------------------------------------- /packages/bun/src/test/setup.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { 3 | describe, 4 | expect, 5 | it, 6 | beforeAll, 7 | beforeEach, 8 | afterAll, 9 | afterEach, 10 | } from 'bun:test'; 11 | 12 | import fs from '../builtins/fs'; 13 | import Bun from '../builtins/Bun'; 14 | import { mockMethod } from '../support/testHelpers'; 15 | 16 | Object.assign(globalThis, { 17 | describe, 18 | expect, 19 | it, 20 | beforeAll, 21 | beforeEach, 22 | afterAll, 23 | afterEach, 24 | }); 25 | 26 | const files = [ 27 | { 28 | path: '/foo/thing.png', 29 | type: 'file', 30 | size: 42, 31 | mtimeMs: new Date('2019-01-01T00:00:00.000Z').valueOf(), 32 | }, 33 | { 34 | path: './foo/dir', 35 | type: 'directory', 36 | size: 5, 37 | mtimeMs: new Date('2019-01-01T00:00:00.000Z').valueOf(), 38 | }, 39 | { 40 | path: './foo/file.asdf', 41 | type: 'file', 42 | size: 15, 43 | mtimeMs: new Date('2019-01-01T00:00:00.000Z').valueOf(), 44 | }, 45 | ]; 46 | 47 | mockMethod(Bun, 'file', (path) => { 48 | return { _stream: path } as any; 49 | }); 50 | 51 | mockMethod(fs, 'stat', async (path) => { 52 | const file = files.find((file) => path === file.path); 53 | if (!file) { 54 | throw Object.assign( 55 | new Error(`ENOENT: no such file or directory, stat '${path}'`), 56 | { errno: -2, code: 'ENOENT', syscall: 'stat', path }, 57 | ); 58 | } 59 | const { type, size, mtimeMs } = file; 60 | return { 61 | isFile: () => type === 'file', 62 | size, 63 | mtimeMs, 64 | } as any; 65 | }); 66 | -------------------------------------------------------------------------------- /packages/bun/src/types: -------------------------------------------------------------------------------- 1 | ../../core/src/types -------------------------------------------------------------------------------- /packages/bun/src/web-io/BodyInit.ts: -------------------------------------------------------------------------------- 1 | export default BodyInit; 2 | -------------------------------------------------------------------------------- /packages/bun/src/web-io/Headers.ts: -------------------------------------------------------------------------------- 1 | export default Headers; 2 | -------------------------------------------------------------------------------- /packages/bun/src/web-io/HeadersInit.ts: -------------------------------------------------------------------------------- 1 | export default HeadersInit; 2 | -------------------------------------------------------------------------------- /packages/bun/src/web-io/Request.ts: -------------------------------------------------------------------------------- 1 | export default Request; 2 | -------------------------------------------------------------------------------- /packages/bun/src/web-io/RequestInit.ts: -------------------------------------------------------------------------------- 1 | export default RequestInit; 2 | -------------------------------------------------------------------------------- /packages/bun/src/web-io/Response.ts: -------------------------------------------------------------------------------- 1 | export default Response; 2 | -------------------------------------------------------------------------------- /packages/bun/src/web-io/ResponseInit.ts: -------------------------------------------------------------------------------- 1 | export default ResponseInit; 2 | -------------------------------------------------------------------------------- /packages/bun/src/web-io/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Request } from './Request'; 2 | export type { default as RequestInit } from './RequestInit'; 3 | export { default as Response } from './Response'; 4 | export type { default as ResponseInit } from './ResponseInit'; 5 | export { default as Headers } from './Headers'; 6 | export type { default as HeadersInit } from './HeadersInit'; 7 | export type { default as BodyInit } from './BodyInit'; 8 | -------------------------------------------------------------------------------- /packages/bun/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "build", 5 | "declaration": true, 6 | "declarationDir": "build/dts", 7 | "emitDeclarationOnly": true 8 | }, 9 | "exclude": ["src/**/*.test.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /packages/bun/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["bun-types"] 5 | }, 6 | "include": ["src"] 7 | } 8 | -------------------------------------------------------------------------------- /packages/cfw/.eslintignore: -------------------------------------------------------------------------------- 1 | build/ 2 | -------------------------------------------------------------------------------- /packages/cfw/index.d.ts: -------------------------------------------------------------------------------- 1 | export * from './src'; 2 | -------------------------------------------------------------------------------- /packages/cfw/index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./build/index.js'); 2 | -------------------------------------------------------------------------------- /packages/cfw/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nbit/cfw", 3 | "version": "0.13.4", 4 | "files": [ 5 | "index.js", 6 | "index.d.ts" 7 | ], 8 | "main": "index.js", 9 | "types": "index.d.ts", 10 | "scripts": { 11 | "lint": "eslint --max-warnings 0 src", 12 | "typecheck": "tsc --noEmit", 13 | "format": "prettier --write src", 14 | "format:check": "prettier --check src", 15 | "unit": "vitest run", 16 | "test": "yarn lint && yarn typecheck && yarn format:check && yarn unit", 17 | "clean": "rm -rf .turbo build coverage", 18 | "copy-files": "cp ../../README.md build/ && lean-package > build/package.json", 19 | "build": "rm -rf build && tsc -p tsconfig.build.json && rollup -c && rm -rf build/dts && yarn copy-files && prettier --write build", 20 | "build:watch": "rollup -wc", 21 | "version": "yarn version" 22 | }, 23 | "dependencies": {}, 24 | "devDependencies": { 25 | "@cloudflare/workers-types": "^3.14.1", 26 | "@rollup/plugin-replace": "^4.0.0", 27 | "@rollup/plugin-typescript": "^8.3.3", 28 | "eslint": "^8.19.0", 29 | "eslint-config-xt": "^1.7.0", 30 | "expect-type": "^0.13.0", 31 | "lean-package": "^1.4.0", 32 | "node-fetch": "^2.6.12", 33 | "nodemon": "^2.0.19", 34 | "prettier": "^2.7.1", 35 | "rollup": "^2.76.0", 36 | "rollup-plugin-cleanup": "^3.2.1", 37 | "rollup-plugin-dts": "^4.2.2", 38 | "typescript": "^4.7.4", 39 | "vitest": "^0.34.1" 40 | }, 41 | "eslintConfig": { 42 | "extends": "xt", 43 | "rules": { 44 | "@typescript-eslint/consistent-type-imports": "warn" 45 | } 46 | }, 47 | "prettier": { 48 | "singleQuote": true, 49 | "trailingComma": "all", 50 | "arrowParens": "always" 51 | }, 52 | "license": "ISC" 53 | } 54 | -------------------------------------------------------------------------------- /packages/cfw/rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from '@rollup/plugin-typescript'; 2 | import dts from 'rollup-plugin-dts'; 3 | import cleanup from 'rollup-plugin-cleanup'; 4 | import replace from '@rollup/plugin-replace'; 5 | 6 | export default [ 7 | { 8 | input: 'src/index.ts', 9 | output: { 10 | dir: 'build', 11 | format: 'esm', 12 | strict: false, 13 | esModule: false, 14 | }, 15 | external: ['fs', 'path'], 16 | plugins: [ 17 | typescript({ 18 | module: 'esnext', 19 | include: ['../**/*.ts'], 20 | }), 21 | replace({ 22 | preventAssignment: true, 23 | delimiters: ['', ''], 24 | values: { 25 | 'import { Request': '// import { Request', 26 | 'import { Response': '// import { Response', 27 | '(..._)': '()', 28 | }, 29 | }), 30 | cleanup({ 31 | extensions: ['js', 'ts'], 32 | }), 33 | ], 34 | }, 35 | { 36 | input: 'build/dts/index.d.ts', 37 | output: { 38 | file: 'build/index.d.ts', 39 | format: 'es', 40 | }, 41 | plugins: [dts()], 42 | }, 43 | ]; 44 | -------------------------------------------------------------------------------- /packages/cfw/src/__tests__/custom-serveFile.test.ts: -------------------------------------------------------------------------------- 1 | import Response from '../core/CustomResponse'; 2 | import { createApplication } from '../cfw'; 3 | 4 | describe('serveFile', () => { 5 | const { defineRoutes, createRequestHandler } = createApplication({ 6 | root: '/home/foo', 7 | allowStaticFrom: ['public'], 8 | serveFile: async (params) => { 9 | const { status, headers, ...other } = params; 10 | const headersObject = Object.fromEntries(headers.entries()); 11 | return Response.json( 12 | { status, headers: headersObject, ...other }, 13 | { 14 | status, 15 | headers: { ...headersObject, ETag: params.filePath }, 16 | }, 17 | ); 18 | }, 19 | }); 20 | 21 | it('should invoke custom serveFile', async () => { 22 | const routes = defineRoutes((app) => [ 23 | app.get('/file', async (_request) => { 24 | return Response.file('public/file.txt'); 25 | }), 26 | ]); 27 | const requestHandler = createRequestHandler(routes); 28 | const request = new Request('http://localhost/file'); 29 | const response = await requestHandler(request); 30 | expect(response.status).toBe(200); 31 | expect(response.headers.get('ETag')).toBe('public/file.txt'); 32 | const parsed = await response.json(); 33 | expect(parsed).toEqual({ 34 | filePath: 'public/file.txt', 35 | fullFilePath: '/home/foo/public/file.txt', 36 | status: 200, 37 | statusText: 'OK', 38 | headers: {}, 39 | options: {}, 40 | }); 41 | }); 42 | 43 | it('should pass through init options', async () => { 44 | const routes = defineRoutes((app) => [ 45 | app.get('/file', async (_request) => { 46 | return Response.file('public/file.txt', { 47 | status: 404, 48 | headers: { foo: '1' }, 49 | maxAge: 10, 50 | cachingHeaders: true, 51 | }); 52 | }), 53 | ]); 54 | const requestHandler = createRequestHandler(routes); 55 | const request = new Request('http://localhost/file'); 56 | const response = await requestHandler(request); 57 | expect(response.status).toBe(404); 58 | expect(response.headers.get('foo')).toBe('1'); 59 | expect(response.headers.get('ETag')).toBe('public/file.txt'); 60 | const parsed = await response.json(); 61 | expect(parsed).toEqual({ 62 | filePath: 'public/file.txt', 63 | fullFilePath: '/home/foo/public/file.txt', 64 | status: 404, 65 | statusText: 'Not Found', 66 | headers: { foo: '1' }, 67 | options: { maxAge: 10, cachingHeaders: true }, 68 | }); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /packages/cfw/src/__tests__/links.test.ts: -------------------------------------------------------------------------------- 1 | import { getPlatform } from '../core/support/getPlatform'; 2 | 3 | it('should correctly import across symlinked boundaries', () => { 4 | const platform = getPlatform(); 5 | expect(platform).toBe('cfw'); 6 | }); 7 | -------------------------------------------------------------------------------- /packages/cfw/src/cfw.ts: -------------------------------------------------------------------------------- 1 | import { defineAdapter } from './core'; 2 | import { StaticFile } from './core/StaticFile'; 3 | import { resolveFilePath } from './support/resolveFilePath'; 4 | 5 | export const createApplication = defineAdapter((applicationOptions) => { 6 | const fromStaticFile = async ( 7 | requestHeaders: Headers, 8 | staticFile: StaticFile, 9 | ): Promise => { 10 | const { filePath, options, responseInit: init } = staticFile; 11 | const resolved = resolveFilePath(filePath, applicationOptions); 12 | if (!resolved) { 13 | return; 14 | } 15 | const [fullFilePath] = resolved; 16 | const customServeFile = applicationOptions.serveFile; 17 | if (customServeFile) { 18 | const { status, statusText, headers } = new Response(null, init); 19 | const maybeResponse = await customServeFile({ 20 | filePath, 21 | fullFilePath, 22 | status, 23 | statusText, 24 | headers, 25 | options, 26 | }); 27 | return maybeResponse ?? undefined; 28 | } 29 | }; 30 | 31 | return { 32 | onError: (request, error) => { 33 | return new Response(String(error), { status: 500 }); 34 | }, 35 | toResponse: async (request, result) => { 36 | if (result instanceof StaticFile) { 37 | return await fromStaticFile(request.headers, result); 38 | } 39 | return result; 40 | }, 41 | createNativeHandler: (handleRequest) => handleRequest, 42 | }; 43 | }); 44 | -------------------------------------------------------------------------------- /packages/cfw/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const PLATFORM = 'cfw'; 2 | -------------------------------------------------------------------------------- /packages/cfw/src/core: -------------------------------------------------------------------------------- 1 | ../../core/src/core -------------------------------------------------------------------------------- /packages/cfw/src/index.ts: -------------------------------------------------------------------------------- 1 | export { HttpError } from './core'; 2 | export { Request } from './web-io'; 3 | export { default as Response } from './core/CustomResponse'; 4 | export { createApplication } from './cfw'; 5 | -------------------------------------------------------------------------------- /packages/cfw/src/support/path.ts: -------------------------------------------------------------------------------- 1 | // Adapted from https://github.com/jinder/path/blob/a2f87c3/path.js 2 | // which is an exact copy of the NodeJS `path` module. 3 | 4 | const CWD = '/'; 5 | 6 | // path.resolve([from ...], to) 7 | export function resolve(...args: Array) { 8 | let resolvedPath = ''; 9 | let resolvedAbsolute = false; 10 | 11 | for (let i = args.length - 1; i >= -1 && !resolvedAbsolute; i--) { 12 | const path = i >= 0 ? args[i] : CWD; 13 | // Skip empty entries 14 | if (!path) { 15 | continue; 16 | } 17 | resolvedPath = path + '/' + resolvedPath; 18 | resolvedAbsolute = path[0] === '/'; 19 | } 20 | 21 | // At this point the path should be resolved to a full absolute path, but 22 | // handle relative paths to be safe 23 | 24 | // Normalize the path 25 | resolvedPath = normalizeArray( 26 | resolvedPath.split('/'), 27 | !resolvedAbsolute, 28 | ).join('/'); 29 | 30 | return (resolvedAbsolute ? '/' : '') + resolvedPath || '.'; 31 | } 32 | 33 | export function join(...args: Array) { 34 | let path = ''; 35 | for (const segment of args) { 36 | if (segment) { 37 | path += path ? '/' + segment : segment; 38 | } 39 | } 40 | return normalize(path); 41 | } 42 | 43 | // resolves . and .. elements in a path array with directory names there 44 | // must be no slashes or device names (c:\) in the array 45 | // (so also no leading and trailing slashes - it does not distinguish 46 | // relative and absolute paths) 47 | function normalizeArray(parts: Array, allowAboveRoot?: boolean) { 48 | const res = []; 49 | for (const p of parts) { 50 | // ignore empty parts 51 | if (!p || p === '.') { 52 | continue; 53 | } 54 | if (p === '..') { 55 | if (res.length && res[res.length - 1] !== '..') { 56 | res.pop(); 57 | } else if (allowAboveRoot) { 58 | res.push('..'); 59 | } 60 | } else { 61 | res.push(p); 62 | } 63 | } 64 | return res; 65 | } 66 | 67 | function normalize(path: string) { 68 | const isPathAbsolute = isAbsolute(path); 69 | const trailingSlash = path ? path[path.length - 1] === '/' : false; 70 | 71 | // Normalize the path 72 | path = normalizeArray(path.split('/'), !isPathAbsolute).join('/'); 73 | 74 | if (!path && !isPathAbsolute) { 75 | path = '.'; 76 | } 77 | if (path && trailingSlash) { 78 | path += '/'; 79 | } 80 | 81 | return (isPathAbsolute ? '/' : '') + path; 82 | } 83 | 84 | function isAbsolute(path: string) { 85 | return path.charAt(0) === '/'; 86 | } 87 | -------------------------------------------------------------------------------- /packages/cfw/src/support/resolveFilePath.ts: -------------------------------------------------------------------------------- 1 | import type { FileServingOptions } from '../types'; 2 | 3 | import { join, resolve } from './path'; 4 | 5 | export function resolveFilePath(filePath: string, options: FileServingOptions) { 6 | const { root = '/', allowStaticFrom = [] } = options; 7 | const projectRoot = resolve(root); 8 | const fullFilePath = join(projectRoot, filePath); 9 | for (const allowedPath of allowStaticFrom) { 10 | const fullAllowedPath = join(root, allowedPath); 11 | if (fullFilePath.startsWith(fullAllowedPath + '/')) { 12 | return [fullFilePath, allowedPath] as const; 13 | } 14 | } 15 | return null; 16 | } 17 | -------------------------------------------------------------------------------- /packages/cfw/src/test/node-fetch.d.ts: -------------------------------------------------------------------------------- 1 | ../../../core/src/test/node-fetch.d.ts -------------------------------------------------------------------------------- /packages/cfw/src/test/setup.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, Headers, type ResponseInit } from 'node-fetch'; 2 | 3 | // Polyfill for static methods which are in the standard but not supported by node-fetch 4 | Object.assign(Response, { 5 | json: (body: unknown, init?: ResponseInit) => { 6 | const headers = new Headers(init?.headers); 7 | headers.set('Content-Type', 'application/json'); 8 | return new Response(JSON.stringify(body), { ...init, headers }); 9 | }, 10 | redirect: (url: string, status = 302) => { 11 | return new Response('', { 12 | status, 13 | headers: { Location: url }, 14 | }); 15 | }, 16 | }); 17 | 18 | Object.assign(globalThis, { Request, Response, Headers }); 19 | -------------------------------------------------------------------------------- /packages/cfw/src/types: -------------------------------------------------------------------------------- 1 | ../../core/src/types -------------------------------------------------------------------------------- /packages/cfw/src/web-io/BodyInit.ts: -------------------------------------------------------------------------------- 1 | export default BodyInit; 2 | -------------------------------------------------------------------------------- /packages/cfw/src/web-io/Headers.ts: -------------------------------------------------------------------------------- 1 | export default Headers; 2 | -------------------------------------------------------------------------------- /packages/cfw/src/web-io/HeadersInit.ts: -------------------------------------------------------------------------------- 1 | export default HeadersInit; 2 | -------------------------------------------------------------------------------- /packages/cfw/src/web-io/Request.ts: -------------------------------------------------------------------------------- 1 | export default Request; 2 | -------------------------------------------------------------------------------- /packages/cfw/src/web-io/RequestInit.ts: -------------------------------------------------------------------------------- 1 | export default RequestInit; 2 | -------------------------------------------------------------------------------- /packages/cfw/src/web-io/Response.ts: -------------------------------------------------------------------------------- 1 | export default Response; 2 | -------------------------------------------------------------------------------- /packages/cfw/src/web-io/ResponseInit.ts: -------------------------------------------------------------------------------- 1 | export default ResponseInit; 2 | -------------------------------------------------------------------------------- /packages/cfw/src/web-io/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Request } from './Request'; 2 | export type { default as RequestInit } from './RequestInit'; 3 | export { default as Response } from './Response'; 4 | export type { default as ResponseInit } from './ResponseInit'; 5 | export { default as Headers } from './Headers'; 6 | export type { default as HeadersInit } from './HeadersInit'; 7 | export type { default as BodyInit } from './BodyInit'; 8 | -------------------------------------------------------------------------------- /packages/cfw/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "build", 5 | "declaration": true, 6 | "declarationDir": "build/dts", 7 | "emitDeclarationOnly": true 8 | }, 9 | "exclude": ["src/**/*.test.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /packages/cfw/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["@cloudflare/workers-types", "vitest/globals"] 5 | }, 6 | "include": ["src"] 7 | } 8 | -------------------------------------------------------------------------------- /packages/cfw/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | globals: true, 6 | setupFiles: ['src/test/setup.ts'], 7 | }, 8 | resolve: { 9 | preserveSymlinks: true, 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /packages/core/.eslintignore: -------------------------------------------------------------------------------- 1 | lib/ 2 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nbit/core", 3 | "version": "0.1.0", 4 | "scripts": { 5 | "lint": "eslint --max-warnings 0 \"src/**/*.ts\"", 6 | "typecheck": "tsc --noEmit", 7 | "format": "prettier --write src", 8 | "format:check": "prettier --check src", 9 | "unit": "vitest run", 10 | "test": "yarn lint && yarn typecheck && yarn format:check && yarn unit", 11 | "clean": "rm -rf .turbo lib coverage" 12 | }, 13 | "dependencies": {}, 14 | "devDependencies": { 15 | "@types/node": "^16.18.40", 16 | "eslint": "^8.19.0", 17 | "eslint-config-xt": "^1.7.0", 18 | "expect-type": "^0.13.0", 19 | "node-fetch": "^2.6.12", 20 | "prettier": "^2.7.1", 21 | "ts-node-dev": "^2.0.0", 22 | "typescript": "^4.7.4", 23 | "vitest": "^0.34.1" 24 | }, 25 | "eslintConfig": { 26 | "extends": "xt", 27 | "rules": { 28 | "@typescript-eslint/consistent-type-imports": "warn" 29 | } 30 | }, 31 | "prettier": { 32 | "singleQuote": true, 33 | "trailingComma": "all", 34 | "arrowParens": "always" 35 | }, 36 | "license": "ISC" 37 | } 38 | -------------------------------------------------------------------------------- /packages/core/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const PLATFORM = 'core'; 2 | -------------------------------------------------------------------------------- /packages/core/src/core/CustomRequest.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { Request, type Headers } from '../web-io'; 3 | import type { JSONValue, MethodNoBody } from '../types'; 4 | 5 | import { HttpError } from './HttpError'; 6 | import { parseUrl } from './support/parseUrl'; 7 | 8 | // TODO: Remove the conditional type when Bun types are updated 9 | type BodyStream = Request extends { body: infer T } ? Exclude : never; 10 | type BodyAccessorArgs = M extends MethodNoBody 11 | ? [ERROR: 'NO_BODY_ALLOWED_FOR_METHOD'] 12 | : []; 13 | 14 | const canHaveNullBody = new Set(['GET', 'DELETE', 'HEAD', 'OPTIONS']); 15 | 16 | export class CustomRequest { 17 | public request: Request; 18 | readonly method: M; 19 | readonly url: string; 20 | readonly headers: Headers; 21 | // These are the custom ones 22 | readonly path: string; 23 | readonly search: string; 24 | readonly query: URLSearchParams; 25 | readonly params: { [K in Params]: string }; 26 | public _fallbackBody: BodyStream | undefined; 27 | 28 | constructor(request: Request) { 29 | this.request = request; 30 | const { method, url, headers } = request; 31 | this.method = method as M; 32 | this.url = url; 33 | this.headers = headers; 34 | // Attach some custom fields 35 | const { pathname, search, searchParams } = parseUrl(url); 36 | this.path = pathname; 37 | this.search = search; 38 | this.query = searchParams; 39 | this.params = {} as { [K in Params]: string }; 40 | } 41 | 42 | get body(): M extends MethodNoBody ? null : BodyStream { 43 | // TODO: Remove the `as any` hack when Bun types are updated 44 | const body = (this.request as any).body as BodyStream | null; 45 | // Ensure that for requests that can have a body we never return null 46 | if (!canHaveNullBody.has(this.method) && body == null) { 47 | const emptyBody = 48 | this._fallbackBody ?? (this._fallbackBody = createEmptyBody()); 49 | return emptyBody as any; 50 | } 51 | return body as any; 52 | } 53 | 54 | get bodyUsed() { 55 | // TODO: Remove the `as any` hack when Bun types are updated 56 | return Boolean((this.request as any).bodyUsed); 57 | } 58 | 59 | arrayBuffer(..._: BodyAccessorArgs) { 60 | return this.request.arrayBuffer(); 61 | } 62 | 63 | text(..._: BodyAccessorArgs) { 64 | return this.request.text(); 65 | } 66 | 67 | async json(..._: BodyAccessorArgs): Promise { 68 | const contentType = getContentType(this.headers); 69 | let message = 'Invalid JSON body'; 70 | if (contentType === 'application/json') { 71 | try { 72 | const parsed = await this.request.json(); 73 | return parsed as any; 74 | } catch (e) { 75 | message = e instanceof Error ? e.message : String(e); 76 | } 77 | } 78 | throw new HttpError(400, message); 79 | } 80 | 81 | // Note: Not implemented yet; we can implement this if we bump the minimum supported Node version to v14.18 82 | // blob() { 83 | // return this.request.blob(); 84 | // } 85 | } 86 | 87 | function getContentType(headers: Headers) { 88 | const contentType = headers.get('content-type'); 89 | if (contentType != null) { 90 | return (contentType.split(';')[0] ?? '').toLowerCase(); 91 | } 92 | } 93 | 94 | // This is a bit of a roundabout way to do this but it should work with any of 95 | // the supported platform's Request implementation. 96 | function createEmptyBody(): BodyStream { 97 | const request = new Request('http://localhost/', { 98 | method: 'POST', 99 | body: '', 100 | }); 101 | return request.body as any; 102 | } 103 | -------------------------------------------------------------------------------- /packages/core/src/core/CustomResponse.ts: -------------------------------------------------------------------------------- 1 | import { Response } from '../web-io'; 2 | 3 | import { StaticFile, type StaticFileInit } from './StaticFile'; 4 | 5 | export default class CustomResponse extends Response { 6 | static file(filePath: string, init?: StaticFileInit) { 7 | return new StaticFile(filePath, init); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/core/src/core/HttpError.ts: -------------------------------------------------------------------------------- 1 | type HttpErrorInit = { 2 | status: number; 3 | message?: string; 4 | }; 5 | 6 | type Args = [ 7 | status: number, 8 | message?: string | undefined, 9 | options?: ErrorOptions | undefined, 10 | ]; 11 | // This form is deprecated; remove in next major version 12 | type ArgsLegacy = [init: HttpErrorInit, options?: ErrorOptions | undefined]; 13 | 14 | type ArgsAll = Args | ArgsLegacy; 15 | 16 | export class HttpError extends Error { 17 | readonly status: number; 18 | 19 | constructor(status: number, message?: string, options?: ErrorOptions); 20 | constructor(init: HttpErrorInit, options?: ErrorOptions); 21 | constructor(...args: ArgsAll) { 22 | const [status, message, options] = normalizeArgs(args); 23 | super(message ?? String(status), options); 24 | this.status = status; 25 | } 26 | 27 | get name() { 28 | return this.constructor.name; 29 | } 30 | 31 | get [Symbol.toStringTag]() { 32 | return this.constructor.name; 33 | } 34 | } 35 | 36 | function normalizeArgs(args: ArgsAll): Args { 37 | if (typeof args[0] === 'number') { 38 | return args as Args; 39 | } 40 | const [{ status, message }, options] = args as ArgsLegacy; 41 | return [status, message, options]; 42 | } 43 | -------------------------------------------------------------------------------- /packages/core/src/core/Router.ts: -------------------------------------------------------------------------------- 1 | type Captures = Record; 2 | 3 | type Match = [T, Captures, [method: string, pattern: string]]; 4 | 5 | type Route = { 6 | method: string; 7 | pattern: string; 8 | matcher: (path: string) => Captures | null; 9 | payload: T; 10 | }; 11 | 12 | export type Router = { 13 | insert: (method: string, pattern: string, payload: T) => void; 14 | getMatches: (method: string, path: string) => Array>; 15 | }; 16 | 17 | export function createRouter() { 18 | const routes: Array> = []; 19 | 20 | return { 21 | insert(method: string, pattern: string, payload: T) { 22 | routes.push({ 23 | method, 24 | pattern, 25 | matcher: getMatcher(pattern), 26 | payload, 27 | }); 28 | }, 29 | getMatches(method: string, path: string) { 30 | const results: Array> = []; 31 | for (const route of routes) { 32 | if (route.method !== '*' && route.method !== method) { 33 | continue; 34 | } 35 | const captures = route.matcher(path); 36 | if (captures) { 37 | const { method, pattern, payload } = route; 38 | results.push([payload, captures, [method, pattern]]); 39 | } 40 | } 41 | return results; 42 | }, 43 | }; 44 | } 45 | 46 | function getMatcher(pattern: string) { 47 | const patternSegments = pattern.slice(1).split('/'); 48 | const hasPlaceholder = pattern.includes('/:'); 49 | const hasWildcard = patternSegments.includes('*'); 50 | const isStatic = !hasPlaceholder && !hasWildcard; 51 | return (path: string) => { 52 | const captures: Captures = {}; 53 | if (isStatic && path === pattern) { 54 | return captures; 55 | } 56 | const pathSegments = path.slice(1).split('/'); 57 | if (!hasWildcard && patternSegments.length !== pathSegments.length) { 58 | return null; 59 | } 60 | const length = Math.max(patternSegments.length, pathSegments.length); 61 | for (let i = 0; i < length; i++) { 62 | const patternSegment = patternSegments[i]; 63 | if (patternSegment === '*') { 64 | const remainder = pathSegments.slice(i); 65 | captures[patternSegment] = remainder.join('/'); 66 | return remainder.length ? captures : null; 67 | } 68 | const pathSegment = pathSegments[i]; 69 | if (!pathSegment || !patternSegment) { 70 | return null; 71 | } 72 | if (patternSegment.startsWith(':') && pathSegment) { 73 | const key = patternSegment.slice(1); 74 | captures[key] = pathSegment; 75 | } else if (patternSegment !== pathSegment) { 76 | return null; 77 | } 78 | } 79 | return captures; 80 | }; 81 | } 82 | -------------------------------------------------------------------------------- /packages/core/src/core/StaticFile.ts: -------------------------------------------------------------------------------- 1 | import type { ResponseInit } from '../web-io'; 2 | 3 | export type StaticFileOptions = { 4 | /** Max age (in seconds) for the Cache-Control header */ 5 | maxAge?: number | undefined; 6 | /** Include ETag and Last-Modified headers automatically (default: true) */ 7 | cachingHeaders?: boolean | undefined; 8 | }; 9 | 10 | export type StaticFileInit = ResponseInit & StaticFileOptions; 11 | 12 | export class StaticFile { 13 | readonly filePath: string; 14 | readonly responseInit: ResponseInit; 15 | readonly options: StaticFileOptions; 16 | 17 | constructor(filePath: string, init?: StaticFileInit) { 18 | this.filePath = filePath; 19 | const { status, statusText, headers, maxAge, cachingHeaders } = init ?? {}; 20 | this.responseInit = { 21 | status: status ?? 200, 22 | statusText: statusText ?? '', 23 | headers: headers ?? {}, 24 | }; 25 | this.options = { maxAge, cachingHeaders }; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/core/src/core/__tests__/CustomRequest.test.ts: -------------------------------------------------------------------------------- 1 | import { Request } from '../../web-io'; 2 | import { CustomRequest } from '../CustomRequest'; 3 | import { HttpError } from '../HttpError'; 4 | 5 | describe('CustomRequest', () => { 6 | it('should instantiate with full url', async () => { 7 | const request = new Request('https://example.com/foo/bar?a=1&b=_'); 8 | const customRequest = new CustomRequest(request); 9 | const { method, url, headers, path, search, query, params } = customRequest; 10 | expect({ method, url, path, search, params }).toEqual({ 11 | method: 'GET', 12 | url: 'https://example.com/foo/bar?a=1&b=_', 13 | path: '/foo/bar', 14 | search: '?a=1&b=_', 15 | params: {}, 16 | }); 17 | expect(Object.fromEntries(query)).toEqual({ a: '1', b: '_' }); 18 | expect(Object.fromEntries(headers)).toEqual({}); 19 | }); 20 | 21 | it('should throw if wrong content type', async () => { 22 | const request = new Request('http://localhost/foo', { 23 | method: 'post', 24 | headers: { 'content-type': 'text/plain' }, 25 | body: JSON.stringify({ foo: 1 }), 26 | }); 27 | const customRequest = new CustomRequest(request); 28 | await expect(customRequest.json()).rejects.toEqual( 29 | new HttpError(400, 'Invalid JSON body'), 30 | ); 31 | }); 32 | 33 | it('should throw if no content type', async () => { 34 | const request = new Request('http://localhost/foo', { 35 | method: 'post', 36 | body: JSON.stringify({ foo: 1 }), 37 | }); 38 | const customRequest = new CustomRequest(request); 39 | await expect(customRequest.json()).rejects.toEqual( 40 | new HttpError(400, 'Invalid JSON body'), 41 | ); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /packages/core/src/core/__tests__/CustomResponse.test.ts: -------------------------------------------------------------------------------- 1 | import { Response, Headers } from '../../web-io'; 2 | import CustomResponse from '../CustomResponse'; 3 | import { StaticFile } from '../StaticFile'; 4 | 5 | describe('CustomResponse', () => { 6 | it('should be a subclass of the native Response', async () => { 7 | const response = new CustomResponse('foo', { status: 418 }); 8 | expect(response instanceof Response).toBe(true); 9 | expect(response.status).toBe(418); 10 | // Temporarily disabled because statusText this is broken in Bun 11 | // https://github.com/oven-sh/bun/issues/866 12 | // expect(response.statusText).toBe("I'm a Teapot"); 13 | expect(response.headers instanceof Headers).toBe(true); 14 | expect(response.bodyUsed).toBe(false); 15 | }); 16 | 17 | it('should have the static methods from native Response', () => { 18 | const response = CustomResponse.json({ foo: 1 }); 19 | expect(response instanceof Response).toBe(true); 20 | expect(response instanceof CustomResponse).toBe(false); 21 | expect(response.status).toBe(200); 22 | const contentType = response.headers.get('Content-Type') ?? ''; 23 | // In some implementations there will be `;charset=utf-8` 24 | expect(contentType.split(';')[0]).toBe('application/json'); 25 | }); 26 | 27 | it('should have custom static method Response.file()', () => { 28 | const response = CustomResponse.file('path/to/foo.txt', { 29 | headers: { 'X-Custom-Thing': 'foo' }, 30 | maxAge: 700, 31 | }); 32 | expect(response instanceof StaticFile).toBe(true); 33 | expect(response.filePath).toBe('path/to/foo.txt'); 34 | expect(JSON.stringify(response.options)).toBe( 35 | JSON.stringify({ maxAge: 700 }), 36 | ); 37 | expect(JSON.stringify(response.responseInit)).toBe( 38 | JSON.stringify({ 39 | status: 200, 40 | statusText: '', 41 | headers: { 'X-Custom-Thing': 'foo' }, 42 | }), 43 | ); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /packages/core/src/core/__tests__/HttpError.test.ts: -------------------------------------------------------------------------------- 1 | import { HttpError } from '../HttpError'; 2 | 3 | describe('HttpError', () => { 4 | it('should work with status and message', () => { 5 | const error = new HttpError(400, 'Bad Input'); 6 | expect(error).toBeInstanceOf(HttpError); 7 | expect(error).toBeInstanceOf(Error); 8 | expect(error.status).toBe(400); 9 | expect(error.message).toBe('Bad Input'); 10 | expect(error.toString()).toBe('HttpError: Bad Input'); 11 | expect(error.name).toBe('HttpError'); 12 | expect(HttpError.name).toBe('HttpError'); 13 | expect(Object.prototype.toString.call(error)).toBe('[object HttpError]'); 14 | }); 15 | 16 | it('should work with status only', () => { 17 | const error = new HttpError(400); 18 | expect(error).toBeInstanceOf(HttpError); 19 | expect(error).toBeInstanceOf(Error); 20 | expect(error.status).toBe(400); 21 | expect(error.message).toBe('400'); 22 | expect(error.toString()).toBe('HttpError: 400'); 23 | expect(error.name).toBe('HttpError'); 24 | expect(HttpError.name).toBe('HttpError'); 25 | expect(Object.prototype.toString.call(error)).toBe('[object HttpError]'); 26 | }); 27 | 28 | it('should work with init object', () => { 29 | const error = new HttpError({ status: 400, message: 'Bad Input' }); 30 | expect(error).toBeInstanceOf(HttpError); 31 | expect(error).toBeInstanceOf(Error); 32 | expect(error.status).toBe(400); 33 | expect(error.message).toBe('Bad Input'); 34 | expect(error.toString()).toBe('HttpError: Bad Input'); 35 | expect(error.name).toBe('HttpError'); 36 | expect(HttpError.name).toBe('HttpError'); 37 | expect(Object.prototype.toString.call(error)).toBe('[object HttpError]'); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /packages/core/src/core/__tests__/Router.test.ts: -------------------------------------------------------------------------------- 1 | import { createRouter } from '../Router'; 2 | 3 | describe('Basic routing', () => { 4 | // Payload can be any type really. It's typically a route handler, but we'll 5 | // use something simple for testing. 6 | type Payload = { name: string }; 7 | let router = createRouter(); 8 | 9 | beforeEach(() => { 10 | router = createRouter(); 11 | router.insert('GET', '/', { name: 'home' }); 12 | router.insert('GET', '/about', { name: 'about' }); 13 | router.insert('GET', '/about', { name: 'about2' }); 14 | router.insert('GET', '/users/me', { name: 'user_me' }); 15 | router.insert('GET', '/users/:id', { name: 'user_get' }); 16 | router.insert('POST', '/users/:id', { name: 'user_post' }); 17 | router.insert('POST', '/users/:userId/pets/:petId', { name: 'pet' }); 18 | router.insert('*', '/login', { name: 'login' }); 19 | router.insert('PUT', '/files/*', { name: 'files' }); 20 | }); 21 | 22 | it('should not match incorrect method', () => { 23 | expect(router.getMatches('POST', '/')).toEqual([]); 24 | expect(router.getMatches('PUT', '/users/foo')).toEqual([]); 25 | expect(router.getMatches('POST', '/files/x')).toEqual([]); 26 | }); 27 | 28 | it('should not match incorrect path', () => { 29 | expect(router.getMatches('GET', '/foo')).toEqual([]); 30 | expect(router.getMatches('GET', '/users')).toEqual([]); 31 | expect(router.getMatches('GET', '/users/')).toEqual([]); 32 | }); 33 | 34 | it('should match correct method and path', () => { 35 | expect(router.getMatches('GET', '/')).toEqual([ 36 | // Each match is a tuple with payload and a captures object 37 | [{ name: 'home' }, {}, ['GET', '/']], 38 | ]); 39 | expect(router.getMatches('POST', '/users/123')).toEqual([ 40 | [{ name: 'user_post' }, { id: '123' }, ['POST', '/users/:id']], 41 | ]); 42 | expect(router.getMatches('GET', '/login')).toEqual([ 43 | [{ name: 'login' }, {}, ['*', '/login']], 44 | ]); 45 | expect(router.getMatches('POST', '/login')).toEqual([ 46 | [{ name: 'login' }, {}, ['*', '/login']], 47 | ]); 48 | }); 49 | 50 | it('should return multiple matches if applicable', () => { 51 | expect(router.getMatches('GET', '/about')).toEqual([ 52 | [{ name: 'about' }, {}, ['GET', '/about']], 53 | [{ name: 'about2' }, {}, ['GET', '/about']], 54 | ]); 55 | expect(router.getMatches('GET', '/users/me')).toEqual([ 56 | [{ name: 'user_me' }, {}, ['GET', '/users/me']], 57 | [{ name: 'user_get' }, { id: 'me' }, ['GET', '/users/:id']], 58 | ]); 59 | }); 60 | 61 | it('should capture multiple params', () => { 62 | expect(router.getMatches('POST', '/users/23/pets/4')).toEqual([ 63 | [ 64 | { name: 'pet' }, 65 | { userId: '23', petId: '4' }, 66 | ['POST', '/users/:userId/pets/:petId'], 67 | ], 68 | ]); 69 | }); 70 | 71 | it('should correctly handle wildcard paths', () => { 72 | expect(router.getMatches('PUT', '/files')).toEqual([]); 73 | expect(router.getMatches('PUT', '/files/')).toEqual([ 74 | [{ name: 'files' }, { '*': '' }, ['PUT', '/files/*']], 75 | ]); 76 | expect(router.getMatches('PUT', '/files/foo')).toEqual([ 77 | [{ name: 'files' }, { '*': 'foo' }, ['PUT', '/files/*']], 78 | ]); 79 | expect(router.getMatches('PUT', '/files/foo/bar.txt')).toEqual([ 80 | [{ name: 'files' }, { '*': 'foo/bar.txt' }, ['PUT', '/files/*']], 81 | ]); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /packages/core/src/core/__tests__/defineAdapter.test.ts: -------------------------------------------------------------------------------- 1 | import { expectTypeOf } from 'expect-type'; 2 | 3 | import { Request, Response } from '../../web-io'; 4 | import { defineAdapter } from '../defineAdapter'; 5 | 6 | type BodyStream = Exclude; 7 | 8 | describe('defineAdapter', () => { 9 | const createApplication = defineAdapter((_applicationOptions) => { 10 | return { 11 | onError: (request, error) => { 12 | return new Response(String(error), { status: 500 }); 13 | }, 14 | toResponse: async (request, result) => { 15 | if (result instanceof Response || result === undefined) { 16 | return result; 17 | } 18 | const { filePath, responseInit } = result; 19 | return new Response(filePath, responseInit); 20 | }, 21 | createNativeHandler: (handleRequest) => handleRequest, 22 | }; 23 | }); 24 | const { defineRoutes, attachRoutes } = createApplication({ 25 | errorHandler: (error) => { 26 | const { name, message } = error; 27 | if (message === 'Special error') { 28 | throw new Error('Thrown from errorHandler'); 29 | } 30 | return Response.json({ name, message }, { status: 500 }); 31 | }, 32 | }); 33 | const routes = defineRoutes((app) => [ 34 | app.get('/foo/:id', (request) => { 35 | return { id: request.params.id }; 36 | }), 37 | ]); 38 | 39 | it('should handle a normal route', async () => { 40 | const requestHandler = attachRoutes(routes); 41 | const request = new Request('http://localhost/foo/123'); 42 | const response = await requestHandler(request); 43 | expect(await response.json()).toEqual({ id: '123' }); 44 | }); 45 | 46 | it('should return 404 response for unknown route', async () => { 47 | const requestHandler = attachRoutes(routes); 48 | const request = new Request('http://localhost/baz'); 49 | const response = await requestHandler(request); 50 | expect(response.status).toBe(404); 51 | expect(await response.text()).toBe('Not found'); 52 | }); 53 | 54 | it('should allow custom 404 handler', async () => { 55 | const fallback = defineRoutes((app) => [ 56 | app.route('*', '/*', (_request) => { 57 | return new Response('Oops, 404', { status: 404 }); 58 | }), 59 | ]); 60 | const requestHandler = attachRoutes(routes, fallback); 61 | const request = new Request('http://localhost/baz'); 62 | const response = await requestHandler(request); 63 | expect(response.status).toBe(404); 64 | expect(await response.text()).toBe('Oops, 404'); 65 | }); 66 | 67 | it('should use TS to disallow body operations on non-body methods', () => { 68 | defineRoutes((app) => [ 69 | app.get('/', (request) => { 70 | expectTypeOf(request.method).toEqualTypeOf<'GET'>(); 71 | expectTypeOf(request.body).toEqualTypeOf(); 72 | // @ts-expect-error - Should not be able to call method that requires request body 73 | request.json(); 74 | // @ts-expect-error - Should not be able to call method that requires request body 75 | request.text(); 76 | // @ts-expect-error - Should not be able to call method that requires request body 77 | request.arrayBuffer(); 78 | }), 79 | app.delete('/', (request) => { 80 | expectTypeOf(request.method).toEqualTypeOf<'DELETE'>(); 81 | expectTypeOf(request.body).toEqualTypeOf(); 82 | }), 83 | app.post('/', (request) => { 84 | expectTypeOf(request.method).toEqualTypeOf<'POST'>(); 85 | expectTypeOf(request.body).toEqualTypeOf(); 86 | }), 87 | app.route('HEAD', '/', (request) => { 88 | expectTypeOf(request.method).toEqualTypeOf<'HEAD'>(); 89 | expectTypeOf(request.body).toEqualTypeOf(); 90 | }), 91 | app.route('POST', '/', (request) => { 92 | expectTypeOf(request.method).toEqualTypeOf<'POST'>(); 93 | expectTypeOf(request.body).toEqualTypeOf(); 94 | }), 95 | ]); 96 | }); 97 | 98 | it('should uppercase request method', async () => { 99 | const routes = defineRoutes((app) => [ 100 | app.route('get', '/', (request) => { 101 | expectTypeOf(request.method).toEqualTypeOf<'GET'>(); 102 | }), 103 | app.route('foo', '/', (request) => { 104 | expectTypeOf(request.method).toEqualTypeOf<'FOO'>(); 105 | }), 106 | ]); 107 | const serializable = routes.map(([method, path, handler]) => [ 108 | method, 109 | path, 110 | typeof handler, 111 | ]); 112 | expect(serializable).toEqual([ 113 | ['GET', '/', 'function'], 114 | ['FOO', '/', 'function'], 115 | ]); 116 | }); 117 | 118 | it('should invoke custom errorHandler', async () => { 119 | const routes = defineRoutes((app) => [ 120 | app.get('/error', () => { 121 | throw new Error('Oops'); 122 | }), 123 | ]); 124 | const requestHandler = attachRoutes(routes); 125 | const request = new Request('http://localhost/error'); 126 | const response = await requestHandler(request); 127 | expect(response.status).toBe(500); 128 | expect(await response.json()).toEqual({ name: 'Error', message: 'Oops' }); 129 | }); 130 | 131 | it('should fall back to default error handler if custom errorHandler throws', async () => { 132 | const routes = defineRoutes((app) => [ 133 | app.get('/error', () => { 134 | throw new Error('Special error'); 135 | }), 136 | ]); 137 | const requestHandler = attachRoutes(routes); 138 | const request = new Request('http://localhost/error'); 139 | const response = await requestHandler(request); 140 | expect(response.status).toBe(500); 141 | expect(await response.text()).toBe('Error: Thrown from errorHandler'); 142 | }); 143 | }); 144 | -------------------------------------------------------------------------------- /packages/core/src/core/__tests__/routeRequest.test.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from '../../web-io'; 2 | import { defineAdapter } from '../defineAdapter'; 3 | 4 | describe('routeRequest', () => { 5 | const createApplication = defineAdapter((_applicationOptions) => { 6 | return { 7 | onError: (request, error) => { 8 | return new Response(String(error), { status: 500 }); 9 | }, 10 | toResponse: async (request, result) => { 11 | if (result instanceof Response || result === undefined) { 12 | return result; 13 | } 14 | const { filePath, responseInit } = result; 15 | return new Response(filePath, responseInit); 16 | }, 17 | createNativeHandler: (handleRequest) => handleRequest, 18 | }; 19 | }); 20 | 21 | const { defineRoutes, createRequestHandler } = createApplication(); 22 | 23 | const routes = defineRoutes((app) => [ 24 | app.get('/', async (request) => { 25 | return { path: request.path }; 26 | }), 27 | app.get('/foo', async (_request) => { 28 | return Response.json( 29 | { foo: 42 }, 30 | { 31 | status: 418, 32 | statusText: 'I like tea', 33 | headers: { 'X-My-Header': 'hello' }, 34 | }, 35 | ); 36 | }), 37 | app.post('/auth', async (request) => { 38 | const body = await request.json(); 39 | return { body }; 40 | }), 41 | ]); 42 | 43 | it('should reflect that routes have been defined', async () => { 44 | expect(routes.length).toBe(3); 45 | const serializable = routes.map(([method, path, handler]) => [ 46 | method, 47 | path, 48 | typeof handler, 49 | ]); 50 | expect(serializable).toEqual([ 51 | ['GET', '/', 'function'], 52 | ['GET', '/foo', 'function'], 53 | ['POST', '/auth', 'function'], 54 | ]); 55 | const handleRequest = createRequestHandler(routes); 56 | expect(typeof handleRequest).toBe('function'); 57 | }); 58 | 59 | it('should handle a GET request', async () => { 60 | const handleRequest = createRequestHandler(routes); 61 | const request = new Request('http://localhost/'); 62 | const response = await handleRequest(request); 63 | expect(response.status).toBe(200); 64 | const contentType = response.headers.get('content-type') ?? ''; 65 | // In some implementations there will be `;charset=utf-8` 66 | expect(contentType.split(';')[0]).toBe('application/json'); 67 | const parsed = await response.json(); 68 | expect(parsed).toEqual({ path: '/' }); 69 | }); 70 | 71 | it('should handle custom response status and headers', async () => { 72 | const handleRequest = createRequestHandler(routes); 73 | const request = new Request('http://localhost/foo'); 74 | const response = await handleRequest(request); 75 | expect(response.status).toBe(418); 76 | // Temporarily disabled because statusText this is broken in Bun 77 | // https://github.com/oven-sh/bun/issues/866 78 | // expect(response.statusText).toBe('I like tea'); 79 | const contentType = response.headers.get('content-type') ?? ''; 80 | // In some implementations there will be `;charset=utf-8` 81 | expect(contentType.split(';')[0]).toBe('application/json'); 82 | expect(response.headers.get('x-my-header')).toBe('hello'); 83 | const parsed = await response.json(); 84 | expect(parsed).toEqual({ foo: 42 }); 85 | }); 86 | 87 | it('should handle a POST request with JSON body', async () => { 88 | const handleRequest = createRequestHandler(routes); 89 | const request = new Request('http://localhost/auth', { 90 | method: 'POST', 91 | headers: { 'Content-Type': 'application/json;charset=UTF-8' }, 92 | body: JSON.stringify({ foo: 1 }), 93 | }); 94 | const response = await handleRequest(request); 95 | expect(response.status).toBe(200); 96 | const contentType = response.headers.get('content-type') ?? ''; 97 | // In some implementations there will be `;charset=utf-8` 98 | expect(contentType.split(';')[0]).toBe('application/json'); 99 | const parsed = await response.json(); 100 | expect(parsed).toEqual({ body: { foo: 1 } }); 101 | }); 102 | 103 | it('should throw correct JSON serialization error', async () => { 104 | const routes = defineRoutes((app) => [ 105 | app.get('/a', async (_request) => { 106 | const data: Record = { a: 1 }; 107 | data.circ = data; 108 | return Response.json(data); 109 | }), 110 | app.get('/b', async (_request) => { 111 | const data: Record = { b: 2 }; 112 | data.circ = data; 113 | return data; 114 | }), 115 | ]); 116 | const handleRequest = createRequestHandler(routes); 117 | const request = new Request('http://localhost/a'); 118 | const response = await handleRequest(request); 119 | expect(response.status).toBe(500); 120 | const message = (await response.text()).split('\n')[0]; 121 | // Depending on JS engine, message will one of the following: 122 | // - TypeError: Converting circular structure to JSON 123 | // - TypeError: JSON.stringify cannot serialize cyclic structures. 124 | expect(message?.startsWith('TypeError:')).toBe(true); 125 | const request2 = new Request('http://localhost/b'); 126 | const response2 = await handleRequest(request2); 127 | expect(response2.status).toBe(500); 128 | const message2 = await response2.text(); 129 | const lines = message2.split('\n').map((s) => s.trim()); 130 | expect(lines[0]).toBe( 131 | 'StringifyError: Failed to stringify value returned from route handler: GET:/b', 132 | ); 133 | expect(lines[1]?.startsWith('TypeError:')).toBe(true); 134 | }); 135 | }); 136 | -------------------------------------------------------------------------------- /packages/core/src/core/__tests__/types.test.ts: -------------------------------------------------------------------------------- 1 | import { expectTypeOf } from 'expect-type'; 2 | 3 | import { type Request, type Headers, Response } from '../../web-io'; 4 | import CustomResponse from '../CustomResponse'; 5 | import type { CustomRequest } from '../CustomRequest'; 6 | import { type StaticFile } from '../StaticFile'; 7 | import type { JSONValue, Route } from '../../types'; 8 | import { defineAdapter } from '../defineAdapter'; 9 | 10 | describe('Types', () => { 11 | const createApplication = defineAdapter((_applicationOptions) => { 12 | return { 13 | onError: (request, error) => { 14 | return new Response(String(error), { status: 500 }); 15 | }, 16 | toResponse: async (request, result) => { 17 | if (result instanceof Response || result === undefined) { 18 | return result; 19 | } 20 | expectTypeOf(result).toEqualTypeOf(); 21 | const { filePath, responseInit } = result; 22 | return new Response(filePath, responseInit); 23 | }, 24 | createNativeHandler: (handleRequest) => handleRequest, 25 | }; 26 | }); 27 | 28 | it('should correctly enforce types on request and params', () => { 29 | const { defineRoutes } = createApplication(); 30 | 31 | const routes = defineRoutes((app) => [ 32 | app.get('/', async (request) => { 33 | const { method, path, params, headers, query, search } = request; 34 | expectTypeOf(method).toEqualTypeOf<'GET'>(); 35 | expectTypeOf(path).toEqualTypeOf(); 36 | expectTypeOf(headers).toEqualTypeOf(); 37 | expectTypeOf(query).toEqualTypeOf(); 38 | expectTypeOf(search).toEqualTypeOf(); 39 | // @ts-expect-error - Should not be able to access non-existent param 40 | params.foo; 41 | // Params should be an empty object 42 | expectTypeOf().toEqualTypeOf(); 43 | // @ts-expect-error - Should not be able to call .json() on GET request 44 | await request.json(); 45 | }), 46 | app.post('/file/:foo', async (request) => { 47 | const { method, params } = request; 48 | expectTypeOf(method).toEqualTypeOf<'POST'>(); 49 | expectTypeOf(params).toEqualTypeOf<{ foo: string }>(); 50 | expectTypeOf(params.foo).toEqualTypeOf(); 51 | // @ts-expect-error - Should not be able to access non-existent param 52 | params.bar; 53 | const promise = request.json(); 54 | expectTypeOf(promise).toEqualTypeOf>(); 55 | const body = await request.json(); 56 | expectTypeOf(body).toEqualTypeOf(); 57 | return {}; 58 | }), 59 | ]); 60 | 61 | // No getContext() specified so Request context is undefined 62 | type Context = undefined; 63 | 64 | expectTypeOf(routes).toEqualTypeOf>>(); 65 | }); 66 | 67 | it('should intersect Response with type returned from getContext', () => { 68 | const { defineRoutes } = createApplication({ 69 | // TODO: Test that we must return an object here 70 | getContext: (request) => { 71 | expectTypeOf(request).toEqualTypeOf(); 72 | expectTypeOf(request.headers).toEqualTypeOf(); 73 | const token = request.headers.get('authorization') ?? ''; 74 | return { 75 | token, 76 | getToken: () => token, 77 | authenticate: async () => { 78 | return token === 'foo'; 79 | }, 80 | }; 81 | }, 82 | }); 83 | 84 | // This is what is returned from getContext above. 85 | type Context = { 86 | token: string; 87 | getToken: () => string; 88 | authenticate: () => Promise; 89 | }; 90 | 91 | const routes = defineRoutes((app) => [ 92 | app.get('/', async (request) => { 93 | expectTypeOf(request).toEqualTypeOf< 94 | CustomRequest<'GET', never> & Context 95 | >(); 96 | expectTypeOf(request.method).toEqualTypeOf<'GET'>(); 97 | expectTypeOf(request.token).toEqualTypeOf(); 98 | expectTypeOf(request.getToken()).toEqualTypeOf(); 99 | expectTypeOf(request.authenticate()).toEqualTypeOf>(); 100 | return {}; 101 | }), 102 | app.get('/foo/:id/bar/:type', async (request) => { 103 | type Params = { id: string; type: string }; 104 | expectTypeOf(request.params).toEqualTypeOf(); 105 | type Param = keyof Params; 106 | expectTypeOf(request).toEqualTypeOf< 107 | CustomRequest<'GET', Param> & Context 108 | >(); 109 | expectTypeOf(request.token).toEqualTypeOf(); 110 | return {}; 111 | }), 112 | ]); 113 | expectTypeOf(routes).toEqualTypeOf>>(); 114 | }); 115 | 116 | it('should enforce valid return value from route handler', () => { 117 | const { defineRoutes } = createApplication(); 118 | const routes = defineRoutes((app) => [ 119 | // It should allow us to omit return, or otherwise return void 120 | app.get('/', (_request) => {}), 121 | app.get('/', async (_request) => {}), 122 | app.post('/', async (_request) => { 123 | return; 124 | }), 125 | // It should allow us to return undefined 126 | app.post('/', async (_request) => undefined), 127 | // It should allow us to return null 128 | app.post('/', async (_request) => null), 129 | // It should not allow us to return a number 130 | // @ts-expect-error Type 'number' is not assignable to type 'Response | StaticFile | JsonPayload | null | undefined' 131 | app.post('/', async (_request) => 5), 132 | // It should not allow us to return a string 133 | // @ts-expect-error Type 'string' is not assignable to type 'Response | StaticFile | JsonPayload | null | undefined' 134 | app.post('/', async (_request) => 'x'), 135 | // It should not allow us to return a boolean 136 | // @ts-expect-error Type 'boolean' is not assignable to type 'Response | StaticFile | JsonPayload | null | undefined' 137 | app.post('/', async (_request) => false), 138 | // It should allow us to return an array 139 | app.post('/', async (_request) => []), 140 | // It should allow us to return an object 141 | app.post('/', async (_request) => ({})), 142 | // It should allow us to return an instance of Response 143 | app.post('/', async (_request) => new Response('')), 144 | app.post('/', async (_request) => Response.json('foo')), 145 | // It should allow us to return an instance of StaticFile 146 | app.post('/', async (_request) => CustomResponse.file('/foo.txt')), 147 | ]); 148 | expectTypeOf(routes).toEqualTypeOf>>(); 149 | }); 150 | }); 151 | -------------------------------------------------------------------------------- /packages/core/src/core/defineAdapter.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import type { 3 | RequestOptions, 4 | FileServingOptions, 5 | Handler, 6 | Route, 7 | Expand, 8 | MaybePromise, 9 | ResponseOptions, 10 | Method, 11 | LooseUnion, 12 | } from '../types'; 13 | import { type Request, Response } from '../web-io'; 14 | 15 | import { createRouter } from './Router'; 16 | import { HttpError } from './HttpError'; 17 | import { CustomRequest } from './CustomRequest'; 18 | import { StaticFile } from './StaticFile'; 19 | import { defineErrors } from './support/defineErrors'; 20 | 21 | const Errors = defineErrors({ 22 | StringifyError: 23 | 'Failed to stringify value returned from route handler: {route}', 24 | }); 25 | 26 | type Options = Expand< 27 | RequestOptions & 28 | ResponseOptions & 29 | FileServingOptions & { 30 | /** 31 | * An optional way to define extra context (e.g. an auth method) that will 32 | * be added to each Request instance. 33 | */ 34 | getContext?: CtxGetter; 35 | } 36 | >; 37 | 38 | type ContextGetter = (request: Request) => object | undefined; 39 | 40 | type Adapter = { 41 | onError: (request: Request, error: Error) => MaybePromise; 42 | toResponse: ( 43 | request: Request, 44 | result: Response | StaticFile | undefined, 45 | ) => MaybePromise; 46 | createNativeHandler: ( 47 | requestHandler: (request: Request) => Promise, 48 | ) => NativeHandler; 49 | }; 50 | 51 | type AnyFunction = (...args: Array) => any; 52 | 53 | type AdapterCreator = < 54 | CtxGetter extends ContextGetter, 55 | >( 56 | applicationOptions: Options, 57 | ) => Adapter; 58 | 59 | export function defineAdapter( 60 | createAdapter: AdapterCreator, 61 | ) { 62 | const createApplication = < 63 | CtxGetter extends ContextGetter = (request: Request) => undefined, 64 | >( 65 | applicationOptions: Options = {}, 66 | ) => { 67 | const { getContext, errorHandler } = applicationOptions; 68 | type RequestContext = ReturnType; 69 | const app = getApp(); 70 | type App = typeof app; 71 | 72 | const adapter = createAdapter(applicationOptions); 73 | 74 | const defineRoutes = ( 75 | fn: (app: App) => Array>, 76 | ): Array> => fn(app); 77 | 78 | const createRequestHandler = ( 79 | ...routeLists: Array>> 80 | ) => { 81 | const router = createRouter(); 82 | for (const routeList of routeLists) { 83 | for (const [method, pattern, handler] of routeList) { 84 | router.insert(method, pattern, handler); 85 | } 86 | } 87 | const routeRequest = async (request: Request) => { 88 | const context = getContext?.(request); 89 | const customRequest = new CustomRequest(request); 90 | if (context) { 91 | Object.assign(customRequest, context); 92 | } 93 | const { method, path } = customRequest; 94 | const matches = router.getMatches(method, path); 95 | for (const [handler, captures, route] of matches) { 96 | Object.assign(customRequest, { params: captures }); 97 | const result = await handler(customRequest); 98 | if (result !== undefined) { 99 | let resolvedResponse: Response | StaticFile; 100 | if (result instanceof Response || result instanceof StaticFile) { 101 | resolvedResponse = result; 102 | } else { 103 | try { 104 | resolvedResponse = Response.json(result); 105 | } catch (e) { 106 | const [method, pattern] = route; 107 | throw new Errors.StringifyError( 108 | { route: `${method}:${pattern}` }, 109 | { cause: toError(e) }, 110 | ); 111 | } 112 | } 113 | return await adapter.toResponse(request, resolvedResponse); 114 | } 115 | } 116 | return await adapter.toResponse(request, undefined); 117 | }; 118 | return async (request: Request): Promise => { 119 | try { 120 | const response = await routeRequest(request); 121 | if (response) { 122 | return response; 123 | } 124 | } catch (e) { 125 | if (e instanceof HttpError) { 126 | const { status, message } = e; 127 | // TODO: Support a custom renderer from applicationOptions 128 | return new Response(message, { status }); 129 | } 130 | const error = toError(e); 131 | if (errorHandler) { 132 | try { 133 | return await errorHandler(error); 134 | } catch (e) { 135 | return await adapter.onError(request, toError(e)); 136 | } 137 | } 138 | return await adapter.onError(request, error); 139 | } 140 | // TODO: Support a custom 404 renderer from applicationOptions 141 | return new Response('Not found', { status: 404 }); 142 | }; 143 | }; 144 | 145 | const attachRoutes = ( 146 | ...routeLists: Array>> 147 | ) => { 148 | const handleRequest = createRequestHandler(...routeLists); 149 | return adapter.createNativeHandler(handleRequest); 150 | }; 151 | 152 | return { defineRoutes, createRequestHandler, attachRoutes }; 153 | }; 154 | 155 | return createApplication; 156 | } 157 | 158 | // Using exclude here to flatten for readability 159 | type MethodOrWildcard = Exclude; 160 | 161 | type MethodOrWildcardOrString = LooseUnion; 162 | 163 | function getApp() { 164 | return { 165 | get:

( 166 | path: P, 167 | handler: Handler<'GET', P, RequestContext>, 168 | ) => ['GET', path as string, handler] as Route, 169 | post:

( 170 | path: P, 171 | handler: Handler<'POST', P, RequestContext>, 172 | ) => ['POST', path as string, handler] as Route, 173 | put:

( 174 | path: P, 175 | handler: Handler<'PUT', P, RequestContext>, 176 | ) => ['PUT', path as string, handler] as Route, 177 | delete:

( 178 | path: P, 179 | handler: Handler<'DELETE', P, RequestContext>, 180 | ) => ['DELETE', path as string, handler] as Route, 181 | route: ( 182 | method: M, 183 | path: P, 184 | handler: Handler, P, RequestContext>, 185 | ) => 186 | [method.toUpperCase(), path as string, handler] as Route, 187 | }; 188 | } 189 | 190 | function toError(e: unknown) { 191 | return e instanceof Error ? e : new Error(String(e)); 192 | } 193 | -------------------------------------------------------------------------------- /packages/core/src/core/index.ts: -------------------------------------------------------------------------------- 1 | export { defineAdapter } from './defineAdapter'; 2 | export { HttpError } from './HttpError'; 3 | export type { Router } from './Router'; 4 | -------------------------------------------------------------------------------- /packages/core/src/core/support/__tests__/mimeTypes.test.ts: -------------------------------------------------------------------------------- 1 | import { getMimeTypeFromExt, getExtForMimeType } from '../mimeTypes'; 2 | 3 | describe('mimeTypes', () => { 4 | it('should look up mime type from file extension', () => { 5 | expect(getMimeTypeFromExt('htm')).toEqual('text/html'); 6 | expect(getMimeTypeFromExt('html')).toEqual('text/html'); 7 | expect(getMimeTypeFromExt('png')).toEqual('image/png'); 8 | expect(getMimeTypeFromExt('asdf')).toEqual(undefined); 9 | }); 10 | 11 | it('should look up extension from mime type', () => { 12 | expect(getExtForMimeType('text/html')).toEqual('html'); 13 | expect(getExtForMimeType('application/json')).toEqual('json'); 14 | expect(getExtForMimeType('application/asdf')).toEqual(undefined); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /packages/core/src/core/support/createMeta.ts: -------------------------------------------------------------------------------- 1 | export function createMeta() { 2 | const weakMap = new WeakMap(); 3 | const get = (object: object): T | undefined => { 4 | return weakMap.get(object); 5 | }; 6 | const set = (object: O, value: T): O => { 7 | weakMap.set(object, value); 8 | return object; 9 | }; 10 | return [get, set] as const; 11 | } 12 | -------------------------------------------------------------------------------- /packages/core/src/core/support/defineErrors.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This provides a way to create a set of pre-defined custom errors. The reason 3 | * we'd do this is so that our application code can easily determine if a given 4 | * error is of some known kind by checking the `name` property or by using 5 | * `instanceof`. 6 | */ 7 | 8 | import type { Expand } from '../../types'; 9 | 10 | type StringWithPlaceholder = `${string}{${string}}${string}`; 11 | 12 | type Parse = 13 | T extends `${infer _Start}{${infer Var}}${infer Rest}` 14 | ? Rest extends StringWithPlaceholder 15 | ? Expand<{ [K in Var]: unknown } & Parse> 16 | : { [K in Var]: unknown } 17 | : never; 18 | 19 | export function defineErrors>( 20 | input: T, 21 | ): { 22 | [K in keyof T as K extends `${string}Error` 23 | ? K 24 | : never]: T[K] extends StringWithPlaceholder 25 | ? new (params: Parse, options?: ErrorOptions) => Error 26 | : new (params?: null, options?: ErrorOptions) => Error; 27 | } { 28 | return Object.fromEntries( 29 | Object.entries(input).map(([name, message]) => [ 30 | name, 31 | Object.defineProperties( 32 | class extends Error { 33 | constructor( 34 | params?: Record, 35 | options?: ErrorOptions, 36 | ) { 37 | let resolvedMessage = params 38 | ? resolveMessage(message, params) 39 | : message; 40 | if (options?.cause) { 41 | resolvedMessage += '\n' + indent(String(options.cause)); 42 | } 43 | super(resolvedMessage, options); 44 | } 45 | get name() { 46 | return name; 47 | } 48 | get [Symbol.toStringTag]() { 49 | return name; 50 | } 51 | }, 52 | { 53 | // Defining the name property here is only necessary because we're 54 | // using an anonymous class 55 | name: { value: name, configurable: true }, 56 | }, 57 | ), 58 | ]), 59 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 60 | ) as any; 61 | } 62 | 63 | function resolveMessage(message: string, params: Record) { 64 | return message.replace(/\{(.*?)\}/g, (_, key) => { 65 | return params[key] == null ? '' : String(params[key]); 66 | }); 67 | } 68 | 69 | function indent(message: string) { 70 | const lineBreak = /\r\n|\r|\n/; 71 | return message 72 | .split(lineBreak) 73 | .map((line) => ' ' + line) 74 | .join('\n'); 75 | } 76 | -------------------------------------------------------------------------------- /packages/core/src/core/support/getPlatform.ts: -------------------------------------------------------------------------------- 1 | import { PLATFORM } from '../../constants'; 2 | 3 | /** 4 | * The sole purpose of this function is as a helper when testing that symlink'd 5 | * modules work correctly. It imports the PLATFORM constant from two directories 6 | * up which intentionally breaks out of the symlink'd `core` directory. 7 | */ 8 | export function getPlatform() { 9 | return PLATFORM; 10 | } 11 | -------------------------------------------------------------------------------- /packages/core/src/core/support/mimeTypes.ts: -------------------------------------------------------------------------------- 1 | // The 73 most common mime types and their corresponding file extension(s) 2 | // Source: https://github.com/mdn/content/blob/8833cdb/files/en-us/web/http/basics_of_http/mime_types/common_types/index.md 3 | const mimeTypeList = 4 | 'audio/aac=aac&application/x-abiword=abw&application/x-freearc=arc&image/avif=avif&video/x-msvideo=avi&application/vnd.amazon.ebook=azw&application/octet-stream=bin&image/bmp=bmp&application/x-bzip=bz&application/x-bzip2=bz2&application/x-cdf=cda&application/x-csh=csh&text/css=css&text/csv=csv&application/msword=doc&application/vnd.openxmlformats-officedocument.wordprocessingml.document=docx&application/vnd.ms-fontobject=eot&application/epub+zip=epub&application/gzip=gz&image/gif=gif&text/html=html,htm&image/vnd.microsoft.icon=ico&text/calendar=ics&application/java-archive=jar&image/jpeg=jpeg,jpg&text/javascript=js,mjs&application/json=json&application/ld+json=jsonld&audio/midi+audio/x-midi=midi,mid&audio/mpeg=mp3&video/mp4=mp4&video/mpeg=mpeg&application/vnd.apple.installer+xml=mpkg&application/vnd.oasis.opendocument.presentation=odp&application/vnd.oasis.opendocument.spreadsheet=ods&application/vnd.oasis.opendocument.text=odt&audio/ogg=oga&video/ogg=ogv&application/ogg=ogx&audio/opus=opus&font/otf=otf&image/png=png&application/pdf=pdf&application/x-httpd-php=php&application/vnd.ms-powerpoint=ppt&application/vnd.openxmlformats-officedocument.presentationml.presentation=pptx&application/vnd.rar=rar&application/rtf=rtf&application/x-sh=sh&image/svg+xml=svg&application/x-shockwave-flash=swf&application/x-tar=tar&image/tiff=tif,tiff&video/mp2t=ts&font/ttf=ttf&text/plain=txt&application/vnd.visio=vsd&audio/wav=wav&audio/webm=weba&video/webm=webm&image/webp=webp&font/woff=woff&font/woff2=woff2&application/xhtml+xml=xhtml&application/vnd.ms-excel=xls&application/vnd.openxmlformats-officedocument.spreadsheetml.sheet=xlsx&application/xml=xml&application/vnd.mozilla.xul+xml=xul&application/zip=zip&video/3gpp=3gp&video/3gpp2=3g2&application/x-7z-compressed=7z'; 5 | 6 | const mimeToExtensions = new Map( 7 | mimeTypeList.split('&').map((item) => { 8 | const [mime = '', exts = ''] = item.split('='); 9 | return [mime, exts.split(',')]; 10 | }), 11 | ); 12 | 13 | const extToMime = new Map(); 14 | for (let [mime, exts] of mimeToExtensions) { 15 | for (let ext of exts) { 16 | extToMime.set(ext, mime); 17 | } 18 | } 19 | 20 | export function getMimeTypeFromExt(ext: string) { 21 | return extToMime.get(ext.toLowerCase()); 22 | } 23 | 24 | export function getExtForMimeType(mimeType: string) { 25 | const extensions = mimeToExtensions.get(mimeType.toLowerCase()); 26 | return extensions ? extensions[0] : undefined; 27 | } 28 | -------------------------------------------------------------------------------- /packages/core/src/core/support/parseUrl.ts: -------------------------------------------------------------------------------- 1 | // This is required by `new URL()` if the url is not fully-qualified (e.g. just 2 | // a pathname). In most cases it doesn't matter what value we use here. 3 | // See: https://nodejs.org/docs/latest-v12.x/api/url.html#url_url 4 | const URL_BASE = 'http://0.0.0.0'; 5 | 6 | export function parseUrl(url: string) { 7 | return new URL(url, URL_BASE); 8 | } 9 | -------------------------------------------------------------------------------- /packages/core/src/core/support/tryAsync.ts: -------------------------------------------------------------------------------- 1 | export async function tryAsync(fn: () => Promise): Promise { 2 | try { 3 | return await fn(); 4 | } catch (e) { 5 | return null; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/core/src/fs/__tests__/resolveFilePath.test.ts: -------------------------------------------------------------------------------- 1 | import { resolveFilePath } from '../resolveFilePath'; 2 | 3 | describe('resolveFilePath', () => { 4 | it('should resolve allowed paths relative to root', () => { 5 | const options = { 6 | root: '/foo', 7 | allowStaticFrom: ['public', 'other'], 8 | }; 9 | expect( 10 | // should resolve since public is allowed 11 | resolveFilePath('public/bar.html', options), 12 | ).toEqual(['/foo/public/bar.html', 'public']); 13 | expect( 14 | // should not resolve 15 | resolveFilePath('foo/bar.html', options), 16 | ).toEqual(null); 17 | expect(resolveFilePath('index.html', options)).toBe(null); 18 | expect( 19 | // other is also allowed 20 | resolveFilePath('other/thing', options), 21 | ).toEqual(['/foo/other/thing', 'other']); 22 | expect( 23 | // should not be allowed to traverse outside of root 24 | resolveFilePath('public/../bar.html', options), 25 | ).toEqual(null); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /packages/core/src/fs/caching.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Adapted from 3 | * https://github.com/http-party/http-server/blob/b0cb863/lib/core/index.js 4 | * which is licensed under the MIT license. 5 | */ 6 | import type { Headers } from '../web-io'; 7 | 8 | import type { FileStats } from './fileServing'; 9 | 10 | export function shouldSend304( 11 | headers: Headers, 12 | serverLastModified: Date, 13 | serverEtag: string, 14 | ) { 15 | const clientModifiedSince = headers.get('if-modified-since'); 16 | const clientEtag = headers.get('if-none-match'); 17 | let clientModifiedDate; 18 | 19 | if (!clientModifiedSince && !clientEtag) { 20 | // Client did not provide any conditional caching headers 21 | return false; 22 | } 23 | 24 | if (clientModifiedSince) { 25 | // Catch "illegal access" dates that will crash v8 26 | try { 27 | clientModifiedDate = Date.parse(clientModifiedSince); 28 | } catch (err) { 29 | return false; 30 | } 31 | 32 | // TODO: Better checking here 33 | if (new Date(clientModifiedDate).toString() === 'Invalid Date') { 34 | return false; 35 | } 36 | // If the client's copy is older than the server's, don't return 304 37 | if (clientModifiedDate < serverLastModified.valueOf()) { 38 | return false; 39 | } 40 | } 41 | 42 | if (clientEtag) { 43 | // TODO: Should we trim the `W/` before comparing? 44 | if (clientEtag !== serverEtag) { 45 | return false; 46 | } 47 | } 48 | 49 | return true; 50 | } 51 | 52 | export function generateEtag(stats: FileStats) { 53 | const datePart = stats.mtimeMs.toString(16).padStart(11, '0'); 54 | const sizePart = stats.size.toString(16); 55 | return `W/"${sizePart}${datePart}"`; 56 | } 57 | -------------------------------------------------------------------------------- /packages/core/src/fs/fileServing.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable dot-notation */ 2 | import { extname } from 'path'; 3 | 4 | import type { StaticFileOptions } from '../core/StaticFile'; 5 | import type { Headers } from '../web-io'; 6 | import { getMimeTypeFromExt } from '../core/support/mimeTypes'; 7 | 8 | import { generateEtag, shouldSend304 } from './caching'; 9 | 10 | type Result = 11 | | { 12 | status: 304; 13 | } 14 | | { 15 | status: undefined; 16 | headers: Record; 17 | }; 18 | 19 | export type FileStats = { 20 | isFile: () => boolean; 21 | size: number; 22 | mtimeMs: number; 23 | }; 24 | 25 | const defaultOptions: StaticFileOptions = { 26 | cachingHeaders: true, 27 | }; 28 | 29 | export async function computeHeaders( 30 | requestHeaders: Headers, 31 | fullFilePath: string, 32 | fileStats: FileStats, 33 | options: StaticFileOptions = defaultOptions, 34 | ): Promise { 35 | const { cachingHeaders = true, maxAge } = options; 36 | 37 | if (!fileStats.isFile()) { 38 | return null; 39 | } 40 | 41 | const lastModified = new Date(fileStats.mtimeMs); 42 | const etag = generateEtag(fileStats); 43 | 44 | if (cachingHeaders) { 45 | const send304 = shouldSend304(requestHeaders, lastModified, etag); 46 | if (send304) { 47 | return { status: 304 }; 48 | } 49 | } 50 | 51 | const ext = extname(fullFilePath).slice(1); 52 | const headers: Record = { 53 | 'Content-Length': String(fileStats.size), 54 | 'Content-Type': getMimeTypeFromExt(ext) ?? 'application/octet-stream', 55 | }; 56 | if (cachingHeaders) { 57 | headers['ETag'] = etag; 58 | headers['Last-Modified'] = lastModified.toGMTString(); 59 | } 60 | if (maxAge !== undefined) { 61 | headers['Cache-Control'] = `max-age=${maxAge}`; 62 | } 63 | 64 | return { 65 | // In the case that we're not sending a 304, we don't want to specify the 66 | // status, it should use the default from the call site. 67 | status: undefined, 68 | headers, 69 | }; 70 | } 71 | -------------------------------------------------------------------------------- /packages/core/src/fs/index.ts: -------------------------------------------------------------------------------- 1 | export * from './caching'; 2 | export * from './fileServing'; 3 | export * from './resolveFilePath'; 4 | -------------------------------------------------------------------------------- /packages/core/src/fs/resolveFilePath.ts: -------------------------------------------------------------------------------- 1 | import { join, resolve } from 'path'; 2 | 3 | import type { FileServingOptions } from '../types'; 4 | 5 | export function resolveFilePath(filePath: string, options: FileServingOptions) { 6 | const { root = process.cwd(), allowStaticFrom = [] } = options; 7 | const projectRoot = resolve(root); 8 | const fullFilePath = join(projectRoot, filePath); 9 | for (let allowedPath of allowStaticFrom) { 10 | const fullAllowedPath = join(root, allowedPath); 11 | if (fullFilePath.startsWith(fullAllowedPath + '/')) { 12 | return [fullFilePath, allowedPath] as const; 13 | } 14 | } 15 | return null; 16 | } 17 | -------------------------------------------------------------------------------- /packages/core/src/test/node-fetch.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/array-type, @typescript-eslint/consistent-type-imports, import/order */ 2 | // Copied from https://unpkg.com/browse/@types/node-fetch@2.6.4/index.d.ts 3 | // The only modifications are: 4 | // - Add `static json()` to `class Response` 5 | // - Remove FormData 6 | // - Allow null for `body` in both Request and Response 7 | declare module 'node-fetch' { 8 | import { RequestOptions } from 'http'; 9 | import { URLSearchParams, URL } from 'url'; 10 | import { AbortSignal } from './externals'; 11 | 12 | export class Request extends Body { 13 | constructor(input: RequestInfo, init?: RequestInit); 14 | clone(): Request; 15 | context: RequestContext; 16 | headers: Headers; 17 | method: string; 18 | redirect: RequestRedirect; 19 | referrer: string; 20 | url: string; 21 | 22 | // node-fetch extensions to the whatwg/fetch spec 23 | agent?: 24 | | RequestOptions['agent'] 25 | | ((parsedUrl: URL) => RequestOptions['agent']); 26 | compress: boolean; 27 | counter: number; 28 | follow: number; 29 | hostname: string; 30 | port?: number | undefined; 31 | protocol: string; 32 | size: number; 33 | timeout: number; 34 | } 35 | 36 | export interface RequestInit { 37 | // whatwg/fetch standard options 38 | body?: BodyInit | null | undefined; 39 | headers?: HeadersInit | undefined; 40 | method?: string | undefined; 41 | redirect?: RequestRedirect | undefined; 42 | signal?: AbortSignal | null | undefined; 43 | 44 | // node-fetch extensions 45 | agent?: 46 | | RequestOptions['agent'] 47 | | ((parsedUrl: URL) => RequestOptions['agent']); // =null http.Agent instance, allows custom proxy, certificate etc. 48 | compress?: boolean | undefined; // =true support gzip/deflate content encoding. false to disable 49 | follow?: number | undefined; // =20 maximum redirect count. 0 to not follow redirect 50 | size?: number | undefined; // =0 maximum response body size in bytes. 0 to disable 51 | timeout?: number | undefined; // =0 req/res timeout in ms, it resets on redirect. 0 to disable (OS limit applies) 52 | 53 | // node-fetch does not support mode, cache or credentials options 54 | } 55 | 56 | export type RequestContext = 57 | | 'audio' 58 | | 'beacon' 59 | | 'cspreport' 60 | | 'download' 61 | | 'embed' 62 | | 'eventsource' 63 | | 'favicon' 64 | | 'fetch' 65 | | 'font' 66 | | 'form' 67 | | 'frame' 68 | | 'hyperlink' 69 | | 'iframe' 70 | | 'image' 71 | | 'imageset' 72 | | 'import' 73 | | 'internal' 74 | | 'location' 75 | | 'manifest' 76 | | 'object' 77 | | 'ping' 78 | | 'plugin' 79 | | 'prefetch' 80 | | 'script' 81 | | 'serviceworker' 82 | | 'sharedworker' 83 | | 'style' 84 | | 'subresource' 85 | | 'track' 86 | | 'video' 87 | | 'worker' 88 | | 'xmlhttprequest' 89 | | 'xslt'; 90 | export type RequestMode = 'cors' | 'no-cors' | 'same-origin'; 91 | export type RequestRedirect = 'error' | 'follow' | 'manual'; 92 | export type RequestCredentials = 'omit' | 'include' | 'same-origin'; 93 | 94 | export type RequestCache = 95 | | 'default' 96 | | 'force-cache' 97 | | 'no-cache' 98 | | 'no-store' 99 | | 'only-if-cached' 100 | | 'reload'; 101 | 102 | export class Headers implements Iterable<[string, string]> { 103 | constructor(init?: HeadersInit); 104 | forEach(callback: (value: string, name: string) => void): void; 105 | append(name: string, value: string): void; 106 | delete(name: string): void; 107 | get(name: string): string | null; 108 | has(name: string): boolean; 109 | raw(): { [k: string]: string[] }; 110 | set(name: string, value: string): void; 111 | 112 | // Iterable methods 113 | entries(): IterableIterator<[string, string]>; 114 | keys(): IterableIterator; 115 | values(): IterableIterator; 116 | [Symbol.iterator](): Iterator<[string, string]>; 117 | } 118 | 119 | type BlobPart = ArrayBuffer | ArrayBufferView | Blob | string; 120 | 121 | interface BlobOptions { 122 | type?: string | undefined; 123 | endings?: 'transparent' | 'native' | undefined; 124 | } 125 | 126 | export class Blob { 127 | constructor(blobParts?: BlobPart[], options?: BlobOptions); 128 | readonly type: string; 129 | readonly size: number; 130 | slice(start?: number, end?: number): Blob; 131 | text(): Promise; 132 | } 133 | 134 | export class Body { 135 | constructor( 136 | body?: any, 137 | opts?: { size?: number | undefined; timeout?: number | undefined }, 138 | ); 139 | arrayBuffer(): Promise; 140 | blob(): Promise; 141 | body: NodeJS.ReadableStream; 142 | bodyUsed: boolean; 143 | buffer(): Promise; 144 | json(): Promise; 145 | size: number; 146 | text(): Promise; 147 | textConverted(): Promise; 148 | timeout: number; 149 | } 150 | 151 | interface SystemError extends Error { 152 | code?: string | undefined; 153 | } 154 | 155 | export class FetchError extends Error { 156 | name: 'FetchError'; 157 | constructor(message: string, type: string, systemError?: SystemError); 158 | type: string; 159 | code?: string | undefined; 160 | errno?: string | undefined; 161 | } 162 | 163 | export class Response extends Body { 164 | constructor(body?: BodyInit | null, init?: ResponseInit); 165 | static error(): Response; 166 | static redirect(url: string, status: number): Response; 167 | static json(body: unknown, init?: ResponseInit): Response; 168 | clone(): Response; 169 | headers: Headers; 170 | ok: boolean; 171 | redirected: boolean; 172 | status: number; 173 | statusText: string; 174 | type: ResponseType; 175 | url: string; 176 | } 177 | 178 | export type ResponseType = 179 | | 'basic' 180 | | 'cors' 181 | | 'default' 182 | | 'error' 183 | | 'opaque' 184 | | 'opaqueredirect'; 185 | 186 | export interface ResponseInit { 187 | headers?: HeadersInit | undefined; 188 | size?: number | undefined; 189 | status?: number | undefined; 190 | statusText?: string | undefined; 191 | timeout?: number | undefined; 192 | url?: string | undefined; 193 | } 194 | 195 | interface URLLike { 196 | href: string; 197 | } 198 | 199 | export type HeadersInit = Headers | string[][] | { [key: string]: string }; 200 | // HeaderInit is exported to support backwards compatibility. See PR #34382 201 | export type HeaderInit = HeadersInit; 202 | export type BodyInit = 203 | | ArrayBuffer 204 | | ArrayBufferView 205 | | NodeJS.ReadableStream 206 | | string 207 | | URLSearchParams; 208 | export type RequestInfo = string | URLLike | Request; 209 | 210 | declare function fetch( 211 | url: RequestInfo, 212 | init?: RequestInit, 213 | ): Promise; 214 | 215 | declare namespace fetch { 216 | function isRedirect(code: number): boolean; 217 | } 218 | 219 | export default fetch; 220 | } 221 | -------------------------------------------------------------------------------- /packages/core/src/test/setup.ts: -------------------------------------------------------------------------------- 1 | import { Response, Headers, type ResponseInit } from 'node-fetch'; 2 | 3 | // Polyfill for static methods which are in the standard but not supported by node-fetch 4 | Object.assign(Response, { 5 | json: (body: unknown, init?: ResponseInit) => { 6 | const headers = new Headers(init?.headers); 7 | headers.set('Content-Type', 'application/json'); 8 | return new Response(JSON.stringify(body), { ...init, headers }); 9 | }, 10 | redirect: (url: string, status = 302) => { 11 | return new Response('', { 12 | status, 13 | headers: { Location: url }, 14 | }); 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /packages/core/src/types/global.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import type { JSONValue } from './json'; 3 | 4 | type Reviver = (this: any, key: string, value: any) => any; 5 | type Replacer = (this: any, key: string, value: any) => any; 6 | 7 | declare global { 8 | interface JSON { 9 | parse(text: string, reviver?: Reviver): JSONValue; 10 | stringify( 11 | value: T, 12 | replacer?: Replacer | Array | null, 13 | space?: string | number, 14 | ): undefined extends T 15 | ? T extends undefined 16 | ? undefined 17 | : string | undefined 18 | : string; 19 | } 20 | 21 | interface ObjectConstructor { 22 | keys( 23 | o: T, 24 | ): T extends Record 25 | ? Array 26 | : Array; 27 | } 28 | 29 | interface Date { 30 | toGMTString(): string; 31 | } 32 | } 33 | 34 | export {}; 35 | -------------------------------------------------------------------------------- /packages/core/src/types/http.ts: -------------------------------------------------------------------------------- 1 | import type { Headers, Response } from '../web-io'; 2 | import type { StaticFile, StaticFileOptions } from '../core/StaticFile'; 3 | import type { CustomRequest } from '../core/CustomRequest'; 4 | 5 | export type RequestOptions = { 6 | /** 7 | * The max number of bytes that will be buffered into memory when parsing a 8 | * request body into a format such as JSON. 9 | */ 10 | bodyParserMaxBufferSize?: number; 11 | }; 12 | 13 | export type ResponseOptions = { 14 | /** 15 | * This error handler, if specified, will be used to generate a response when 16 | * an exception was raised during the execution of any of the handlers. 17 | */ 18 | errorHandler?: (error: Error) => MaybePromise; 19 | }; 20 | 21 | export type FileServingOptions = { 22 | /** 23 | * The root from which file paths will be resolved when serving files. 24 | * Defaults to current working directory (or `/` in serverless environments). 25 | */ 26 | root?: string; 27 | /** 28 | * An array of paths (relative to `root`) from which static files are allowed 29 | * to be served when invoking `Response.file(filePath)`. If `allowStaticFrom` 30 | * is not specified, or if the fully-resolved filePath does not exist within 31 | * an allowed path, all files will be treated as non-existent, resulting in 32 | * 404. 33 | */ 34 | allowStaticFrom?: Array; 35 | /** 36 | * Overrides the default serveFile. Useful within tests or serverless 37 | * environments. 38 | * 39 | * This is invoked when a request handler returns `Response.file(filePath)`. 40 | * 41 | * If the `root` option is specified, `filePath` will be resolved relative to 42 | * that, otherwise it will be resolved relative to process.cwd (or `/` in 43 | * serverless environments). The fully resolved path will be checked against 44 | * `allowStaticFrom` and if that check does not pass (or allowStaticFrom is 45 | * not specified) then `serveFile` will never be invoked and a 404 response 46 | * will be sent. 47 | * 48 | * Return null to indicate no valid file exists at the given path, resulting 49 | * in 404 response. 50 | * 51 | * Note: A comprehensive implementation of serveFile should specify response 52 | * headers such as Content-Type, Last-Modified, ETag and Cache-Control (see 53 | * packages/core/src/fs/fileServing.ts). It should also potentially send a 54 | * response status of 304 based on request headers like `If-Modified-Since` 55 | * and/or `If-None-Match` (see packages/core/src/fs/caching.ts). 56 | */ 57 | serveFile?: (params: { 58 | /** The original path specified in Response.file(...) */ 59 | filePath: string; 60 | /** The fully resolved path starting with `/` */ 61 | fullFilePath: string; 62 | /** Specified at call site, e.g. Response.file(filePath, { status }) */ 63 | status: number; 64 | /** Specified at call site, e.g. Response.file(filePath, { statusText }) */ 65 | statusText: string; 66 | /** Specified at call site, e.g. Response.file(filePath, { headers }) */ 67 | headers: Headers; 68 | /** File serving options such as `maxAge`, specified at call site, e.g. Response.file(filePath, { maxAge }) */ 69 | options: StaticFileOptions; 70 | }) => Promise; 71 | }; 72 | 73 | export type MaybePromise = T | Promise; 74 | 75 | export type MaybeIntersect = U extends undefined ? T : T & U; 76 | 77 | type JsonPayload = Record | Array; 78 | 79 | type ExtractParams = string extends T 80 | ? never 81 | : T extends `${infer _Start}:${infer Param}/${infer Rest}` 82 | ? Param | ExtractParams 83 | : T extends `${infer _Start}:${infer Param}` 84 | ? Param 85 | : never; 86 | 87 | // List of known methods for auto-complete 88 | export type Method = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'HEAD' | 'OPTIONS'; 89 | 90 | // Methods that are not allowed to have a request body 91 | export type MethodNoBody = Exclude; 92 | 93 | export type Handler = ( 94 | request: MaybeIntersect>, RequestContext>, 95 | ) => MaybePromise< 96 | Response | StaticFile | JsonPayload | null | undefined | void 97 | >; 98 | 99 | export type Route = [ 100 | string, 101 | string, 102 | Handler, 103 | ]; 104 | -------------------------------------------------------------------------------- /packages/core/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './http'; 2 | export * from './json'; 3 | export * from './utilities'; 4 | -------------------------------------------------------------------------------- /packages/core/src/types/json.ts: -------------------------------------------------------------------------------- 1 | export type JSONValue = 2 | | null 3 | | boolean 4 | | number 5 | | string 6 | | Array 7 | | { [key: string]: JSONValue }; 8 | 9 | export type JSONObject = { [key: string]: JSONValue }; 10 | -------------------------------------------------------------------------------- /packages/core/src/types/utilities.ts: -------------------------------------------------------------------------------- 1 | // A helpful utility type to expand an intersection of two or more object. 2 | // Source: https://stackoverflow.com/a/57683652 3 | export type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; 4 | 5 | /** 6 | * A way to specify a union of string literals (for autocomplete) but also allow 7 | * any string. 8 | * See: https://github.com/Microsoft/TypeScript/issues/29729 9 | * Similar to this implementation: 10 | * https://github.com/sindresorhus/type-fest/blob/e3234d7/source/literal-union.d.ts 11 | */ 12 | export type LooseUnion = 13 | | T 14 | // eslint-disable-next-line @typescript-eslint/ban-types 15 | | (string & {}); 16 | -------------------------------------------------------------------------------- /packages/core/src/web-io/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | Request, 3 | type RequestInit, 4 | Response, 5 | type ResponseInit, 6 | Headers, 7 | type HeadersInit, 8 | type BodyInit, 9 | } from 'node-fetch'; 10 | -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["vitest/globals"] 5 | }, 6 | "include": ["src"] 7 | } 8 | -------------------------------------------------------------------------------- /packages/core/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | globals: true, 6 | setupFiles: ['src/test/setup.ts'], 7 | }, 8 | resolve: { 9 | preserveSymlinks: true, 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /packages/express/index.d.ts: -------------------------------------------------------------------------------- 1 | export * from '../node/src/packages/express'; 2 | -------------------------------------------------------------------------------- /packages/express/index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('../node/build/express/index.js'); 2 | -------------------------------------------------------------------------------- /packages/express/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nbit/express", 3 | "version": "0.13.4", 4 | "files": [ 5 | "index.js", 6 | "index.d.ts" 7 | ], 8 | "main": "index.js", 9 | "types": "index.d.ts", 10 | "scripts": { 11 | "version": "yarn version", 12 | "clean": "rm -rf .turbo" 13 | }, 14 | "dependencies": {}, 15 | "devDependencies": {}, 16 | "license": "ISC" 17 | } 18 | -------------------------------------------------------------------------------- /packages/node/.eslintignore: -------------------------------------------------------------------------------- 1 | build/ 2 | -------------------------------------------------------------------------------- /packages/node/index.d.ts: -------------------------------------------------------------------------------- 1 | export * from './src'; 2 | -------------------------------------------------------------------------------- /packages/node/index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./build/node/index.js'); 2 | -------------------------------------------------------------------------------- /packages/node/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nbit/node", 3 | "version": "0.13.4", 4 | "files": [ 5 | "index.js", 6 | "index.d.ts" 7 | ], 8 | "main": "index.js", 9 | "types": "index.d.ts", 10 | "scripts": { 11 | "lint": "eslint --max-warnings 0 \"src/**/*.ts\"", 12 | "typecheck": "tsc --noEmit", 13 | "format": "prettier --write src", 14 | "format:check": "prettier --check src", 15 | "unit": "vitest run", 16 | "test": "yarn lint && yarn typecheck && yarn format:check && yarn unit", 17 | "clean": "rm -rf .turbo build coverage", 18 | "copy-files:express": "cp ../../README.md build/express/ && lean-package -i ../express/package.json > build/express/package.json", 19 | "copy-files:node": "cp ../../README.md build/node/ && lean-package -n @nbit/node > build/node/package.json", 20 | "copy-files": "yarn copy-files:node && yarn copy-files:express", 21 | "build": "rm -rf build && tsc -p tsconfig.build.json && rollup -c && rm -rf build/dts && yarn copy-files && prettier --write build", 22 | "version": "yarn version" 23 | }, 24 | "dependencies": {}, 25 | "devDependencies": { 26 | "@rollup/plugin-replace": "^4.0.0", 27 | "@rollup/plugin-typescript": "^8.3.3", 28 | "@types/express": "^4.17.13", 29 | "@types/node": "^16.18.40", 30 | "eslint": "^8.19.0", 31 | "eslint-config-xt": "^1.7.0", 32 | "lean-package": "^1.4.0", 33 | "prettier": "^2.7.1", 34 | "rollup": "^2.76.0", 35 | "rollup-plugin-cleanup": "^3.2.1", 36 | "rollup-plugin-dts": "^4.2.2", 37 | "ts-node-dev": "^2.0.0", 38 | "typescript": "^4.7.4", 39 | "vitest": "^0.34.1" 40 | }, 41 | "engines": { 42 | "node": "^14.18.0 || ^16.10.0 || >=18.0.0" 43 | }, 44 | "eslintConfig": { 45 | "extends": "xt", 46 | "rules": { 47 | "@typescript-eslint/consistent-type-imports": "warn" 48 | } 49 | }, 50 | "prettier": { 51 | "singleQuote": true, 52 | "trailingComma": "all", 53 | "arrowParens": "always" 54 | }, 55 | "license": "ISC" 56 | } 57 | -------------------------------------------------------------------------------- /packages/node/rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from '@rollup/plugin-typescript'; 2 | import dts from 'rollup-plugin-dts'; 3 | import cleanup from 'rollup-plugin-cleanup'; 4 | import replace from '@rollup/plugin-replace'; 5 | 6 | const external = [ 7 | 'fs', 8 | 'fs/promises', 9 | 'http', 10 | 'path', 11 | 'stream', 12 | 'stream/web', 13 | 'util', 14 | ]; 15 | 16 | export default [ 17 | { 18 | input: 'src/packages/node/index.ts', 19 | output: { 20 | dir: 'build/node', 21 | format: 'cjs', 22 | strict: false, 23 | esModule: false, 24 | }, 25 | external, 26 | plugins: [ 27 | typescript({ 28 | module: 'esnext', 29 | include: ['../**/*.ts'], 30 | }), 31 | replace({ 32 | preventAssignment: true, 33 | delimiters: ['', ''], 34 | values: { 35 | '(..._)': '()', 36 | }, 37 | }), 38 | cleanup({ 39 | extensions: ['js', 'ts'], 40 | }), 41 | ], 42 | }, 43 | { 44 | input: 'build/dts/packages/node/index.d.ts', 45 | output: { 46 | file: 'build/node/index.d.ts', 47 | format: 'es', 48 | }, 49 | external, 50 | plugins: [dts()], 51 | }, 52 | { 53 | input: 'src/packages/express/index.ts', 54 | output: { 55 | dir: 'build/express', 56 | format: 'cjs', 57 | strict: false, 58 | esModule: false, 59 | }, 60 | external, 61 | plugins: [ 62 | typescript({ 63 | module: 'esnext', 64 | include: ['../**/*.ts'], 65 | }), 66 | cleanup({ 67 | extensions: ['js', 'ts'], 68 | }), 69 | ], 70 | }, 71 | { 72 | input: 'build/dts/packages/express/index.d.ts', 73 | output: { 74 | file: 'build/express/index.d.ts', 75 | format: 'es', 76 | }, 77 | external, 78 | plugins: [dts()], 79 | }, 80 | ]; 81 | -------------------------------------------------------------------------------- /packages/node/src/__tests__/express.test.ts: -------------------------------------------------------------------------------- 1 | import { Readable } from 'stream'; 2 | 3 | import { createApplication } from '../express'; 4 | import Response from '../core/CustomResponse'; 5 | 6 | import { createMockExpress } from './helpers/createMockExpress'; 7 | 8 | const { defineRoutes, attachRoutes } = createApplication({ 9 | allowStaticFrom: ['public'], 10 | getContext: (request) => ({ 11 | auth: () => { 12 | return request.headers.get('authentication') ?? ''; 13 | }, 14 | }), 15 | }); 16 | 17 | const routes = defineRoutes((app) => [ 18 | app.get('/', () => { 19 | return { a: 1 }; 20 | }), 21 | app.get('/null', () => { 22 | return new Response(null, { status: 400 }); 23 | }), 24 | app.get('/buffer', () => { 25 | return new Response(Buffer.from('foo'), { 26 | status: 418, 27 | statusText: 'Yo', 28 | headers: { foo: 'x' }, 29 | }); 30 | }), 31 | app.get('/file', () => { 32 | return Response.file('public/foo.txt'); 33 | }), 34 | app.get('/stream', () => { 35 | const data = 'Hello'; 36 | return new Response(Readable.from(data, { objectMode: false })); 37 | }), 38 | app.get('/error', () => { 39 | throw new Error('Waat'); 40 | }), 41 | app.get('/error2', () => { 42 | throw new Error('Rethrow'); 43 | }), 44 | ]); 45 | 46 | describe('express', () => { 47 | const expressHandler = attachRoutes(routes); 48 | 49 | it('should handle a json response body', async () => { 50 | const [req, res, next, promise] = createMockExpress('/'); 51 | expressHandler(req, res, next); 52 | await promise; 53 | expect(next).toHaveBeenCalledTimes(0); 54 | expect(res.writeHead).toHaveBeenCalledTimes(1); 55 | expect(res.writeHead).toHaveBeenCalledWith(200, '', { 56 | 'Content-Type': 'application/json;charset=UTF-8', 57 | }); 58 | expect(res.write).toHaveBeenCalledTimes(1); 59 | expect(res.write).toHaveBeenCalledWith('{"a":1}'); 60 | expect(res.end).toHaveBeenCalledTimes(1); 61 | }); 62 | 63 | it('should handle a null response body', async () => { 64 | const [req, res, next, promise] = createMockExpress('/null'); 65 | expressHandler(req, res, next); 66 | await promise; 67 | expect(next).toHaveBeenCalledTimes(0); 68 | expect(res.writeHead).toHaveBeenCalledTimes(1); 69 | expect(res.writeHead).toHaveBeenCalledWith(400, '', {}); 70 | expect(res.write).toHaveBeenCalledTimes(0); 71 | expect(res.end).toHaveBeenCalledTimes(1); 72 | }); 73 | 74 | it('should handle a buffer response body', async () => { 75 | const [req, res, next, promise] = createMockExpress('/buffer'); 76 | expressHandler(req, res, next); 77 | await promise; 78 | expect(next).toHaveBeenCalledTimes(0); 79 | expect(res.writeHead).toHaveBeenCalledTimes(1); 80 | expect(res.writeHead).toHaveBeenCalledWith(418, 'Yo', { foo: 'x' }); 81 | expect(res.write).toHaveBeenCalledTimes(1); 82 | expect(res.write).toHaveBeenCalledWith(Buffer.from('foo')); 83 | expect(res.end).toHaveBeenCalledTimes(1); 84 | }); 85 | 86 | it('should forward an error to next', async () => { 87 | const [req, res, next, promise] = createMockExpress('/error'); 88 | expressHandler(req, res, next); 89 | await promise; 90 | expect(next).toHaveBeenCalledTimes(1); 91 | expect(next).toHaveBeenCalledWith(new Error('Waat')); 92 | expect(res.writeHead).toHaveBeenCalledTimes(0); 93 | expect(res.write).toHaveBeenCalledTimes(0); 94 | expect(res.end).toHaveBeenCalledTimes(0); 95 | }); 96 | 97 | it('should handle an error thrown by the framework', async () => { 98 | const [req, res, next, promise] = createMockExpress('/error2'); 99 | expressHandler(req, res, next); 100 | await promise; 101 | expect(next).toHaveBeenCalledTimes(1); 102 | expect(next).toHaveBeenCalledWith(new Error('Rethrow')); 103 | expect(res.writeHead).toHaveBeenCalledTimes(1); 104 | expect(res.writeHead).toHaveBeenCalledWith(500); 105 | expect(res.write).toHaveBeenCalledTimes(0); 106 | expect(res.end).toHaveBeenCalledTimes(1); 107 | expect(res.end).toHaveBeenCalledWith('Error: Rethrow'); 108 | }); 109 | 110 | it('should call next() on no route', async () => { 111 | const [req, res, next, promise] = createMockExpress('/nothing'); 112 | expressHandler(req, res, next); 113 | await promise; 114 | expect(next).toHaveBeenCalledTimes(1); 115 | expect(next).toHaveBeenCalledWith(); 116 | expect(res.writeHead).toHaveBeenCalledTimes(0); 117 | expect(res.write).toHaveBeenCalledTimes(0); 118 | expect(res.end).toHaveBeenCalledTimes(0); 119 | }); 120 | 121 | it('should use res.sendFile()', async () => { 122 | const [req, res, next, promise] = createMockExpress('/file'); 123 | expressHandler(req, res, next); 124 | await promise; 125 | expect(next).toHaveBeenCalledTimes(0); 126 | expect(res.status).toHaveBeenCalledTimes(1); 127 | expect(res.status).toHaveBeenCalledWith(200); 128 | expect(res.writeHead).toHaveBeenCalledTimes(0); 129 | expect(res.write).toHaveBeenCalledTimes(0); 130 | expect(res.end).toHaveBeenCalledTimes(0); 131 | expect(res.sendFile).toHaveBeenCalledTimes(1); 132 | expect(res.sendFile).toHaveBeenCalledWith( 133 | 'foo.txt', 134 | { 135 | headers: {}, 136 | lastModified: true, 137 | maxAge: undefined, 138 | root: 'public', 139 | }, 140 | next, 141 | ); 142 | }); 143 | 144 | it('should handle a stream response body', async () => { 145 | const [req, res, next, promise] = createMockExpress('/stream'); 146 | expressHandler(req, res, next); 147 | await promise; 148 | expect(next).toHaveBeenCalledTimes(0); 149 | expect(res.writeHead).toHaveBeenCalledTimes(1); 150 | expect(res.writeHead).toHaveBeenCalledWith(200, '', {}); 151 | expect(res.write).toHaveBeenCalledTimes(1); 152 | expect(res.write).toHaveBeenCalledWith(Buffer.from('Hello')); 153 | expect(res.end).toHaveBeenCalledTimes(1); 154 | }); 155 | 156 | describe('custom serveFile', () => { 157 | const { defineRoutes, attachRoutes } = createApplication({ 158 | root: '/home/foo', 159 | allowStaticFrom: ['public'], 160 | serveFile: async (params) => { 161 | const { status, headers, filePath, ...other } = params; 162 | if (filePath.endsWith('/file2.txt')) { 163 | return null; 164 | } 165 | const headersObject = Object.fromEntries(headers.entries()); 166 | return Response.json( 167 | { status, headers: headersObject, filePath, ...other }, 168 | { 169 | status, 170 | headers: { ...headersObject, ETag: params.filePath }, 171 | }, 172 | ); 173 | }, 174 | }); 175 | 176 | it('should invoke custom serveFile', async () => { 177 | const routes = defineRoutes((app) => [ 178 | app.get('/file', async (_request) => { 179 | return Response.file('public/file.txt'); 180 | }), 181 | ]); 182 | const expressHandler = attachRoutes(routes); 183 | const [req, res, next, promise] = createMockExpress('/file'); 184 | expressHandler(req, res, next); 185 | await promise; 186 | expect(next).toHaveBeenCalledTimes(0); 187 | expect(res.writeHead).toHaveBeenCalledTimes(1); 188 | expect(res.writeHead).toHaveBeenCalledWith(200, '', { 189 | 'Content-Type': 'application/json;charset=UTF-8', 190 | ETag: 'public/file.txt', 191 | }); 192 | expect(res.write).toHaveBeenCalledTimes(1); 193 | const data = Object(res.write).mock.calls[0][0]; 194 | const parsed = JSON.parse(String(data)); 195 | expect(parsed).toEqual({ 196 | filePath: 'public/file.txt', 197 | fullFilePath: '/home/foo/public/file.txt', 198 | status: 200, 199 | statusText: '', 200 | headers: {}, 201 | options: {}, 202 | }); 203 | expect(res.end).toHaveBeenCalledTimes(1); 204 | }); 205 | 206 | it('should pass through init options', async () => { 207 | const routes = defineRoutes((app) => [ 208 | app.get('/file', async (_request) => { 209 | return Response.file('public/file.txt', { 210 | status: 404, 211 | headers: { foo: '1' }, 212 | maxAge: 10, 213 | cachingHeaders: true, 214 | }); 215 | }), 216 | ]); 217 | const expressHandler = attachRoutes(routes); 218 | const [req, res, next, promise] = createMockExpress('/file'); 219 | expressHandler(req, res, next); 220 | await promise; 221 | expect(next).toHaveBeenCalledTimes(0); 222 | expect(res.writeHead).toHaveBeenCalledTimes(1); 223 | expect(res.writeHead).toHaveBeenCalledWith(404, '', { 224 | 'Content-Type': 'application/json;charset=UTF-8', 225 | foo: '1', 226 | ETag: 'public/file.txt', 227 | }); 228 | expect(res.write).toHaveBeenCalledTimes(1); 229 | const data = Object(res.write).mock.calls[0][0]; 230 | const parsed = JSON.parse(String(data)); 231 | expect(parsed).toEqual({ 232 | filePath: 'public/file.txt', 233 | fullFilePath: '/home/foo/public/file.txt', 234 | status: 404, 235 | statusText: '', 236 | headers: { foo: '1' }, 237 | options: { maxAge: 10, cachingHeaders: true }, 238 | }); 239 | expect(res.end).toHaveBeenCalledTimes(1); 240 | }); 241 | 242 | it('should allow returning null', async () => { 243 | const routes = defineRoutes((app) => [ 244 | app.get('/file2', async (_request) => { 245 | return Response.file('public/file2.txt'); 246 | }), 247 | ]); 248 | const expressHandler = attachRoutes(routes); 249 | const [req, res, next, promise] = createMockExpress('/file2'); 250 | expressHandler(req, res, next); 251 | await promise; 252 | expect(next).toHaveBeenCalledTimes(0); 253 | expect(res.writeHead).toHaveBeenCalledTimes(1); 254 | expect(res.writeHead).toHaveBeenCalledWith(404, '', { 255 | 'Content-Type': 'text/plain;charset=UTF-8', 256 | }); 257 | expect(res.write).toHaveBeenCalledTimes(1); 258 | expect(res.write).toHaveBeenCalledWith('Not found'); 259 | expect(res.end).toHaveBeenCalledTimes(1); 260 | }); 261 | }); 262 | }); 263 | -------------------------------------------------------------------------------- /packages/node/src/__tests__/helpers/createMockExpress.ts: -------------------------------------------------------------------------------- 1 | import { Readable } from 'stream'; 2 | import EventEmitter from 'events'; 3 | 4 | import type { 5 | Request as ExpressRequest, 6 | Response as ExpressResponse, 7 | NextFunction, 8 | } from 'express'; 9 | 10 | type BodyInit = 11 | | Uint8Array // Includes Buffer which is a subclass of Uint8Array 12 | | Readable 13 | | string 14 | | null 15 | | undefined; 16 | 17 | type RequestInit = { 18 | method?: string; 19 | headers?: Record; 20 | body?: BodyInit; 21 | }; 22 | 23 | export function createMockExpress(path: string, init?: RequestInit) { 24 | // This is used to emit an `end` event whenever the test request is "over", 25 | // meaning we're ready to start making assertions. This is communicated to the 26 | // caller via a promise. This does not necessarily indicate that the request 27 | // was handled or ended, it could mean that next() was called or something 28 | // threw. 29 | const emitter = new EventEmitter(); 30 | const promise = new Promise((resolve) => { 31 | emitter.on('end', (error?: unknown) => resolve(error)); 32 | }); 33 | const headers: Record = init?.headers ?? {}; 34 | const rawHeaders: Array = []; 35 | for (const [key, value] of Object.entries(headers)) { 36 | rawHeaders.push(key); 37 | rawHeaders.push(value); 38 | } 39 | const req: ExpressRequest = Object.assign(toStream(init?.body ?? ''), { 40 | method: init?.method ?? 'GET', 41 | url: path, 42 | headers, 43 | rawHeaders, 44 | }) as never; 45 | 46 | const res: ExpressResponse = Object.assign(new EventEmitter(), { 47 | status: vi.fn(), 48 | headersSent: false, 49 | writeHead: vi.fn().mockImplementation(() => { 50 | res.headersSent = true; 51 | }), 52 | write: vi.fn(), 53 | sendFile: vi.fn().mockImplementation(() => emitter.emit('end')), 54 | end: vi.fn().mockImplementation(() => emitter.emit('end')), 55 | }) as never; 56 | 57 | const next: NextFunction = vi.fn().mockImplementation((error?: unknown) => { 58 | setTimeout(() => { 59 | emitter.emit('end', error); 60 | }); 61 | // This allows us to simulate if something throws. Realistically it would be 62 | // if some internal framework function throws, like `toNodeHeaders()` or 63 | // something with .pipe(). 64 | if (Object(error).message === 'Rethrow') { 65 | throw error; 66 | } 67 | }); 68 | return [req, res, next, promise] as const; 69 | } 70 | 71 | function toStream(body: Uint8Array | Readable | string): Readable { 72 | if (body instanceof Uint8Array || typeof body === 'string') { 73 | return Readable.from(body, { objectMode: false }); 74 | } 75 | return body; 76 | } 77 | -------------------------------------------------------------------------------- /packages/node/src/__tests__/helpers/createMockNode.ts: -------------------------------------------------------------------------------- 1 | import { Readable } from 'stream'; 2 | import EventEmitter from 'events'; 3 | import type { IncomingMessage, ServerResponse } from 'http'; 4 | 5 | type BodyInit = 6 | | Uint8Array // Includes Buffer which is a subclass of Uint8Array 7 | | Readable 8 | | string 9 | | null 10 | | undefined; 11 | 12 | type RequestInit = { 13 | method?: string; 14 | headers?: Record; 15 | body?: BodyInit; 16 | }; 17 | 18 | export function createMockNode(path: string, init?: RequestInit) { 19 | // This is used to emit an `end` event whenever the test request is "over", 20 | // meaning we're ready to start making assertions. This is communicated to the 21 | // caller via a promise. This does not necessarily indicate that the request 22 | // was handled or ended, it could mean that something threw. 23 | const emitter = new EventEmitter(); 24 | const promise = new Promise((resolve) => { 25 | emitter.on('end', (error?: unknown) => resolve(error)); 26 | }); 27 | const headers: Record = init?.headers ?? {}; 28 | const rawHeaders: Array = []; 29 | for (const [key, value] of Object.entries(headers)) { 30 | rawHeaders.push(key); 31 | rawHeaders.push(value); 32 | } 33 | const req: IncomingMessage = Object.assign(toStream(init?.body ?? ''), { 34 | method: init?.method ?? 'GET', 35 | url: path, 36 | headers, 37 | rawHeaders, 38 | }) as never; 39 | 40 | let hasThrown = false; 41 | const res: ServerResponse = Object.assign(new EventEmitter(), { 42 | status: vi.fn(), 43 | headersSent: false, 44 | writeHead: vi.fn().mockImplementation(() => { 45 | // This allows us to simulate if something right before/during writeHead 46 | // throws. Realistically it would be if some internal framework function 47 | // throws, like `toNodeHeaders()` or something with .pipe(). 48 | // We will throw only on the first call to writeHead(). 49 | // TODO: A less hacky way might be to mock toNodeHeaders and make it 50 | // throw, because in reality, writeHead should never throw. 51 | const toBeThrown = init?.headers?.throw; 52 | if (toBeThrown && !hasThrown) { 53 | hasThrown = true; 54 | setTimeout(() => { 55 | emitter.emit('end'); 56 | }); 57 | throw new Error(toBeThrown); 58 | } 59 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 60 | // @ts-ignore - Readonly property 61 | res.headersSent = true; 62 | }), 63 | write: vi.fn(), 64 | sendFile: vi.fn().mockImplementation(() => emitter.emit('end')), 65 | end: vi.fn().mockImplementation(() => emitter.emit('end')), 66 | }) as never; 67 | return [req, res, promise] as const; 68 | } 69 | 70 | function toStream(body: Uint8Array | Readable | string): Readable { 71 | if (body instanceof Uint8Array || typeof body === 'string') { 72 | return Readable.from(body, { objectMode: false }); 73 | } 74 | return body; 75 | } 76 | -------------------------------------------------------------------------------- /packages/node/src/__tests__/links.test.ts: -------------------------------------------------------------------------------- 1 | import { getPlatform } from '../core/support/getPlatform'; 2 | 3 | it('should correctly import across symlinked boundaries', () => { 4 | const platform = getPlatform(); 5 | expect(platform).toBe('node'); 6 | }); 7 | -------------------------------------------------------------------------------- /packages/node/src/__tests__/node.test.ts: -------------------------------------------------------------------------------- 1 | import { Readable } from 'stream'; 2 | 3 | import { createApplication } from '../node'; 4 | import Response from '../core/CustomResponse'; 5 | 6 | import { createMockNode } from './helpers/createMockNode'; 7 | 8 | process.chdir('/project'); 9 | 10 | const { defineRoutes, attachRoutes } = createApplication({ 11 | root: '/project', 12 | allowStaticFrom: ['public'], 13 | getContext: (request) => ({ 14 | auth: () => { 15 | return request.headers.get('authentication') ?? ''; 16 | }, 17 | }), 18 | }); 19 | 20 | const routes = defineRoutes((app) => [ 21 | app.get('/', () => { 22 | return { a: 1 }; 23 | }), 24 | app.get('/null', () => { 25 | return new Response(null, { status: 400 }); 26 | }), 27 | app.get('/buffer', () => { 28 | return new Response(Buffer.from('foo'), { 29 | status: 418, 30 | statusText: 'Yo', 31 | headers: { foo: 'x' }, 32 | }); 33 | }), 34 | app.get('/file', () => { 35 | return Response.file('public/foo.txt'); 36 | }), 37 | app.get('/file2', () => { 38 | return Response.file('public/foo.txt', { 39 | maxAge: 0, 40 | }); 41 | }), 42 | app.get('/stream', () => { 43 | const data = 'Hello'; 44 | return new Response(Readable.from(data, { objectMode: false })); 45 | }), 46 | app.get('/error', () => { 47 | throw new Error('Waat'); 48 | }), 49 | ]); 50 | 51 | describe('node', () => { 52 | const nodeHandler = attachRoutes(routes); 53 | 54 | it('should handle a json response body', async () => { 55 | const [req, res, promise] = createMockNode('/'); 56 | nodeHandler(req, res); 57 | await promise; 58 | expect(res.writeHead).toHaveBeenCalledTimes(1); 59 | expect(res.writeHead).toHaveBeenCalledWith(200, '', { 60 | 'Content-Type': 'application/json;charset=UTF-8', 61 | }); 62 | expect(res.write).toHaveBeenCalledTimes(1); 63 | expect(res.write).toHaveBeenCalledWith('{"a":1}'); 64 | expect(res.end).toHaveBeenCalledTimes(1); 65 | }); 66 | 67 | it('should handle a null response body', async () => { 68 | const [req, res, promise] = createMockNode('/null'); 69 | nodeHandler(req, res); 70 | await promise; 71 | expect(res.writeHead).toHaveBeenCalledTimes(1); 72 | expect(res.writeHead).toHaveBeenCalledWith(400, '', {}); 73 | expect(res.write).toHaveBeenCalledTimes(0); 74 | expect(res.end).toHaveBeenCalledTimes(1); 75 | }); 76 | 77 | it('should handle a buffer response body', async () => { 78 | const [req, res, promise] = createMockNode('/buffer'); 79 | nodeHandler(req, res); 80 | await promise; 81 | expect(res.writeHead).toHaveBeenCalledTimes(1); 82 | expect(res.writeHead).toHaveBeenCalledWith(418, 'Yo', { foo: 'x' }); 83 | expect(res.write).toHaveBeenCalledTimes(1); 84 | expect(res.write).toHaveBeenCalledWith(Buffer.from('foo')); 85 | expect(res.end).toHaveBeenCalledTimes(1); 86 | }); 87 | 88 | it('should handle error thrown', async () => { 89 | const [req, res, promise] = createMockNode('/error'); 90 | nodeHandler(req, res); 91 | await promise; 92 | expect(res.writeHead).toHaveBeenCalledTimes(1); 93 | expect(res.writeHead).toHaveBeenCalledWith(500, '', { 94 | 'Content-Type': 'text/plain;charset=UTF-8', 95 | }); 96 | expect(res.write).toHaveBeenCalledTimes(1); 97 | expect(res.write).toHaveBeenCalledWith('Error: Waat'); 98 | expect(res.end).toHaveBeenCalledTimes(1); 99 | }); 100 | 101 | it('should handle an error thrown by the framework', async () => { 102 | const [req, res, promise] = createMockNode('/', { 103 | headers: { throw: 'Foo' }, 104 | }); 105 | nodeHandler(req, res); 106 | await promise; 107 | expect(res.writeHead).toHaveBeenCalledTimes(2); 108 | expect(res.writeHead).toHaveBeenLastCalledWith(500); 109 | expect(res.write).toHaveBeenCalledTimes(0); 110 | expect(res.end).toHaveBeenCalledTimes(1); 111 | expect(res.end).toHaveBeenCalledWith('Error: Foo'); 112 | }); 113 | 114 | it('should handle no route', async () => { 115 | const [req, res, promise] = createMockNode('/nothing'); 116 | nodeHandler(req, res); 117 | await promise; 118 | expect(res.writeHead).toHaveBeenCalledTimes(1); 119 | expect(res.writeHead).toHaveBeenCalledWith(404, '', { 120 | 'Content-Type': 'text/plain;charset=UTF-8', 121 | }); 122 | expect(res.write).toHaveBeenCalledTimes(1); 123 | expect(res.write).toHaveBeenCalledWith('Not found'); 124 | expect(res.end).toHaveBeenCalledTimes(1); 125 | }); 126 | 127 | it('should send file', async () => { 128 | const [req, res, promise] = createMockNode('/file'); 129 | nodeHandler(req, res); 130 | await promise; 131 | expect(res.writeHead).toHaveBeenCalledTimes(1); 132 | expect(res.writeHead).toHaveBeenCalledWith(200, '', { 133 | 'Content-Length': '12', 134 | 'Content-Type': 'text/plain', 135 | ETag: 'W/"c18a1eeaccf8"', 136 | 'Last-Modified': 'Tue, 22 Aug 2023 20:23:39 GMT', 137 | }); 138 | expect(res.write).toHaveBeenCalledTimes(1); 139 | expect(res.write).toHaveBeenCalledWith(Buffer.from('Some content')); 140 | expect(res.end).toHaveBeenCalledTimes(1); 141 | }); 142 | 143 | it('should honor caching options', async () => { 144 | const [req, res, promise] = createMockNode('/file2'); 145 | nodeHandler(req, res); 146 | await promise; 147 | expect(res.writeHead).toHaveBeenCalledTimes(1); 148 | expect(res.writeHead).toHaveBeenCalledWith(200, '', { 149 | 'Content-Length': '12', 150 | 'Content-Type': 'text/plain', 151 | ETag: 'W/"c18a1eeaccf8"', 152 | 'Last-Modified': 'Tue, 22 Aug 2023 20:23:39 GMT', 153 | 'Cache-Control': 'max-age=0', 154 | }); 155 | expect(res.write).toHaveBeenCalledTimes(1); 156 | expect(res.write).toHaveBeenCalledWith(Buffer.from('Some content')); 157 | expect(res.end).toHaveBeenCalledTimes(1); 158 | }); 159 | 160 | it('should handle a stream response body', async () => { 161 | const [req, res, promise] = createMockNode('/stream'); 162 | nodeHandler(req, res); 163 | await promise; 164 | expect(res.writeHead).toHaveBeenCalledTimes(1); 165 | expect(res.writeHead).toHaveBeenCalledWith(200, '', {}); 166 | expect(res.write).toHaveBeenCalledTimes(1); 167 | expect(res.write).toHaveBeenCalledWith(Buffer.from('Hello')); 168 | expect(res.end).toHaveBeenCalledTimes(1); 169 | }); 170 | 171 | describe('custom serveFile', () => { 172 | const { defineRoutes, attachRoutes } = createApplication({ 173 | root: '/home/foo', 174 | allowStaticFrom: ['public'], 175 | serveFile: async (params) => { 176 | const { status, headers, filePath, ...other } = params; 177 | if (filePath.endsWith('/file2.txt')) { 178 | return null; 179 | } 180 | const headersObject = Object.fromEntries(headers.entries()); 181 | return Response.json( 182 | { status, headers: headersObject, filePath, ...other }, 183 | { 184 | status, 185 | headers: { ...headersObject, ETag: params.filePath }, 186 | }, 187 | ); 188 | }, 189 | }); 190 | 191 | it('should invoke custom serveFile', async () => { 192 | const routes = defineRoutes((app) => [ 193 | app.get('/file', async (_request) => { 194 | return Response.file('public/file.txt'); 195 | }), 196 | ]); 197 | const nodeHandler = attachRoutes(routes); 198 | const [req, res, promise] = createMockNode('/file'); 199 | nodeHandler(req, res); 200 | await promise; 201 | expect(res.writeHead).toHaveBeenCalledTimes(1); 202 | expect(res.writeHead).toHaveBeenCalledWith(200, '', { 203 | 'Content-Type': 'application/json;charset=UTF-8', 204 | ETag: 'public/file.txt', 205 | }); 206 | expect(res.write).toHaveBeenCalledTimes(1); 207 | const data = Object(res.write).mock.calls[0][0]; 208 | const parsed = JSON.parse(String(data)); 209 | expect(parsed).toEqual({ 210 | filePath: 'public/file.txt', 211 | fullFilePath: '/home/foo/public/file.txt', 212 | status: 200, 213 | statusText: '', 214 | headers: {}, 215 | options: {}, 216 | }); 217 | expect(res.end).toHaveBeenCalledTimes(1); 218 | }); 219 | 220 | it('should pass through init options', async () => { 221 | const routes = defineRoutes((app) => [ 222 | app.get('/file', async (_request) => { 223 | return Response.file('public/file.txt', { 224 | status: 404, 225 | headers: { foo: '1' }, 226 | maxAge: 10, 227 | cachingHeaders: true, 228 | }); 229 | }), 230 | ]); 231 | const nodeHandler = attachRoutes(routes); 232 | const [req, res, promise] = createMockNode('/file'); 233 | nodeHandler(req, res); 234 | await promise; 235 | expect(res.writeHead).toHaveBeenCalledTimes(1); 236 | expect(res.writeHead).toHaveBeenCalledWith(404, '', { 237 | 'Content-Type': 'application/json;charset=UTF-8', 238 | foo: '1', 239 | ETag: 'public/file.txt', 240 | }); 241 | expect(res.write).toHaveBeenCalledTimes(1); 242 | const data = Object(res.write).mock.calls[0][0]; 243 | const parsed = JSON.parse(String(data)); 244 | expect(parsed).toEqual({ 245 | filePath: 'public/file.txt', 246 | fullFilePath: '/home/foo/public/file.txt', 247 | status: 404, 248 | statusText: '', 249 | headers: { foo: '1' }, 250 | options: { maxAge: 10, cachingHeaders: true }, 251 | }); 252 | expect(res.end).toHaveBeenCalledTimes(1); 253 | }); 254 | 255 | it('should allow returning null', async () => { 256 | const routes = defineRoutes((app) => [ 257 | app.get('/file2', async (_request) => { 258 | return Response.file('public/file2.txt'); 259 | }), 260 | ]); 261 | const nodeHandler = attachRoutes(routes); 262 | const [req, res, promise] = createMockNode('/file2'); 263 | nodeHandler(req, res); 264 | await promise; 265 | expect(res.writeHead).toHaveBeenCalledTimes(1); 266 | expect(res.writeHead).toHaveBeenCalledWith(404, '', { 267 | 'Content-Type': 'text/plain;charset=UTF-8', 268 | }); 269 | expect(res.write).toHaveBeenCalledTimes(1); 270 | expect(res.write).toHaveBeenCalledWith('Not found'); 271 | expect(res.end).toHaveBeenCalledTimes(1); 272 | }); 273 | }); 274 | }); 275 | -------------------------------------------------------------------------------- /packages/node/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const PLATFORM = 'node'; 2 | -------------------------------------------------------------------------------- /packages/node/src/core: -------------------------------------------------------------------------------- 1 | ../../core/src/core -------------------------------------------------------------------------------- /packages/node/src/express.ts: -------------------------------------------------------------------------------- 1 | import { relative } from 'path'; 2 | import { Readable } from 'stream'; 3 | 4 | import type { 5 | Request as ExpressRequest, 6 | Response as ExpressResponse, 7 | NextFunction, 8 | } from 'express'; 9 | 10 | import { defineAdapter } from './core'; 11 | import { StaticFile } from './core/StaticFile'; 12 | import { createMeta } from './core/support/createMeta'; 13 | import { defineErrors } from './core/support/defineErrors'; 14 | import { Response, Headers } from './web-io'; 15 | import { resolveFilePath } from './fs'; 16 | import { pipeStreamAsync } from './support/pipeStreamAsync'; 17 | import { toNodeHeaders } from './support/headers'; 18 | import { fromNodeRequest } from './support/fromNodeRequest'; 19 | 20 | type ExpressStaticFileOpts = { 21 | root: string; 22 | headers: Record>; 23 | lastModified: boolean; 24 | maxAge: number | undefined; 25 | }; 26 | 27 | // This is roughly the params needed for Express's res.sendFile() 28 | type ExpressStaticFile = [ 29 | status: number, 30 | path: string, 31 | options: ExpressStaticFileOpts, 32 | ]; 33 | 34 | const Errors = defineErrors({ 35 | // This is a placeholder for when no route matches so we can easily identify 36 | // it as a special case of error and hand control over to Express. 37 | NoRouteError: 'No Route', 38 | }); 39 | 40 | export const createApplication = defineAdapter((applicationOptions) => { 41 | const [getExpressStaticFile, setExpressStaticFile] = 42 | createMeta(); 43 | const [getError, setError] = createMeta(); 44 | 45 | const fromStaticFile = async ( 46 | requestHeaders: Headers, 47 | staticFile: StaticFile, 48 | ): Promise => { 49 | const { filePath, options, responseInit: init } = staticFile; 50 | const resolved = resolveFilePath(filePath, applicationOptions); 51 | if (!resolved) { 52 | return; 53 | } 54 | const [fullFilePath, allowedRoot] = resolved; 55 | const customServeFile = applicationOptions.serveFile; 56 | if (customServeFile) { 57 | const { status, statusText, headers } = new Response(null, init); 58 | const maybeResponse = await customServeFile({ 59 | filePath, 60 | fullFilePath, 61 | status, 62 | statusText, 63 | headers, 64 | options, 65 | }); 66 | return maybeResponse ?? undefined; 67 | } 68 | 69 | const { cachingHeaders = true, maxAge } = options; 70 | const response = new Response(filePath); 71 | setExpressStaticFile(response, [ 72 | init.status ?? 200, 73 | // Pass the file path relative to allowedRoot. Express will not 74 | // serve the file if it does not exist within the allowed root. 75 | relative(allowedRoot, fullFilePath), 76 | { 77 | root: allowedRoot, 78 | headers: toNodeHeaders(new Headers(init.headers)), 79 | // Note: Express always sends the ETag header 80 | lastModified: cachingHeaders, 81 | maxAge: typeof maxAge === 'number' ? maxAge * 1000 : undefined, 82 | }, 83 | ]); 84 | return response; 85 | }; 86 | 87 | return { 88 | onError: (request, error) => { 89 | // We're creating a dummy response here and keeping a reference to the 90 | // error for use below. 91 | const response = new Response(String(error), { status: 500 }); 92 | setError(response, error); 93 | return response; 94 | }, 95 | toResponse: async (request, result) => { 96 | if (result instanceof StaticFile) { 97 | return await fromStaticFile(request.headers, result); 98 | } 99 | if (result === undefined) { 100 | // In the other implementations we return undefined here, causing the 101 | // calling function to create a 404 response. But in this case we're 102 | // throwing a special NoRouteError which will be sent to the onError 103 | // function above, which will use a separate trick to pass the error to 104 | // handleRequest below where we can call Express's next(). 105 | throw new Errors.NoRouteError(); 106 | } 107 | return result; 108 | }, 109 | createNativeHandler: (getResponse) => { 110 | const handleRequest = async ( 111 | expressRequest: ExpressRequest, 112 | expressResponse: ExpressResponse, 113 | next: NextFunction, 114 | ) => { 115 | const request = fromNodeRequest(expressRequest, applicationOptions); 116 | const response = await getResponse(request); 117 | const error = getError(response); 118 | if (error) { 119 | return error instanceof Errors.NoRouteError ? next() : next(error); 120 | } 121 | const staticFile = getExpressStaticFile(response); 122 | if (staticFile) { 123 | const [status, path, options] = staticFile; 124 | expressResponse.status(status); 125 | expressResponse.sendFile(path, options, next); 126 | return; 127 | } 128 | const { status, statusText, headers, bodyRaw: body } = response; 129 | if (body instanceof Readable) { 130 | await pipeStreamAsync(body, expressResponse, { 131 | beforeFirstWrite: () => 132 | expressResponse.writeHead( 133 | status, 134 | statusText, 135 | toNodeHeaders(headers), 136 | ), 137 | }); 138 | } else { 139 | expressResponse.writeHead(status, statusText, toNodeHeaders(headers)); 140 | if (body != null) { 141 | expressResponse.write(body); 142 | } 143 | expressResponse.end(); 144 | } 145 | }; 146 | return ( 147 | expressRequest: ExpressRequest, 148 | expressResponse: ExpressResponse, 149 | next: NextFunction, 150 | ) => { 151 | handleRequest(expressRequest, expressResponse, next).catch((e) => { 152 | const error = e instanceof Error ? e : new Error(String(e)); 153 | // Normally we'd pass the error on to next() but in this case it seems 154 | // something went wrong with streaming so we'll end the request here. 155 | if (!expressResponse.headersSent) { 156 | expressResponse.writeHead(500); 157 | expressResponse.end(String(error)); 158 | } else { 159 | expressResponse.end(); 160 | } 161 | }); 162 | }; 163 | }, 164 | }; 165 | }); 166 | -------------------------------------------------------------------------------- /packages/node/src/fs: -------------------------------------------------------------------------------- 1 | ../../core/src/fs -------------------------------------------------------------------------------- /packages/node/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './packages/node'; 2 | -------------------------------------------------------------------------------- /packages/node/src/node.ts: -------------------------------------------------------------------------------- 1 | import { Readable } from 'stream'; 2 | import type { IncomingMessage, ServerResponse } from 'http'; 3 | 4 | import { defineAdapter } from './core'; 5 | import { Response, Headers } from './web-io'; 6 | import { StaticFile } from './core/StaticFile'; 7 | import { resolveFilePath } from './fs'; 8 | import { serveFile } from './support/serveFile'; 9 | import { pipeStreamAsync } from './support/pipeStreamAsync'; 10 | import { toNodeHeaders } from './support/headers'; 11 | import { fromNodeRequest } from './support/fromNodeRequest'; 12 | 13 | export const createApplication = defineAdapter((applicationOptions) => { 14 | // This `fromStaticFile` function is identical to that of bun. 15 | const fromStaticFile = async ( 16 | requestHeaders: Headers, 17 | staticFile: StaticFile, 18 | ): Promise => { 19 | const { filePath, options, responseInit: init } = staticFile; 20 | const resolved = resolveFilePath(filePath, applicationOptions); 21 | if (!resolved) { 22 | return; 23 | } 24 | const [fullFilePath] = resolved; 25 | const customServeFile = applicationOptions.serveFile; 26 | if (customServeFile) { 27 | const { status, statusText, headers } = new Response(null, init); 28 | const maybeResponse = await customServeFile({ 29 | filePath, 30 | fullFilePath, 31 | status, 32 | statusText, 33 | headers, 34 | options, 35 | }); 36 | return maybeResponse ?? undefined; 37 | } 38 | const fileResponse = await serveFile(requestHeaders, fullFilePath, options); 39 | if (!fileResponse) { 40 | return; 41 | } 42 | // Use the status from fileResponse if provided (e.g. "304 Not Modified") 43 | // otherwise fall back to user-specified value or default. 44 | const responseStatus = fileResponse.status ?? init.status ?? 200; 45 | const responseHeaders = new Headers(init.headers); 46 | // Merge in the headers without overwriting existing headers 47 | for (const [key, value] of Object.entries(fileResponse.headers ?? {})) { 48 | if (!responseHeaders.has(key)) { 49 | responseHeaders.set(key, value); 50 | } 51 | } 52 | return new Response(fileResponse.body ?? '', { 53 | ...init, 54 | status: responseStatus, 55 | headers: responseHeaders, 56 | }); 57 | }; 58 | 59 | return { 60 | onError: (request, error) => { 61 | return new Response(String(error), { status: 500 }); 62 | }, 63 | toResponse: async (request, result) => { 64 | if (result instanceof StaticFile) { 65 | return await fromStaticFile(request.headers, result); 66 | } 67 | return result; 68 | }, 69 | createNativeHandler: (getResponse) => { 70 | const handleRequest = async ( 71 | nodeRequest: IncomingMessage, 72 | nodeResponse: ServerResponse, 73 | ) => { 74 | const request = fromNodeRequest(nodeRequest, applicationOptions); 75 | const response = await getResponse(request); 76 | const { status, statusText, headers, bodyRaw: body } = response; 77 | if (body instanceof Readable) { 78 | await pipeStreamAsync(body, nodeResponse, { 79 | beforeFirstWrite: () => 80 | nodeResponse.writeHead( 81 | status, 82 | statusText, 83 | toNodeHeaders(headers), 84 | ), 85 | }); 86 | } else { 87 | nodeResponse.writeHead(status, statusText, toNodeHeaders(headers)); 88 | if (body != null) { 89 | nodeResponse.write(body); 90 | } 91 | nodeResponse.end(); 92 | } 93 | }; 94 | 95 | return (nodeRequest: IncomingMessage, nodeResponse: ServerResponse) => { 96 | handleRequest(nodeRequest, nodeResponse).catch((e) => { 97 | const error = e instanceof Error ? e : new Error(String(e)); 98 | if (!nodeResponse.headersSent) { 99 | nodeResponse.writeHead(500); 100 | nodeResponse.end(String(error)); 101 | } else { 102 | nodeResponse.end(); 103 | } 104 | }); 105 | }; 106 | }, 107 | }; 108 | }); 109 | -------------------------------------------------------------------------------- /packages/node/src/packages/express/index.ts: -------------------------------------------------------------------------------- 1 | export { HttpError } from '../../core'; 2 | export { Request } from '../../web-io'; 3 | export { default as Response } from '../../core/CustomResponse'; 4 | export { createApplication } from '../../express'; 5 | -------------------------------------------------------------------------------- /packages/node/src/packages/node/index.ts: -------------------------------------------------------------------------------- 1 | export { HttpError } from '../../core'; 2 | export { Request } from '../../web-io'; 3 | export { default as Response } from '../../core/CustomResponse'; 4 | export { createApplication } from '../../node'; 5 | -------------------------------------------------------------------------------- /packages/node/src/support/__tests__/serveFile.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { serveFile } from '../serveFile'; 3 | import { Headers } from '../../web-io'; 4 | 5 | vi.mock('fs', async (importOriginal) => { 6 | const fs = await importOriginal(); 7 | return { 8 | ...Object(fs), 9 | createReadStream: vi.fn().mockImplementation((filePath) => { 10 | return { _stream: filePath } as any; 11 | }), 12 | }; 13 | }); 14 | 15 | vi.mock('fs/promises', async (importOriginal) => { 16 | const fs = await importOriginal(); 17 | return { 18 | ...Object(fs), 19 | stat: vi.fn().mockImplementation(async (path) => { 20 | const fileName = path.split('/').pop().split('.')[0] ?? ''; 21 | const parts = fileName.split('-').slice(1); 22 | if (!parts.length) { 23 | throw new Error('ENOENT: no such file or directory'); 24 | } 25 | const [size, isFile] = parts; 26 | return mockStat(Number(size), Boolean(Number(isFile))); 27 | }), 28 | }; 29 | }); 30 | 31 | describe('serveFile', () => { 32 | it('should serve a file that exists', async () => { 33 | const filePath = '/foo/thing-42-1.png'; 34 | const result = await serveFile(new Headers(), filePath); 35 | expect(result).toEqual({ 36 | headers: { 37 | 'Content-Length': '42', 38 | 'Content-Type': 'image/png', 39 | ETag: 'W/"2a16806b5bc00"', 40 | 'Last-Modified': 'Tue, 01 Jan 2019 00:00:00 GMT', 41 | }, 42 | body: { _stream: filePath }, 43 | }); 44 | }); 45 | 46 | it('should return null if the file does not exist', async () => { 47 | const filePath = './foo.txt'; 48 | const result = await serveFile(new Headers(), filePath); 49 | expect(result).toEqual(null); 50 | }); 51 | 52 | it('should return null if the entry at path is not a file', async () => { 53 | const filePath = './foo/dir-5-0'; 54 | const result = await serveFile(new Headers(), filePath); 55 | expect(result).toEqual(null); 56 | }); 57 | 58 | it('should fall back to default content type', async () => { 59 | const filePath = './foo/file-15-1.asdf'; 60 | const result = await serveFile(new Headers(), filePath); 61 | expect(result).toEqual({ 62 | headers: { 63 | 'Content-Length': '15', 64 | 'Content-Type': 'application/octet-stream', 65 | ETag: 'W/"f16806b5bc00"', 66 | 'Last-Modified': 'Tue, 01 Jan 2019 00:00:00 GMT', 67 | }, 68 | body: { _stream: filePath }, 69 | }); 70 | }); 71 | }); 72 | 73 | function mockStat(size: number, isFile: boolean) { 74 | return { 75 | size, 76 | mtimeMs: new Date('2019-01-01T00:00:00.000Z').valueOf(), 77 | isFile: () => isFile, 78 | }; 79 | } 80 | -------------------------------------------------------------------------------- /packages/node/src/support/fromNodeRequest.ts: -------------------------------------------------------------------------------- 1 | import type { IncomingMessage } from 'http'; 2 | 3 | import type { RequestOptions } from '../types'; 4 | import { Request } from '../web-io'; 5 | 6 | import { fromNodeRawHeaders } from './headers'; 7 | 8 | export function fromNodeRequest( 9 | nodeRequest: IncomingMessage, 10 | options: RequestOptions, 11 | ) { 12 | const method = (nodeRequest.method ?? 'GET').toUpperCase(); 13 | const pathname = nodeRequest.url ?? '/'; 14 | const headers = fromNodeRawHeaders(nodeRequest.rawHeaders); 15 | return new Request(pathname, { 16 | method, 17 | headers, 18 | body: nodeRequest, 19 | options, 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /packages/node/src/support/headers.ts: -------------------------------------------------------------------------------- 1 | import { Headers } from '../web-io'; 2 | 3 | // https://nodejs.org/docs/latest-v16.x/api/http.html#messagerawheaders 4 | export function fromNodeRawHeaders(rawHeaders: Array) { 5 | const headers = new Headers(); 6 | for (let i = 0; i < rawHeaders.length; i++) { 7 | const name = rawHeaders[i] ?? ''; 8 | const value = rawHeaders[++i] ?? ''; 9 | headers.append(name, value); 10 | } 11 | return headers; 12 | } 13 | 14 | // Helper to convert headers to the object expected by Node's 15 | // response.writeHead(). 16 | // https://nodejs.org/docs/latest-v16.x/api/http.html#responsewriteheadstatuscode-statusmessage-headers 17 | export function toNodeHeaders(headers: Headers) { 18 | const result: Record> = {}; 19 | for (const [name, values] of headers.headers.values()) { 20 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 21 | result[name] = values.length === 1 ? values[0]! : values; 22 | } 23 | return result; 24 | } 25 | -------------------------------------------------------------------------------- /packages/node/src/support/pipeStreamAsync.ts: -------------------------------------------------------------------------------- 1 | import type { Readable, Writable } from 'stream'; 2 | 3 | export function pipeStreamAsync( 4 | readStream: Readable, 5 | writeStream: Writable, 6 | options: { beforeFirstWrite: () => void }, 7 | ): Promise { 8 | const { beforeFirstWrite } = options; 9 | return new Promise((resolve, reject) => { 10 | readStream 11 | .once('data', () => { 12 | try { 13 | beforeFirstWrite(); 14 | } catch (e) { 15 | try { 16 | readStream.unpipe(writeStream); 17 | } catch (_) {} 18 | reject(e); 19 | } 20 | }) 21 | .pipe(writeStream) 22 | .on('close', () => resolve()) 23 | .on('error', (error) => { 24 | readStream.off('data', beforeFirstWrite); 25 | reject(error); 26 | }); 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /packages/node/src/support/readEntireStream.ts: -------------------------------------------------------------------------------- 1 | import { type Readable } from 'stream'; 2 | 3 | import { defineErrors } from '../core/support/defineErrors'; 4 | 5 | type Options = { 6 | expectedSize: number | undefined; 7 | maxBufferSize: number; 8 | }; 9 | 10 | export const Errors = defineErrors({ 11 | ExpectedSizeMismatchError: 12 | 'Expected {expected} bytes but received {received}', 13 | MaxSizeExceededError: 'Exceeded maximum buffer size of {maxBufferSize} bytes', 14 | }); 15 | 16 | // There are two potential opportunities for early return here: (1) check 17 | // content-length against maxBufferSize before we begin and throw 18 | // MaxSizeExceededError if it's too large; (2) on each chunk received check 19 | // totalBytes and throw ExpectedSizeMismatchError if it exceeds expectedSize. 20 | export function readEntireStream(stream: Readable, options: Options) { 21 | const { expectedSize, maxBufferSize } = options; 22 | return new Promise((resolve, reject) => { 23 | const chunks: Array = []; 24 | let totalBytesRead = 0; 25 | stream.on('data', (data) => { 26 | chunks.push(data); 27 | totalBytesRead += data.length; 28 | if (totalBytesRead > maxBufferSize) { 29 | const error = new Errors.MaxSizeExceededError({ maxBufferSize }); 30 | reject(error); 31 | } 32 | }); 33 | stream.on('error', (error) => { 34 | reject(error); 35 | }); 36 | stream.on('end', () => { 37 | if (expectedSize !== undefined && totalBytesRead !== expectedSize) { 38 | const error = new Errors.ExpectedSizeMismatchError({ 39 | expected: expectedSize, 40 | received: totalBytesRead, 41 | }); 42 | reject(error); 43 | return; 44 | } 45 | try { 46 | resolve(Buffer.concat(chunks, totalBytesRead)); 47 | } catch (e) { 48 | reject(e); 49 | } 50 | }); 51 | }); 52 | } 53 | -------------------------------------------------------------------------------- /packages/node/src/support/serveFile.ts: -------------------------------------------------------------------------------- 1 | import { createReadStream } from 'fs'; 2 | import { stat as statAsync } from 'fs/promises'; 3 | import { type Readable } from 'stream'; 4 | 5 | import { tryAsync } from '../core/support/tryAsync'; 6 | import { computeHeaders } from '../fs'; 7 | import type { StaticFileOptions } from '../core/StaticFile'; 8 | import type { Headers } from '../web-io'; 9 | 10 | type FileResponse = { 11 | status?: number; 12 | headers?: Record; 13 | body?: Readable; 14 | }; 15 | 16 | export async function serveFile( 17 | requestHeaders: Headers, 18 | fullFilePath: string, 19 | options: StaticFileOptions = {}, 20 | ): Promise { 21 | const fileStats = await tryAsync(() => statAsync(fullFilePath)); 22 | if (!fileStats || !fileStats.isFile()) { 23 | return null; 24 | } 25 | const result = await computeHeaders( 26 | requestHeaders, 27 | fullFilePath, 28 | fileStats, 29 | options, 30 | ); 31 | if (result == null || result.status === 304) { 32 | return result; 33 | } 34 | return { 35 | headers: result.headers, 36 | body: createReadStream(fullFilePath), 37 | }; 38 | } 39 | -------------------------------------------------------------------------------- /packages/node/src/support/streams.ts: -------------------------------------------------------------------------------- 1 | import { Readable } from 'stream'; 2 | import type { ReadableStream } from 'stream/web'; 3 | 4 | // Although web streams are available in Node from v16.5, this method is 5 | // available only in v18 and newer. 6 | const fromWeb: ((readableStream: ReadableStream) => Readable) | undefined = 7 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 8 | (Readable as any).fromWeb; 9 | 10 | export function readableFromWeb(readableStream: ReadableStream) { 11 | if (!fromWeb) { 12 | throw new Error( 13 | 'Readable.fromWeb() is only available in Node v18 and above', 14 | ); 15 | } 16 | return fromWeb(readableStream); 17 | } 18 | -------------------------------------------------------------------------------- /packages/node/src/test/setup.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as fsp from 'fs/promises'; 3 | import { resolve } from 'path'; 4 | import { Readable } from 'stream'; 5 | 6 | vi.mock('fs'); 7 | vi.mock('fs/promises'); 8 | 9 | let cwd = '/'; 10 | 11 | const files = [ 12 | { 13 | path: 'public/foo.txt', 14 | type: 'file', 15 | content: 'Some content', 16 | mtime: 1692735819, 17 | }, 18 | ]; 19 | 20 | vi.spyOn(process, 'cwd').mockImplementation(() => { 21 | return cwd; 22 | }); 23 | 24 | vi.spyOn(process, 'chdir').mockImplementation((path: string) => { 25 | cwd = resolve(cwd, path); 26 | }); 27 | 28 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 29 | // @ts-ignore 30 | vi.spyOn(fsp, 'stat').mockImplementation(async (path: string) => { 31 | const fullPath = resolve(cwd, path); 32 | const file = files.find((file) => fullPath === resolve(cwd, file.path)); 33 | if (!file) { 34 | throw Object.assign( 35 | new Error(`ENOENT: no such file or directory, stat '${path}'`), 36 | { errno: -2, code: 'ENOENT', syscall: 'stat', path }, 37 | ); 38 | } 39 | const { type, content, mtime } = file; 40 | return { 41 | isFile: () => type === 'file', 42 | size: content.length, 43 | mtimeMs: mtime * 1000, 44 | }; 45 | }); 46 | 47 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 48 | // @ts-ignore 49 | vi.spyOn(fs, 'createReadStream').mockImplementation((path: string) => { 50 | const fullPath = resolve(cwd, path); 51 | const file = files.find((file) => fullPath === resolve(cwd, file.path)); 52 | if (!file) { 53 | return new Readable({ 54 | read() { 55 | this.emit( 56 | 'error', 57 | Object.assign( 58 | new Error(`ENOENT: no such file or directory, open '${path}'`), 59 | { errno: -2, code: 'ENOENT', syscall: 'open', path }, 60 | ), 61 | ); 62 | this.push(null); 63 | }, 64 | }); 65 | } 66 | return Readable.from(file.content, { objectMode: false }); 67 | }); 68 | -------------------------------------------------------------------------------- /packages/node/src/types: -------------------------------------------------------------------------------- 1 | ../../core/src/types -------------------------------------------------------------------------------- /packages/node/src/web-io/Body.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { TextDecoder } from 'util'; 3 | import { Readable } from 'stream'; 4 | import type { ReadableStream } from 'stream/web'; 5 | 6 | import { readEntireStream } from '../support/readEntireStream'; 7 | import { readableFromWeb } from '../support/streams'; 8 | 9 | export type BodyInit = 10 | | Uint8Array // Includes Buffer which is a subclass of Uint8Array 11 | | Readable // Traditional Node Streams API 12 | | ReadableStream // New Web Streams API (since Node 16.5) 13 | | URLSearchParams 14 | | string 15 | | null 16 | | undefined; 17 | 18 | type BodyInitNormalized = Uint8Array | Readable | string | null; 19 | 20 | // The maximum amount (bytes) we'll read into memory from a body stream. 21 | // Defaults to 100kb, same as Express, see https://github.com/expressjs/body-parser/blob/9db582d/lib/types/json.js#L54 22 | const MAX_BUFFER_SIZE = 100 * 1024; 23 | 24 | export type Options = { 25 | expectedSize?: number | undefined; 26 | maxBufferSize?: number | undefined; 27 | /** 28 | * This allows us to catch an error that occurs while reading the body and 29 | * convert it to a more use-case appropriate error like an HttpError 30 | */ 31 | onReadError?: (error: Error) => Error; 32 | }; 33 | 34 | export class Body { 35 | private _bodyInit: BodyInitNormalized; 36 | private _bodyStream: Readable | undefined; 37 | private _bodyUsed = false; 38 | private options: Options; 39 | 40 | constructor(body: BodyInit, options?: Options) { 41 | this._bodyInit = normalizeBody(body); 42 | this.options = options ?? {}; 43 | } 44 | 45 | // TODO: This is `Readable | null` but it should be `Readable` to allow request.body.pipe(...) 46 | get body() { 47 | if (this._bodyStream) { 48 | return this._bodyStream; 49 | } 50 | const body = this._bodyInit; 51 | if (body == null) { 52 | return null; 53 | } 54 | return (this._bodyStream = toStream(body)); 55 | } 56 | 57 | /** 58 | * Non-standard getter for fast-path handling of non-stream response body 59 | */ 60 | get bodyRaw() { 61 | const body = this._bodyInit; 62 | if ( 63 | body == null || 64 | body instanceof Uint8Array || 65 | typeof body === 'string' 66 | ) { 67 | return body; 68 | } 69 | return this.body; 70 | } 71 | 72 | get bodyUsed() { 73 | const body = this._bodyInit; 74 | if (body == null) { 75 | return false; 76 | } 77 | if ( 78 | this._bodyUsed || 79 | body instanceof Uint8Array || 80 | typeof body === 'string' 81 | ) { 82 | return this._bodyUsed; 83 | } 84 | if (body instanceof Readable) { 85 | // In Node v16.8+ we can rely on Readable.isDisturbed() 86 | if (Readable.isDisturbed) { 87 | return Readable.isDisturbed(body); 88 | } 89 | // In Node v14.18+ we can rely on stream.readableDidRead 90 | // https://nodejs.org/docs/latest-v14.x/api/stream.html#stream_readable_readabledidread 91 | const { readableDidRead } = body; 92 | if (typeof readableDidRead === 'boolean') { 93 | return readableDidRead; 94 | } 95 | // If it's an IncomingMessage, we can rely on the _consuming field 96 | const consuming = (body as any)._consuming as unknown; 97 | if (typeof consuming === 'boolean') { 98 | return consuming; 99 | } 100 | // If nothing else, we'll rely on our own internal flag 101 | return this._bodyUsed; 102 | } 103 | // For Web Streams (Node v16.5+) we'll rely on Readable.isDisturbed() if 104 | // available (Node v16.8+) otherwise fall back to our own internal flag. 105 | if (Readable.isDisturbed) { 106 | return Readable.isDisturbed(body as any); 107 | } 108 | return this._bodyUsed; 109 | } 110 | 111 | private async consumeBody(methodName: string): Promise { 112 | if (this.bodyUsed) { 113 | const className = this.constructor.name; 114 | throw new TypeError( 115 | `TypeError: Failed to execute '${methodName}' on '${className}': body stream already read`, 116 | ); 117 | } 118 | const body = this.body; 119 | if (body == null) { 120 | return Buffer.from(''); 121 | } 122 | this._bodyUsed = true; 123 | const { 124 | expectedSize, 125 | maxBufferSize = MAX_BUFFER_SIZE, 126 | onReadError, 127 | } = this.options; 128 | try { 129 | return await readEntireStream(body, { 130 | expectedSize, 131 | maxBufferSize, 132 | }); 133 | } catch (e) { 134 | if (e instanceof Error && onReadError) { 135 | throw onReadError(e); 136 | } 137 | throw e; 138 | } 139 | } 140 | 141 | async arrayBuffer(): Promise { 142 | const { buffer } = await this.consumeBody('arrayBuffer'); 143 | return buffer; 144 | } 145 | 146 | async text(): Promise { 147 | const body = await this.consumeBody('text'); 148 | return toString(body); 149 | } 150 | 151 | async json(): Promise { 152 | const body = await this.consumeBody('json'); 153 | return JSON.parse(toString(body)) as any; 154 | } 155 | } 156 | 157 | function normalizeBody(body: BodyInit): BodyInitNormalized { 158 | if ( 159 | !body || 160 | typeof body === 'string' || 161 | body instanceof Uint8Array || 162 | body instanceof Readable 163 | ) { 164 | return body ?? null; 165 | } 166 | if (body instanceof URLSearchParams) { 167 | return body.toString(); 168 | } 169 | return readableFromWeb(body); 170 | } 171 | 172 | function toStream(body: Uint8Array | Readable | string): Readable { 173 | if (body instanceof Uint8Array || typeof body === 'string') { 174 | return Readable.from(body, { objectMode: false }); 175 | } 176 | return body; 177 | } 178 | 179 | function toString(body: Uint8Array): string { 180 | return new TextDecoder().decode(body); 181 | } 182 | -------------------------------------------------------------------------------- /packages/node/src/web-io/Headers.ts: -------------------------------------------------------------------------------- 1 | export type HeadersInit = 2 | | Array<[string, string]> 3 | | Record 4 | | Headers; 5 | 6 | export class Headers { 7 | // TODO: Consider internally storing as a Node-style object, so we can easily 8 | // return it without iterating so often. We might also want to store along 9 | // side it a lower-case -> mixed-case mapping. 10 | // Consider tracking copy-on-write or some other way to do less copying. 11 | readonly headers = new Map]>(); 12 | 13 | constructor(init?: HeadersInit) { 14 | // TODO: Should be able to iterate this without using private `.headers` 15 | if (init instanceof Headers) { 16 | for (const [key, [name, values]] of init.headers) { 17 | this.headers.set(key, [name, values.slice()]); 18 | } 19 | } else if (Array.isArray(init)) { 20 | for (const [name, value] of init) { 21 | this.append(name, value); 22 | } 23 | } else if (init) { 24 | for (const [name, value] of Object.entries(init)) { 25 | const key = name.toLowerCase(); 26 | this.headers.set(key, [name, [value]]); 27 | } 28 | } 29 | } 30 | 31 | has(name: string) { 32 | return this.headers.has(name.toLowerCase()); 33 | } 34 | 35 | get(name: string) { 36 | const entry = this.headers.get(name.toLowerCase()); 37 | if (entry) { 38 | // TODO: This is wrong / not spec compliant. Should .join() 39 | return entry[1][0] ?? null; 40 | } 41 | return null; 42 | } 43 | 44 | set(name: string, value: string) { 45 | const key = name.toLowerCase(); 46 | this.headers.set(key, [name, [value]]); 47 | } 48 | 49 | append(name: string, value: string) { 50 | const { headers } = this; 51 | const key = name.toLowerCase(); 52 | const existing = headers.get(key); 53 | if (existing) { 54 | existing[1].push(value); 55 | } else { 56 | headers.set(key, [name, [value]]); 57 | } 58 | } 59 | 60 | forEach( 61 | fn: (value: string, key: string, container: Headers) => void, 62 | thisArg?: object, 63 | ) { 64 | for (const [_key, [name, values]] of this.headers) { 65 | const value = values.join(', '); 66 | thisArg ? fn.call(thisArg, value, name, this) : fn(value, name, this); 67 | } 68 | } 69 | 70 | keys() { 71 | // TODO: Should we preserve case here? 72 | return this.headers.keys(); 73 | } 74 | 75 | values() { 76 | return transformIterator(this.headers.values(), ([_name, values]) => 77 | values.join(', '), 78 | ); 79 | } 80 | 81 | entries() { 82 | return transformIterator( 83 | this.headers.values(), 84 | ([name, values]) => [name, values.join(', ')] as const, 85 | ); 86 | } 87 | 88 | [Symbol.iterator]() { 89 | return this.entries(); 90 | } 91 | } 92 | 93 | function transformIterator( 94 | upstreamIterator: IterableIterator, 95 | transform: (input: T) => U, 96 | ): IterableIterator { 97 | const iterator = { 98 | [Symbol.iterator]: () => iterator, 99 | next: () => { 100 | const result = upstreamIterator.next(); 101 | return result.done 102 | ? result 103 | : { done: false, value: transform(result.value) }; 104 | }, 105 | }; 106 | return iterator; 107 | } 108 | -------------------------------------------------------------------------------- /packages/node/src/web-io/Request.ts: -------------------------------------------------------------------------------- 1 | import { HttpError } from '../core/HttpError'; 2 | import type { RequestOptions } from '../types'; 3 | 4 | import { Headers, type HeadersInit } from './Headers'; 5 | import { Body, type BodyInit } from './Body'; 6 | 7 | // Same as Express 8 | const TOO_LARGE = { status: 413, message: 'Request Entity Too Large' }; 9 | const SIZE_MISMATCH = { 10 | status: 400, 11 | message: 'Request body size did not match content-length header', 12 | }; 13 | 14 | export type RequestInit = { 15 | method?: string; 16 | headers?: HeadersInit; 17 | body?: BodyInit; 18 | options?: RequestOptions; 19 | }; 20 | 21 | export class Request extends Body { 22 | readonly method: string; 23 | readonly url: string; 24 | readonly headers: Headers; 25 | 26 | constructor(url: string, init?: RequestInit) { 27 | const { bodyParserMaxBufferSize: maxBufferSize } = init?.options ?? {}; 28 | const headers = new Headers(init?.headers); 29 | const expectedSize = getContentLength(headers); 30 | super(init?.body, { 31 | maxBufferSize, 32 | expectedSize, 33 | onReadError: (error) => { 34 | if (error.name === 'MaxSizeExceededError') { 35 | return new HttpError(TOO_LARGE); 36 | } 37 | if (error.name === 'ExpectedSizeMismatchError') { 38 | return new HttpError(SIZE_MISMATCH); 39 | } 40 | return error; 41 | }, 42 | }); 43 | this.url = url; 44 | this.method = init?.method?.toUpperCase() ?? 'GET'; 45 | this.headers = headers; 46 | } 47 | } 48 | 49 | function getContentLength(headers: Headers) { 50 | const contentLength = headers.get('content-length'); 51 | if (contentLength != null) { 52 | const parsed = parseInt(contentLength, 10); 53 | if (isFinite(parsed)) { 54 | return parsed; 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /packages/node/src/web-io/Response.ts: -------------------------------------------------------------------------------- 1 | import { Headers, type HeadersInit } from './Headers'; 2 | import { Body, type BodyInit } from './Body'; 3 | 4 | type RedirectStatus = 301 | 302 | 303 | 307 | 308; 5 | 6 | export type ResponseInit = { 7 | headers?: HeadersInit; 8 | status?: number; 9 | statusText?: string; 10 | }; 11 | 12 | export class Response extends Body { 13 | readonly status: number; 14 | readonly statusText: string; 15 | readonly headers: Headers; 16 | 17 | constructor(body?: BodyInit, init?: ResponseInit) { 18 | super(body); 19 | const { status, statusText } = init ?? {}; 20 | this.status = status ?? 200; 21 | this.statusText = statusText ?? ''; 22 | const headers = new Headers(init?.headers); 23 | if (!headers.has('Content-Type')) { 24 | const contentType = getDefaultContentType(body); 25 | if (contentType) { 26 | headers.set('Content-Type', contentType); 27 | } 28 | } 29 | this.headers = headers; 30 | } 31 | 32 | get ok() { 33 | return this.status >= 200 && this.status <= 299; 34 | } 35 | 36 | static redirect(url: string, status?: RedirectStatus) { 37 | return new Response('', { 38 | status: status ?? 302, 39 | // Note: express would percent-encode this URL using npm.im/encodeurl 40 | // In the spec, there is a well-defined set of valid characters, see https://github.com/nodejs/undici/blob/0ef0e265e1c8edf2614f058ea1a4224349680e99/lib/fetch/util.js#L116 41 | // Invalid characters include, anything above 0x7e, anything below 0x21 or 42 | // any of the following 17 characters: ()<>@,;:\"/[]?={} 43 | headers: { Location: url }, 44 | }); 45 | } 46 | 47 | // Note: This will throw if payload has circular references 48 | static json(payload: unknown, init?: ResponseInit) { 49 | const body = JSON.stringify(payload) ?? 'null'; 50 | const headers = new Headers(init?.headers); 51 | headers.set('Content-Type', enc('application/json')); 52 | return new Response(body, { 53 | ...init, 54 | headers, 55 | }); 56 | } 57 | } 58 | 59 | function getDefaultContentType(body: BodyInit) { 60 | if (typeof body === 'string') { 61 | return enc('text/plain'); 62 | } 63 | if (body instanceof URLSearchParams) { 64 | return enc('application/x-www-form-urlencoded'); 65 | } 66 | return; 67 | } 68 | 69 | /** Add encoding (charset) to content-type value */ 70 | function enc(contentType: string) { 71 | return contentType + ';charset=UTF-8'; 72 | } 73 | -------------------------------------------------------------------------------- /packages/node/src/web-io/__tests__/Body.test.ts: -------------------------------------------------------------------------------- 1 | import { TextEncoder } from 'util'; 2 | import { Readable } from 'stream'; 3 | 4 | import { Body } from '../Body'; 5 | 6 | describe('Body', () => { 7 | it('should initialize with null', async () => { 8 | const body = new Body(null); 9 | expect(body.bodyRaw).toBe(null); 10 | expect(body.body).toBe(null); 11 | const text = await body.text(); 12 | expect(text).toBe(''); 13 | }); 14 | 15 | it('should initialize with undefined', async () => { 16 | const body = new Body(undefined); 17 | expect(body.bodyRaw).toBe(null); 18 | expect(body.body).toBe(null); 19 | const text = await body.text(); 20 | expect(text).toBe(''); 21 | }); 22 | 23 | it('should initialize with string', async () => { 24 | const body = new Body('Hello world'); 25 | expect(body.bodyRaw).toBe('Hello world'); 26 | expect(body.body).toBeInstanceOf(Readable); 27 | const text = await body.text(); 28 | expect(text).toBe('Hello world'); 29 | }); 30 | 31 | it('should initialize with buffer', async () => { 32 | const body = new Body(Buffer.from('Hello world')); 33 | expect(body.bodyRaw).toBeInstanceOf(Buffer); 34 | expect(body.body).toBeInstanceOf(Readable); 35 | const text = await body.text(); 36 | expect(text).toBe('Hello world'); 37 | }); 38 | 39 | it('should initialize with readable stream', async () => { 40 | const body = new Body(Readable.from('Hello world', { objectMode: false })); 41 | expect(body.bodyRaw).toBeInstanceOf(Readable); 42 | expect(body.body).toBeInstanceOf(Readable); 43 | const text = await body.text(); 44 | expect(text).toBe('Hello world'); 45 | }); 46 | 47 | const nodeVersionMajor = Number(process.version.slice(1).split('.')[0]); 48 | 49 | if (nodeVersionMajor < 16) { 50 | it('should not support web stream (Node v14)', async () => { 51 | expect(() => { 52 | createReadableStream('Hello world'); 53 | }).toThrow(`Cannot find module 'stream/web'`); 54 | }); 55 | } 56 | 57 | if (nodeVersionMajor === 16) { 58 | it('should throw on web stream (Node v16)', async () => { 59 | const readableStream = createReadableStream('Hello world'); 60 | expect(() => { 61 | // eslint-disable-next-line no-new 62 | new Body(readableStream); 63 | }).toThrow('Readable.fromWeb() is only available in Node v18 and above'); 64 | }); 65 | } 66 | 67 | if (nodeVersionMajor >= 18) { 68 | it('should initialize with web stream (Node v18+)', async () => { 69 | const readableStream = createReadableStream('Hello world'); 70 | const body = new Body(readableStream); 71 | expect(body.bodyRaw).toBeInstanceOf(Readable); 72 | expect(body.body).toBeInstanceOf(Readable); 73 | const text = await body.text(); 74 | expect(text).toBe('Hello world'); 75 | }); 76 | } 77 | }); 78 | 79 | function createReadableStream(string: string) { 80 | // eslint-disable-next-line @typescript-eslint/no-var-requires 81 | const { ReadableStream } = require('stream/web'); 82 | const encoder = new TextEncoder(); 83 | 84 | return new ReadableStream({ 85 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 86 | start(controller: any) { 87 | controller.enqueue(encoder.encode(string)); 88 | controller.close(); 89 | }, 90 | }); 91 | } 92 | -------------------------------------------------------------------------------- /packages/node/src/web-io/__tests__/Request.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { Readable } from 'stream'; 3 | 4 | import { Request } from '../Request'; 5 | 6 | describe('Request', () => { 7 | it('should construct with just path', () => { 8 | const request = new Request('/'); 9 | expect(request.method).toBe('GET'); 10 | expect(request.url).toBe('/'); 11 | expect(Object.fromEntries(request.headers)).toEqual({}); 12 | expect(request.body).toBe(null); 13 | expect(request.bodyRaw).toBe(null); 14 | }); 15 | 16 | it('should respect method and headers', () => { 17 | const request = new Request('/', { 18 | method: 'PUT', 19 | headers: { foo: 'foo', 'content-type': 'bar' }, 20 | }); 21 | expect(request.method).toBe('PUT'); 22 | expect(request.url).toBe('/'); 23 | expect(Object.fromEntries(request.headers)).toEqual({ 24 | foo: 'foo', 25 | 'content-type': 'bar', 26 | }); 27 | expect(request.body).toBe(null); 28 | expect(request.bodyRaw).toBe(null); 29 | }); 30 | 31 | it('should construct with a null body', () => { 32 | const request = new Request('/', { body: null }); 33 | expect(request.method).toBe('GET'); 34 | expect(request.url).toBe('/'); 35 | expect(Object.fromEntries(request.headers)).toEqual({}); 36 | expect(request.body).toBe(null); 37 | expect(request.bodyRaw).toBe(null); 38 | }); 39 | 40 | it('should construct with a string body', () => { 41 | const request = new Request('/', { body: 'foo' }); 42 | expect(request.method).toBe('GET'); 43 | expect(request.url).toBe('/'); 44 | expect(Object.fromEntries(request.headers)).toEqual({}); 45 | expect(request.body instanceof Readable).toBe(true); 46 | expect(request.bodyRaw).toBe('foo'); 47 | }); 48 | 49 | it('should construct with a buffer body', () => { 50 | const buffer = Buffer.from('foo'); 51 | const request = new Request('/foo', { method: 'post', body: buffer }); 52 | expect(request.method).toBe('POST'); 53 | expect(Object.fromEntries(request.headers)).toEqual({}); 54 | expect(request.body instanceof Readable).toBe(true); 55 | expect(request.bodyRaw).toBe(buffer); 56 | }); 57 | 58 | it('should construct with a stream body', async () => { 59 | const stream = Readable.from('foo', { objectMode: false }); 60 | const request = new Request('/foo', { body: stream }); 61 | expect(Object.fromEntries(request.headers)).toEqual({}); 62 | expect(request.body).toBe(stream); 63 | expect(request.bodyRaw).toBe(stream); 64 | expect(request.bodyUsed).toBe(false); 65 | const text = await request.text(); 66 | expect(text).toBe('foo'); 67 | expect(request.bodyUsed).toBe(true); 68 | await expect(request.text()).rejects.toThrow( 69 | `TypeError: Failed to execute 'text' on 'Request': body stream already read`, 70 | ); 71 | }); 72 | 73 | it('should parse a json body from buffer', async () => { 74 | const buffer = Buffer.from(JSON.stringify({ foo: 1 })); 75 | const request = new Request('/foo', { 76 | method: 'post', 77 | headers: { 'content-type': 'application/json' }, 78 | body: buffer, 79 | }); 80 | expect(Object.fromEntries(request.headers)).toEqual({ 81 | 'content-type': 'application/json', 82 | }); 83 | expect(request.body instanceof Readable).toBe(true); 84 | expect(request.bodyRaw).toBe(buffer); 85 | expect(request.bodyUsed).toBe(false); 86 | const parsed = await request.json(); 87 | expect(parsed).toEqual({ foo: 1 }); 88 | expect(request.bodyUsed).toBe(true); 89 | await expect(request.text()).rejects.toThrow( 90 | `TypeError: Failed to execute 'text' on 'Request': body stream already read`, 91 | ); 92 | }); 93 | 94 | it('should parse a json body from stream', async () => { 95 | const stream = Readable.from(JSON.stringify({ foo: 1 }), { 96 | objectMode: false, 97 | }); 98 | const request = new Request('/foo', { method: 'get', body: stream }); 99 | expect(Object.fromEntries(request.headers)).toEqual({}); 100 | const parsed = await request.json(); 101 | expect(parsed).toEqual({ foo: 1 }); 102 | expect(request.bodyUsed).toBe(true); 103 | await expect(request.json()).rejects.toThrow( 104 | `TypeError: Failed to execute 'json' on 'Request': body stream already read`, 105 | ); 106 | }); 107 | 108 | it('should throw if unable to parse json', async () => { 109 | const request = new Request('/foo', { 110 | method: 'post', 111 | body: '[1, 2', 112 | }); 113 | await expect(request.json()).rejects.toThrow( 114 | `Unexpected end of JSON input`, 115 | ); 116 | }); 117 | 118 | it('should throw if content-length smaller than body size', async () => { 119 | const request = new Request('/foo', { 120 | method: 'post', 121 | headers: { 'content-length': '2' }, 122 | body: 'foo', 123 | }); 124 | await expect(request.text()).rejects.toThrow( 125 | `Request body size did not match content-length header`, 126 | ); 127 | }); 128 | 129 | it('should throw if content-length greater than body size', async () => { 130 | const request = new Request('/foo', { 131 | method: 'post', 132 | headers: { 'content-length': '4' }, 133 | body: 'foo', 134 | }); 135 | await expect(request.text()).rejects.toThrow( 136 | `Request body size did not match content-length header`, 137 | ); 138 | }); 139 | 140 | it('should throw if body size exceeds maximum allowed', async () => { 141 | const request = new Request('/foo', { 142 | method: 'post', 143 | headers: { 'content-length': '4' }, 144 | body: 'x'.repeat(1001), 145 | options: { bodyParserMaxBufferSize: 1000 }, 146 | }); 147 | let error: any; 148 | try { 149 | await request.text(); 150 | } catch (e) { 151 | error = e; 152 | } 153 | expect(error.status).toBe(413); 154 | expect(error.message).toBe('Request Entity Too Large'); 155 | }); 156 | }); 157 | -------------------------------------------------------------------------------- /packages/node/src/web-io/__tests__/Response.test.ts: -------------------------------------------------------------------------------- 1 | import { Readable } from 'stream'; 2 | 3 | import { Response } from '../Response'; 4 | 5 | describe('Response', () => { 6 | it('should construct with no args', () => { 7 | const response = new Response(); 8 | expect(response.body).toBe(null); 9 | expect(response.bodyRaw).toBe(null); 10 | expect(Object.fromEntries(response.headers)).toEqual({}); 11 | }); 12 | 13 | it('should construct with undefined', () => { 14 | const response = new Response(undefined); 15 | expect(Object.fromEntries(response.headers)).toEqual({}); 16 | expect(response.bodyUsed).toBe(false); 17 | expect(response.body).toBe(null); 18 | expect(response.bodyRaw).toBe(null); 19 | expect(response.bodyUsed).toBe(false); 20 | }); 21 | 22 | it('should construct with null', () => { 23 | const response = new Response(null); 24 | expect(Object.fromEntries(response.headers)).toEqual({}); 25 | expect(response.body).toBe(null); 26 | expect(response.bodyRaw).toBe(null); 27 | }); 28 | 29 | it('should construct with string', async () => { 30 | const response = new Response('foo'); 31 | expect(Object.fromEntries(response.headers)).toEqual({ 32 | 'Content-Type': 'text/plain;charset=UTF-8', 33 | }); 34 | expect(response.body instanceof Readable).toBe(true); 35 | expect(response.bodyRaw).toEqual('foo'); 36 | expect(response.bodyUsed).toBe(false); 37 | const text = await response.text(); 38 | expect(text).toBe('foo'); 39 | expect(response.bodyUsed).toBe(true); 40 | await expect(response.text()).rejects.toThrow( 41 | `TypeError: Failed to execute 'text' on 'Response': body stream already read`, 42 | ); 43 | }); 44 | 45 | it('should honor headers', async () => { 46 | const response = new Response('foo', { 47 | headers: { foo: 'foo', 'content-type': 'bar' }, 48 | }); 49 | expect(Object.fromEntries(response.headers)).toEqual({ 50 | foo: 'foo', 51 | 'content-type': 'bar', 52 | }); 53 | expect(response.bodyRaw).toEqual('foo'); 54 | expect(response.bodyUsed).toBe(false); 55 | const text = await response.text(); 56 | expect(text).toBe('foo'); 57 | }); 58 | 59 | it('should construct with buffer', async () => { 60 | const response = new Response(Buffer.from('foo')); 61 | expect(Object.fromEntries(response.headers)).toEqual({}); 62 | expect(response.body instanceof Readable).toBe(true); 63 | const stream = response.body; 64 | expect(response.body).toBe(stream); 65 | expect(Buffer.isBuffer(response.bodyRaw)).toBe(true); 66 | expect(response.bodyUsed).toBe(false); 67 | const text = await response.text(); 68 | expect(text).toBe('foo'); 69 | expect(response.bodyUsed).toBe(true); 70 | await expect(response.text()).rejects.toThrow( 71 | `TypeError: Failed to execute 'text' on 'Response': body stream already read`, 72 | ); 73 | }); 74 | 75 | it('should construct with stream', async () => { 76 | const stream = Readable.from('foo', { objectMode: false }); 77 | const response = new Response(stream); 78 | expect(Object.fromEntries(response.headers)).toEqual({}); 79 | expect(response.body).toBe(stream); 80 | expect(response.bodyRaw).toBe(stream); 81 | expect(response.bodyUsed).toBe(false); 82 | const text = await response.text(); 83 | expect(text).toBe('foo'); 84 | expect(response.bodyUsed).toBe(true); 85 | await expect(response.text()).rejects.toThrow( 86 | `TypeError: Failed to execute 'text' on 'Response': body stream already read`, 87 | ); 88 | }); 89 | 90 | it('should construct with Response.json()', async () => { 91 | const response = Response.json({ foo: 1 }); 92 | expect(Object.fromEntries(response.headers)).toEqual({ 93 | 'Content-Type': 'application/json;charset=UTF-8', 94 | }); 95 | expect(response.body instanceof Readable).toBe(true); 96 | expect(response.bodyRaw).toBe(JSON.stringify({ foo: 1 })); 97 | expect(response.bodyUsed).toBe(false); 98 | const parsed = await response.json(); 99 | expect(parsed).toEqual({ foo: 1 }); 100 | expect(response.bodyUsed).toBe(true); 101 | await expect(response.text()).rejects.toThrow( 102 | `TypeError: Failed to execute 'text' on 'Response': body stream already read`, 103 | ); 104 | await expect(response.json()).rejects.toThrow( 105 | `TypeError: Failed to execute 'json' on 'Response': body stream already read`, 106 | ); 107 | }); 108 | 109 | it('should parse JSON string regardless of content-type', async () => { 110 | const response = new Response(JSON.stringify({ foo: 1 })); 111 | expect(Object.fromEntries(response.headers)).toEqual({ 112 | 'Content-Type': 'text/plain;charset=UTF-8', 113 | }); 114 | const parsed = await response.json(); 115 | expect(parsed).toEqual({ foo: 1 }); 116 | }); 117 | 118 | it('should parse JSON buffer regardless of content-type', async () => { 119 | const response = new Response(Buffer.from(JSON.stringify({ foo: 1 }))); 120 | expect(Object.fromEntries(response.headers)).toEqual({}); 121 | const parsed = await response.json(); 122 | expect(parsed).toEqual({ foo: 1 }); 123 | }); 124 | 125 | it('should throw on invalid JSON', async () => { 126 | const response = new Response(Buffer.from('foo')); 127 | expect(Object.fromEntries(response.headers)).toEqual({}); 128 | await expect(response.json()).rejects.toThrow(`Unexpected token`); 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /packages/node/src/web-io/index.ts: -------------------------------------------------------------------------------- 1 | export { Request, type RequestInit } from './Request'; 2 | export { Response, type ResponseInit } from './Response'; 3 | export { Headers, type HeadersInit } from './Headers'; 4 | export { type BodyInit } from './Body'; 5 | -------------------------------------------------------------------------------- /packages/node/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "build", 5 | "declaration": true, 6 | "declarationDir": "build/dts", 7 | "emitDeclarationOnly": true 8 | }, 9 | "exclude": ["src/**/*.test.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /packages/node/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "types": ["vitest/globals"] 6 | }, 7 | "include": ["src"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/node/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | globals: true, 6 | setupFiles: ['src/test/setup.ts'], 7 | }, 8 | resolve: { 9 | preserveSymlinks: true, 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /publish.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | yarn build 4 | 5 | if [ $? -eq 0 ]; then 6 | pushd packages/bun/build 7 | npm publish 8 | popd 9 | pushd packages/cfw/build 10 | npm publish 11 | popd 12 | pushd packages/node/build/node 13 | npm publish 14 | popd 15 | pushd packages/node/build/express 16 | npm publish 17 | popd 18 | fi 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2022", 4 | "lib": ["es2022"], 5 | "module": "esnext", 6 | "esModuleInterop": true, 7 | "moduleResolution": "node", 8 | "preserveSymlinks": true, 9 | "skipLibCheck": true, 10 | "strict": true, 11 | "noUncheckedIndexedAccess": true, 12 | "exactOptionalPropertyTypes": true, 13 | "noFallthroughCasesInSwitch": true, 14 | "isolatedModules": true, 15 | "forceConsistentCasingInFileNames": true 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turborepo.org/schema.json", 3 | "pipeline": { 4 | "lint": { 5 | "outputs": [] 6 | }, 7 | "typecheck": { 8 | "outputs": [] 9 | }, 10 | "format:check": { 11 | "outputs": [] 12 | }, 13 | "unit": { 14 | "outputs": [] 15 | }, 16 | "version": { 17 | "outputs": [] 18 | }, 19 | "build": { 20 | "outputs": [] 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ -z "$1" ]; then 4 | echo "Please provide an argument: major, minor or patch" 5 | exit 1 6 | fi 7 | 8 | npm version $1 --no-git-tag-version 9 | --------------------------------------------------------------------------------