diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5330070 --- /dev/null +++ b/.gitignore @@ -0,0 +1,177 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +dist/ + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Caches + +.cache + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..f679b49 --- /dev/null +++ b/.npmignore @@ -0,0 +1,25 @@ +.git +.github +.gitignore +.prettierrc +.cjs.swcrc +.es.swcrc +bun.lockb + +node_modules +tsconfig.json +pnpm-lock.yaml +jest.config.js +nodemon.json + +tests +src +test +CHANGELOG.md +.eslintrc.js +tsconfig.cjs.json +tsconfig.esm.json +tsconfig.dts.json + +build.ts +src diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9c2a3aa --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,12 @@ +{ + "editor.formatOnSave": true, + "[javascript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[json]": { + "editor.defaultFormatter": "biomejs.biome" + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5df146a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Igor Morozov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..49a9992 --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +# elysia-accepts + +Plugin for [Elysia](https://github.com/elysiajs/elysia) for content negotiation based on Accept headers based on `@tinyhttp/accepts` which is based on old good `negotiator`. + +## Installation + +```sh +bun a elysia-accepts +``` + +```ts +import { accepts } from "elysia-accepts"; + +const app = new Elysia() + .use(accepts()); +``` + +No options currently available and plugin is always registered in global scope. + +## Context + +### Type + +```ts +const app = new Elysia() + .use(accepts()) + .get("/", (ctx) => ctx.type("text/html")); // Returns "html" if acceptable +``` + +```ts +const app = new Elysia() + .use(accepts()) + .get("/", (ctx) => ctx.type(["text/html", "application/json"])); + // Returns the first acceptable or false if none +``` + +```ts +const app = new Elysia() + .use(accepts()) + .get("/", (ctx) => ctx.type(["html", "json"])); + // Can pass file extension. In this case "html" returned if acceptable. +``` + +```ts +const app = new Elysia() + .use(accepts()) + .get("/", (ctx) => ctx.types); // Returns the list of all acceptable encodings +``` + +### Encoding + +```ts +const app = new Elysia() + .use(accepts()) + .get("/", (ctx) => ctx.encoding("gzip")); // Returns "gzip" if acceptable +``` + +```ts +const app = new Elysia() + .use(accepts()) + .get("/", (ctx) => ctx.encoding(["gzip", "deflate"])); // Returns the first acceptable or false if none +``` + +```ts +const app = new Elysia() + .use(accepts()) + .get("/", (ctx) => ctx.encodings); // Returns the list of all acceptable encodings +``` + +### Languages + +```ts +const app = new Elysia() + .use(accepts()) + .get("/", (ctx) => ctx.language("en")); // Returns "en" if acceptable +``` + +```ts +const app = new Elysia() + .use(accepts()) + .get("/", (ctx) => ctx.language(["en", "ru"])); // Returns the first acceptable or false if none +``` + +```ts +const app = new Elysia() + .use(accepts()) + .get("/", (ctx) => ctx.languages); // Returns the list of all acceptable languages +``` + +## Guard + +### Per Route + +```ts +const app = new Elysia() + .use(accepts()) + .get("/", (ctx) => 'hi', { types: ['text/plain'] }); + // Returns 406 if plain text is not acceptable +``` + +### Global + +```ts +const app = new Elysia() + .use(accepts()) + .guard({ types: ['text/plain'] }) + .get("/", (ctx) => 'hi'); + // Returns 406 if plain text is not acceptable +``` diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..57aab2b --- /dev/null +++ b/biome.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "formatter": { + "indentStyle": "space", + "indentWidth": 2 + }, + "json": { + "formatter": { + "trailingCommas": "none", + "indentStyle": "space", + "indentWidth": 4 + } + } +} diff --git a/build.ts b/build.ts new file mode 100644 index 0000000..ef8794e --- /dev/null +++ b/build.ts @@ -0,0 +1,37 @@ +import { $ } from "bun"; +import { build, type Options } from "tsup"; + +await $`rm -rf dist`; + +const tsupConfig: Options = { + entry: ["src/**/*.ts"], + splitting: false, + sourcemap: false, + clean: true, + bundle: true, +} satisfies Options; + +await Promise.all([ + // ? tsup esm + build({ + outDir: "dist", + format: "esm", + target: "node20", + cjsInterop: false, + ...tsupConfig, + }), + // ? tsup cjs + build({ + outDir: "dist/cjs", + format: "cjs", + target: "node20", + // dts: true, + ...tsupConfig, + }), +]); + +await $`tsc --project tsconfig.dts.json`; + +await Promise.all([$`cp dist/*.d.ts dist/cjs`]); + +process.exit(); diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..b20b319 Binary files /dev/null and b/bun.lockb differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..4432bfd --- /dev/null +++ b/package.json @@ -0,0 +1,48 @@ +{ + "name": "elysia-accepts", + "description": "Elysia plugin for accept headers parsing and content negotiation", + "version": "1.0.0", + "author": { + "name": "Igor Morozov", + "url": "https://github.com/morigs", + "email": "morozov.ig.s@gmail.com" + }, + "repository": { + "type": "git", + "url": "https://github.com/morigs/elysia-accepts" + }, + "main": "./dist/cjs/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/cjs/index.js" + } + }, + "bugs": "https://github.com/morigs/elysia-accepts/issues", + "homepage": "https://github.com/morigs/elysia-accepts", + "keywords": ["elysia", "accept", "negotiation"], + "license": "MIT", + "scripts": { + "test": "bun test", + "build": "bun build.ts", + "lint": "biome lint", + "release": "npm run build && npm run test && npm publish --access public" + }, + "devDependencies": { + "@biomejs/biome": "^1.8.3", + "@types/bun": "latest", + "elysia": "^1.1.4", + "tsup": "^8.2.3" + }, + "peerDependencies": { + "typescript": "^5.0.0", + "elysia": "^1.1.4" + }, + "dependencies": { + "@tinyhttp/accepts": "^2.2.2" + } +} diff --git a/src/accept.spec.ts b/src/accept.spec.ts new file mode 100644 index 0000000..03ea91e --- /dev/null +++ b/src/accept.spec.ts @@ -0,0 +1,320 @@ +import { expect, test, describe } from "bun:test"; +import Elysia from "elysia"; +import { accepts } from "."; + +describe("accept context", () => { + describe("charset", () => { + test("should return the first accepted charset", async () => { + const app = new Elysia() + .use(accepts()) + .get("/", (ctx) => ctx.charset("utf-8")); + + const req = new Request("http://localhost/", { + headers: { "accept-charset": "*" }, + }); + const res = await app.handle(req).then((res) => res.text()); + + expect(res).toBe("utf-8"); + }); + + test("should return false when nothing is accepted", async () => { + const app = new Elysia() + .use(accepts()) + .get("/", (ctx) => ctx.charset("utf-8")); + + const req = new Request("http://localhost/", { + headers: { "accept-charset": "unknown" }, + }); + const res = await app.handle(req).then((res) => res.text()); + + expect(res).toBe("false"); + }); + + test("should return list when no params", async () => { + const app = new Elysia().use(accepts()).get("/", (ctx) => ctx.charset); + + const req = new Request("http://localhost/", { + headers: { "accept-charset": "utf8, win1251" }, + }); + const res = await app.handle(req).then((res) => res.json()); + + expect(res).toEqual(["utf8", "win1251"]); + }); + + test("should return list", async () => { + const app = new Elysia().use(accepts()).get("/", (ctx) => ctx.charsets); + + const req = new Request("http://localhost/", { + headers: { "accept-charset": "utf8, win1251" }, + }); + const res = await app.handle(req).then((res) => res.json()); + + expect(res).toEqual(["utf8", "win1251"]); + }); + }); + + describe("language", () => { + test("should return the first accepted language", async () => { + const app = new Elysia() + .use(accepts()) + .get("/", (ctx) => ctx.language("en")); + + const req = new Request("http://localhost/", { + headers: { "accept-language": "ru, en" }, + }); + const res = await app.handle(req).then((res) => res.text()); + + expect(res).toBe("en"); + }); + + test("should return false when nothing is accepted", async () => { + const app = new Elysia() + .use(accepts()) + .get("/", (ctx) => ctx.language("en")); + + const req = new Request("http://localhost/", { + headers: { "accept-language": "ru" }, + }); + const res = await app.handle(req).then((res) => res.text()); + + expect(res).toBe("false"); + }); + + test("should return list when no params", async () => { + const app = new Elysia().use(accepts()).get("/", (ctx) => ctx.language); + + const req = new Request("http://localhost/", { + headers: { "accept-language": "ru, en" }, + }); + const res = await app.handle(req).then((res) => res.json()); + + expect(res).toEqual(["ru", "en"]); + }); + + test("should return list", async () => { + const app = new Elysia().use(accepts()).get("/", (ctx) => ctx.languages); + + const req = new Request("http://localhost/", { + headers: { "accept-language": "ru, en" }, + }); + const res = await app.handle(req).then((res) => res.json()); + + expect(res).toEqual(["ru", "en"]); + }); + }); + + describe("encoding", () => { + test("should return the first accepted encoding", async () => { + const app = new Elysia() + .use(accepts()) + .get("/", (ctx) => ctx.encoding("gzip")); + + const req = new Request("http://localhost/", { + headers: { "accept-encoding": "deflate, gzip" }, + }); + const res = await app.handle(req).then((res) => res.text()); + + expect(res).toBe("gzip"); + }); + + test("should return false when nothing is accepted", async () => { + const app = new Elysia() + .use(accepts()) + .get("/", (ctx) => ctx.encoding("gzip")); + + const req = new Request("http://localhost/", { + headers: { "accept-encoding": "deflate" }, + }); + const res = await app.handle(req).then((res) => res.text()); + + expect(res).toBe("false"); + }); + + test("should return list with identity when no params", async () => { + const app = new Elysia().use(accepts()).get("/", (ctx) => ctx.encoding); + + const req = new Request("http://localhost/", { + headers: { "accept-encoding": "deflate, gzip" }, + }); + const res = await app.handle(req).then((res) => res.json()); + + expect(res).toEqual(["deflate", "gzip", "identity"]); + }); + + test("should return list with identity", async () => { + const app = new Elysia().use(accepts()).get("/", (ctx) => ctx.encodings); + + const req = new Request("http://localhost/", { + headers: { "accept-encoding": "deflate, gzip" }, + }); + const res = await app.handle(req).then((res) => res.json()); + + expect(res).toEqual(["deflate", "gzip", "identity"]); + }); + }); + + describe("type", () => { + test("should return the first accepted type", async () => { + const app = new Elysia() + .use(accepts()) + .get("/", (ctx) => ctx.type("text/html")); + + const req = new Request("http://localhost/", { + headers: { accept: "text/html, application/json" }, + }); + const res = await app.handle(req).then((res) => res.text()); + + expect(res).toBe("text/html"); + }); + + test("should return the first accepted type for star", async () => { + const app = new Elysia() + .use(accepts()) + .get("/", (ctx) => ctx.type("text/html")); + + const req = new Request("http://localhost/", { + headers: { accept: "*/*" }, + }); + const res = await app.handle(req).then((res) => res.text()); + + expect(res).toBe("text/html"); + }); + + test("should return the first accepted type in short form", async () => { + const app = new Elysia() + .use(accepts()) + .get("/", (ctx) => ctx.type("json")); + + const req = new Request("http://localhost/", { + headers: { accept: "text/html, application/json" }, + }); + const res = await app.handle(req).then((res) => res.text()); + + expect(res).toBe("json"); + }); + + test("should return false when nothing is accepted", async () => { + const app = new Elysia() + .use(accepts()) + .get("/", (ctx) => ctx.type("xml")); + + const req = new Request("http://localhost/", { + headers: { accept: "text/html, application/json" }, + }); + const res = await app.handle(req).then((res) => res.text()); + + expect(res).toBe("false"); + }); + + test("should return list when no params", async () => { + const app = new Elysia().use(accepts()).get("/", (ctx) => ctx.type); + + const req = new Request("http://localhost/", { + headers: { accept: "text/html, application/json" }, + }); + const res = await app.handle(req).then((res) => res.json()); + + expect(res).toEqual(["text/html", "application/json"]); + }); + + test("should return list", async () => { + const app = new Elysia().use(accepts()).get("/", (ctx) => ctx.types); + + const req = new Request("http://localhost/", { + headers: { accept: "text/html, application/json" }, + }); + const res = await app.handle(req).then((res) => res.json()); + + expect(res).toEqual(["text/html", "application/json"]); + }); + }); +}); + +describe("guard", () => { + describe("per route", () => { + test("passes when type is accepted", async () => { + const app = new Elysia() + .use(accepts()) + .get("/", () => "OK", { types: ["text/plain"] }); + + const req = new Request("http://localhost/", { + headers: { accept: "text/plain, application/json" }, + }); + const res = await app.handle(req).then((res) => res.text()); + + expect(res).toBe("OK"); + }); + + test("passes for star", async () => { + const app = new Elysia() + .use(accepts()) + .get("/", () => "OK", { types: ["text/plain"] }); + + const req = new Request("http://localhost/", { + headers: { accept: "*/*" }, + }); + const res = await app.handle(req).then((res) => res.text()); + + expect(res).toBe("OK"); + }); + + test("406 when not accepted", async () => { + const app = new Elysia() + .use(accepts()) + .get("/", () => "OK", { types: ["text/plain"] }); + + const req = new Request("http://localhost/", { + headers: { accept: "application/json" }, + }); + const res = await app.handle(req); + + expect(res.status).toBe(406); + expect(res.text()).resolves.toBe("Not Acceptable"); + }); + }); + + describe("global", () => { + test("passes when type is accepted", async () => { + const app = new Elysia() + .use(accepts()) + .guard({ types: ["text/plain"] }) + .get("/", () => "OK"); + + const req = new Request("http://localhost/", { + headers: { accept: "text/plain, application/json" }, + }); + const res = await app.handle(req).then((res) => res.text()); + + expect(res).toBe("OK"); + }); + + test("passes for star", async () => { + const app = new Elysia() + .use(accepts()) + .guard({ types: ["text/plain"] }) + .get("/", () => "OK"); + + const req = new Request("http://localhost/", { + headers: { accept: "*/*" }, + }); + const res = await app.handle(req).then((res) => res.text()); + + expect(res).toBe("OK"); + }); + + test("406 when not accepted", async () => { + const app = new Elysia() + .use(accepts()) + .guard({ types: ["text/plain"] }) + .get("/", () => "OK"); + + const req = new Request("http://localhost/", { + headers: { accept: "application/json" }, + }); + const res = await app.handle(req); + + expect(res.status).toBe(406); + expect(res.text()).resolves.toBe("Not Acceptable"); + }); + }); +}); diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..e38e8b2 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,94 @@ +import Elysia from "elysia"; +import { Accepts } from "@tinyhttp/accepts"; + +export function accepts() { + return new Elysia({ name: "accepts" }) + .derive({ as: "global" }, ({ headers }) => { + const accept = new Accepts({ headers }); + + return { + /** Return the first accepted charset. + * When used as property acts the same as `charsets`. + * + * @param charsets {string[]} The charsets to accept. + * @returns {string[] | string | false} The first accepted charset or false if nothing is accepted. + * @deprecated Do not use this header. Browsers omit this header and servers should ignore it. + */ + charset: accept.charset.bind(accept), + + /** Return the charsets that the request accepts, in the order of the client's preference (most preferred first). + * Can be used as both property and method. + * + * @returns {string[]} The charsets that the request accepts, in the order of the client's preference (most preferred first). + * @deprecated Do not use this header. Browsers omit this header and servers should ignore it. + */ + charsets: accept.charsets.bind(accept), + + /** Return the first accepted encoding. + * If nothing in encodings is accepted, then false is returned. + * When used as property acts the same as `encodings`. + * + * @param encodings {string[]} The encodings to accept. + * @returns {string[] | string | false} The first accepted encoding or false if nothing is accepted. + */ + encoding: accept.encoding.bind(accept), + + /** Return the encodings that the request accepts, + * in the order of the client's preference (most preferred first). + * Can be used as both property and method. + * + * @returns {string[]} The encodings that the request accepts, in the order of the client's preference (most preferred first). + */ + encodings: accept.encodings.bind(accept), + + /** Return the first accepted language. + * If nothing in languages is accepted, then false is returned. + * When used as property acts the same as `languages`. + * + * @param languages {string[]} The languages to accept. + * @returns {string[] | string | false} Return the first accepted language. If nothing in languages is accepted, then false is returned. + */ + language: accept.language.bind(accept), + + /** Return the languages that the request accepts, + * in the order of the client's preference (most preferred first). + * Can be used as both property and method. + * + * @returns {string[]} Return the languages that the request accepts, in the order of the client's preference (most preferred first). + */ + languages: accept.languages.bind(accept), + + /** Return the first accepted type. + * If nothing in types is accepted, then false is returned. + * When used as property acts the same as `types`. + * + * @param types {string[]} The types to accept. + * @returns {string[]} The first accepted type. + * It is returned as the same text as what appears in the types array. + * If nothing in types is accepted, then false is returned. + */ + type: accept.type.bind(accept), + + /** Return the types that the request accepts, in the order of the client's preference (most preferred first). + * Can be used as both property and method. + * + * @returns {string[]} The types that the request accepts, in the order of the client's preference (most preferred first). + */ + types: accept.types.bind(accept), + }; + }) + .macro(({ onBeforeHandle }) => ({ + /** Check that the client accepts the specified types. + * If not, return 406. + * + * @param types {string[]} The types to accept + */ + types(types: string[]) { + onBeforeHandle({ insert: "before" }, ({ type, error }) => { + if (type && !type(types)) { + return error(406, "Not Acceptable"); + } + }); + }, + })); +} diff --git a/tsconfig.dts.json b/tsconfig.dts.json new file mode 100644 index 0000000..f56e2ed --- /dev/null +++ b/tsconfig.dts.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "preserveSymlinks": true, + "target": "ES2021", + "lib": [ + "ESNext" + ], + "resolveJsonModule": true, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./dist", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + }, + "exclude": [ + "node_modules", + "test", + "dist", + "build.ts" + ] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..238655f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}